feat(apply): consolidate logic in --dry-run

This commit is contained in:
Ray Sinurat 2026-02-06 15:40:03 -06:00
parent 289aa82ded
commit f23a9b2653
11 changed files with 268 additions and 183 deletions

View file

@ -1,6 +1,6 @@
use super::{find_config_file, parse_config, type_check}; use super::{find_config_file, parse_config, type_check};
use doot_core::state::{StateStore, SyncStatus}; use doot_core::state::{StateStore, SyncStatus};
use doot_core::{Config, Deployer}; use doot_core::{Config, DeployAction, Deployer};
use doot_lang::ast::HookStage; use doot_lang::ast::HookStage;
use doot_lang::evaluator::{DotfileConfig, DotfilesPattern, DotfilesSource, HookConfig}; use doot_lang::evaluator::{DotfileConfig, DotfilesPattern, DotfilesSource, HookConfig};
use doot_lang::{DotfileConflict, Evaluator, validate_dotfile_targets}; use doot_lang::{DotfileConflict, Evaluator, validate_dotfile_targets};
@ -121,6 +121,28 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
if full_source.is_dir() { if full_source.is_dir() {
let changed_files = state.get_changed_files_in_dir(&full_source, &dotfile.target); let changed_files = state.get_changed_files_in_dir(&full_source, &dotfile.target);
// Filter out excluded files before checking for changes
let changed_files: Vec<_> = changed_files
.into_iter()
.filter(|(src, tgt, _)| {
if dotfile
.exclude_paths
.iter()
.any(|ex| tgt.starts_with(ex) || ex == tgt)
{
return false;
}
if dotfile
.exclude_sources
.iter()
.any(|ex| src.strip_prefix(&full_source) == Ok(ex.as_path()))
{
return false;
}
true
})
.collect();
let mut has_real_conflicts = false; let mut has_real_conflicts = false;
let mut has_changes = false; let mut has_changes = false;
@ -310,85 +332,15 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
} }
} }
// Dry-run: show what would be done and exit let dry_prefix = if dry_run { "[dry-run] " } else { "" };
if dry_run {
if deploy_set.is_empty() {
println!("\n[dry-run] all dotfiles synced, nothing to deploy");
} else {
println!("\n[dry-run] would deploy:");
for &idx in &deploy_set {
let dotfile = &result.dotfiles[idx];
println!(
" {} -> {}",
dotfile.source.display(),
dotfile.target.display()
);
}
}
if !result.packages.is_empty() {
if let Some(manager) = doot_core::package::detect_package_manager() {
let mut to_install = Vec::new();
let mut already_installed = Vec::new();
for pkg in &result.packages {
if let Some(ref name) = pkg.default {
match manager.is_installed(name) {
Ok(true) => already_installed.push(name.clone()),
_ => to_install.push(name.clone()),
}
}
}
if !already_installed.is_empty() {
println!("\n[dry-run] packages already installed:");
for pkg in &already_installed {
println!(" {}", pkg);
}
}
if !to_install.is_empty() {
println!("\n[dry-run] would install packages:");
for pkg in &to_install {
println!(" {}", pkg);
}
} else if already_installed.is_empty() {
println!("\n[dry-run] no packages to install");
}
} else {
println!("\n[dry-run] no supported package manager found");
}
}
// Show packages that would be pruned
{
let configured_names: std::collections::HashSet<String> = result
.packages
.iter()
.filter_map(|p| p.default.clone())
.collect();
let state_for_prune = StateStore::new(&state_file);
let to_prune: Vec<_> = state_for_prune
.get_all_packages()
.iter()
.filter(|(name, _)| !configured_names.contains(*name))
.collect();
if !to_prune.is_empty() {
println!("\n[dry-run] would uninstall removed packages:");
for (name, _) in &to_prune {
println!(" {}", name);
}
}
}
return Ok(());
}
// Run before_deploy hooks // Run before_deploy hooks
run_hooks(&result.hooks, HookStage::BeforeDeploy, &hook_env)?; if !dry_run {
run_hooks(&result.hooks, HookStage::BeforeDeploy, &hook_env)?;
}
if deploy_set.is_empty() { if deploy_set.is_empty() {
println!("\nNothing to deploy (all files synced)."); println!("\n{}all dotfiles synced, nothing to deploy", dry_prefix);
} else { } else {
// Filter parallel batches to only include items in deploy_set // Filter parallel batches to only include items in deploy_set
let filtered_batches: Vec<Vec<usize>> = validation let filtered_batches: Vec<Vec<usize>> = validation
@ -406,56 +358,92 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
let deployer = Deployer::new(config, result.sandbox); let deployer = Deployer::new(config, result.sandbox);
let pb = ProgressBar::new(deploy_set.len() as u64); let progress = if !dry_run {
pb.set_style( let pb = ProgressBar::new(deploy_set.len() as u64);
ProgressStyle::default_bar() pb.set_style(
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") ProgressStyle::default_bar()
.unwrap() .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.progress_chars("=>-"), .unwrap()
); .progress_chars("=>-"),
);
pb.set_message("deploying dotfiles"); pb.set_message("deploying dotfiles");
Some(pb)
} else {
None
};
let deploy_result = let deploy_result =
deployer.deploy_batches(&result.dotfiles, &filtered_batches, Some(&pb))?; deployer.deploy_batches(&result.dotfiles, &filtered_batches, progress.as_ref())?;
pb.finish_with_message("done");
println!("\ndeployment complete:"); if let Some(pb) = progress {
println!(" deployed: {}", deploy_result.deployed.len()); pb.finish_with_message("done");
println!(" skipped: {}", deploy_result.skipped.len());
println!(" errors: {}", deploy_result.errors.len());
for deployed in &deploy_result.deployed {
tracing::debug!(
source = %deployed.source.display(),
target = %deployed.target.display(),
"deployed"
);
} }
for skipped in &deploy_result.skipped { if dry_run {
println!(" [skip] {} ({})", skipped.target.display(), skipped.reason); // Dry-run: show what the conflict-check decided needs deploying
} println!("\n{}would deploy:", dry_prefix);
for &idx in &deploy_set {
let dotfile = &result.dotfiles[idx];
println!(
" {} -> {}",
dotfile.source.display(),
dotfile.target.display()
);
}
for error in &deploy_result.errors { if !deploy_result.errors.is_empty() {
tracing::error!( println!("\n{}errors:", dry_prefix);
source = %error.source.display(), for error in &deploy_result.errors {
target = %error.target.display(), println!(" {} ({})", error.target.display(), error.error);
error = %error.error, }
"deployment failed" }
); } else {
let active: Vec<_> = deploy_result
.deployed
.iter()
.filter(|d| !matches!(d.action, DeployAction::Unchanged))
.collect();
println!("\ndeployment complete:");
println!(" deployed: {}", active.len());
println!(" skipped: {}", deploy_result.skipped.len());
println!(" errors: {}", deploy_result.errors.len());
for deployed in &deploy_result.deployed {
tracing::debug!(
source = %deployed.source.display(),
target = %deployed.target.display(),
"deployed"
);
}
for skipped in &deploy_result.skipped {
println!(" [skip] {} ({})", skipped.target.display(), skipped.reason);
}
for error in &deploy_result.errors {
tracing::error!(
source = %error.source.display(),
target = %error.target.display(),
error = %error.error,
"deployment failed"
);
}
} }
} }
// Run after_deploy hooks // Run after_deploy hooks
run_hooks(&result.hooks, HookStage::AfterDeploy, &hook_env)?; if !dry_run {
run_hooks(&result.hooks, HookStage::AfterDeploy, &hook_env)?;
}
// Package handling
if !result.packages.is_empty() { if !result.packages.is_empty() {
// Run before_package hooks if !dry_run {
run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?; run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?;
}
if let Some(manager) = doot_core::package::detect_package_manager() { if let Some(manager) = doot_core::package::detect_package_manager() {
// Filter out already installed packages
let mut to_install = Vec::new(); let mut to_install = Vec::new();
let mut already_installed = Vec::new(); let mut already_installed = Vec::new();
@ -469,39 +457,55 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
} }
if !already_installed.is_empty() { if !already_installed.is_empty() {
tracing::debug!( if dry_run {
count = already_installed.len(), println!("\n{}packages already installed:", dry_prefix);
"packages already installed" for pkg in &already_installed {
); println!(" {}", pkg);
for pkg in &already_installed { }
tracing::debug!(package = %pkg, "already installed"); } else {
tracing::debug!(
count = already_installed.len(),
"packages already installed"
);
for pkg in &already_installed {
tracing::debug!(package = %pkg, "already installed");
}
} }
} }
if to_install.is_empty() { if to_install.is_empty() {
println!( if !dry_run {
"\nall {} packages already installed", println!(
already_installed.len() "\nall {} packages already installed",
); already_installed.len()
);
}
} else if dry_run {
println!("\n{}would install packages:", dry_prefix);
for pkg in &to_install {
println!(" {}", pkg);
}
} else { } else {
println!("\ninstalling {} packages...", to_install.len()); println!("\ninstalling {} packages...", to_install.len());
manager.install(&to_install)?; manager.install(&to_install)?;
println!("installed {} packages", to_install.len()); println!("installed {} packages", to_install.len());
} }
// Record all managed packages in state (both newly installed and already installed) if !dry_run {
let mut state = StateStore::new(&state_file); let mut state = StateStore::new(&state_file);
let manager_name = manager.name(); let manager_name = manager.name();
for pkg in to_install.iter().chain(already_installed.iter()) { for pkg in to_install.iter().chain(already_installed.iter()) {
state.record_package(pkg, manager_name); state.record_package(pkg, manager_name);
}
state.save()?;
} }
state.save()?;
} else { } else {
println!("no supported package manager found"); println!("no supported package manager found");
} }
// Run after_package hooks if !dry_run {
run_hooks(&result.hooks, HookStage::AfterPackage, &hook_env)?; run_hooks(&result.hooks, HookStage::AfterPackage, &hook_env)?;
}
} }
// Prune packages removed from config // Prune packages removed from config
@ -521,35 +525,42 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
.collect(); .collect();
if !to_prune.is_empty() { if !to_prune.is_empty() {
println!("\n{} package(s) removed from config:", to_prune.len()); if dry_run {
for (name, _) in &to_prune { println!("\n{}would uninstall removed packages:", dry_prefix);
println!(" {}", name); for (name, _) in &to_prune {
} println!(" {}", name);
}
} else {
println!("\n{} package(s) removed from config:", to_prune.len());
for (name, _) in &to_prune {
println!(" {}", name);
}
let mut uninstalled = Vec::new(); let mut uninstalled = Vec::new();
for (name, mgr_name) in &to_prune { for (name, mgr_name) in &to_prune {
let should_uninstall = if prune { true } else { prompt_uninstall(name)? }; let should_uninstall = if prune { true } else { prompt_uninstall(name)? };
if should_uninstall { if should_uninstall {
if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) { if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) {
mgr.uninstall(std::slice::from_ref(name))?; mgr.uninstall(std::slice::from_ref(name))?;
println!("uninstalled {}", name); println!("uninstalled {}", name);
uninstalled.push(name.clone()); uninstalled.push(name.clone());
} else { } else {
tracing::warn!( tracing::warn!(
package = %name, manager = %mgr_name, package = %name, manager = %mgr_name,
"cannot uninstall: package manager not available" "cannot uninstall: package manager not available"
); );
}
} }
} }
}
if !uninstalled.is_empty() { if !uninstalled.is_empty() {
let mut state = StateStore::new(&state_file); let mut state = StateStore::new(&state_file);
for name in &uninstalled { for name in &uninstalled {
state.remove_package(name); state.remove_package(name);
}
state.save()?;
} }
state.save()?;
} }
} }
} }
@ -767,6 +778,7 @@ fn expand_dotfile_patterns(
link_patterns: pattern.link_patterns.clone(), link_patterns: pattern.link_patterns.clone(),
copy_patterns: pattern.copy_patterns.clone(), copy_patterns: pattern.copy_patterns.clone(),
exclude_paths: vec![], exclude_paths: vec![],
exclude_sources: vec![],
}); });
} }
} }
@ -775,9 +787,11 @@ fn expand_dotfile_patterns(
/// Merges explicit dotfile blocks into glob-expanded entries. /// Merges explicit dotfile blocks into glob-expanded entries.
/// ///
/// Two merge cases: /// Three merge cases:
/// 1. Same target: explicit replaces glob-expanded entry entirely. /// 1. Same target: explicit replaces glob-expanded entry entirely.
/// 2. File inside directory: adds the file's target to the directory entry's exclude_paths. /// 2. Target inside directory target: adds the file's target to exclude_paths.
/// 3. Source inside directory source: adds the file's source to exclude_sources
/// (handles cases where the explicit block targets a different location).
fn merge_specializations(dotfiles: &mut Vec<DotfileConfig>, glob_count: usize) { fn merge_specializations(dotfiles: &mut Vec<DotfileConfig>, glob_count: usize) {
let total = dotfiles.len(); let total = dotfiles.len();
let explicit_end = total - glob_count; let explicit_end = total - glob_count;
@ -801,7 +815,7 @@ fn merge_specializations(dotfiles: &mut Vec<DotfileConfig>, glob_count: usize) {
} }
} }
// Second pass: collect exclude_paths for directory entries // Second pass: collect exclusions for directory entries
for exp_idx in 0..explicit_end { for exp_idx in 0..explicit_end {
for glob_idx in glob_start..total { for glob_idx in glob_start..total {
if glob_to_remove.contains(&glob_idx) { if glob_to_remove.contains(&glob_idx) {
@ -811,9 +825,24 @@ fn merge_specializations(dotfiles: &mut Vec<DotfileConfig>, glob_count: usize) {
let exp_target = dotfiles[exp_idx].target.clone(); let exp_target = dotfiles[exp_idx].target.clone();
let glob_target = &dotfiles[glob_idx].target; let glob_target = &dotfiles[glob_idx].target;
// Target-based exclusion: explicit target is inside glob target directory
if exp_target.starts_with(glob_target) && exp_target != *glob_target { if exp_target.starts_with(glob_target) && exp_target != *glob_target {
dotfiles[glob_idx].exclude_paths.push(exp_target); dotfiles[glob_idx].exclude_paths.push(exp_target);
} }
// Source-based exclusion: explicit source is inside glob source directory
// Store path relative to the directory source so it matches after strip_prefix
let exp_source = dotfiles[exp_idx].source.clone();
let glob_source = dotfiles[glob_idx].source.clone();
if exp_source.starts_with(&glob_source)
&& exp_source != glob_source
&& let Ok(relative) = exp_source.strip_prefix(&glob_source)
{
dotfiles[glob_idx]
.exclude_sources
.push(relative.to_path_buf());
}
} }
} }

View file

@ -165,7 +165,9 @@ impl Deployer {
} }
} }
self.state.lock().unwrap().save()?; if !self.config.dry_run {
self.state.lock().unwrap().save()?;
}
Ok(result) Ok(result)
} }
@ -220,7 +222,9 @@ impl Deployer {
} }
} }
self.state.lock().unwrap().save()?; if !self.config.dry_run {
self.state.lock().unwrap().save()?;
}
Ok(result) Ok(result)
} }
@ -320,16 +324,40 @@ impl Deployer {
let mut any_updated = false; let mut any_updated = false;
let mut any_created = false; let mut any_created = false;
for (src_file, tgt_file, status) in changed_files { // Filter out excluded files before processing
// Skip files that have explicit specializations let changed_files: Vec<_> = changed_files
if dotfile .into_iter()
.exclude_paths .filter(|(src_file, tgt_file, _)| {
.iter() // Skip files excluded by target path
.any(|ex| tgt_file.starts_with(ex) || *ex == tgt_file) if dotfile
{ .exclude_paths
continue; .iter()
} .any(|ex| tgt_file.starts_with(ex) || ex == tgt_file)
{
return false;
}
// Skip files excluded by source path
if dotfile
.exclude_sources
.iter()
.any(|ex| src_file.strip_prefix(source) == Ok(ex.as_path()))
{
return false;
}
true
})
.collect();
// If all files are excluded, skip creating the directory entirely
if changed_files.is_empty() {
return Ok(DeployedFile {
source: source.to_path_buf(),
target: target.to_path_buf(),
action: DeployAction::Unchanged,
});
}
for (src_file, tgt_file, status) in changed_files {
match status { match status {
SyncStatus::NotDeployed SyncStatus::NotDeployed
| SyncStatus::TargetMissing | SyncStatus::TargetMissing

View file

@ -12,7 +12,7 @@ pub mod package;
pub mod state; pub mod state;
pub use config::Config; pub use config::Config;
pub use deploy::{DeployResult, Deployer}; pub use deploy::{DeployAction, DeployResult, Deployer};
pub use encryption::AgeEncryption; pub use encryption::AgeEncryption;
pub use hooks::HookRunner; pub use hooks::HookRunner;
pub use os::OsInfo; pub use os::OsInfo;

View file

@ -145,6 +145,12 @@ pub struct Dotfile {
pub deploy: DeployMode, pub deploy: DeployMode,
pub link_patterns: Vec<String>, pub link_patterns: Vec<String>,
pub copy_patterns: Vec<String>, pub copy_patterns: Vec<String>,
/// Span of the source expression (for error reporting).
pub source_span: Option<Span>,
/// Span of the target expression (for error reporting).
pub target_span: Option<Span>,
/// Span of the when expression (for error reporting).
pub when_span: Option<Span>,
} }
/// Package installation declaration. /// Package installation declaration.

View file

@ -174,7 +174,7 @@ pub fn path_extension(args: &[Value]) -> Result<Value, EvalError> {
} }
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
pub fn home() -> Result<Value, EvalError> { pub fn home_dir() -> Result<Value, EvalError> {
Ok(Value::Path(dirs::home_dir().unwrap_or_default())) Ok(Value::Path(dirs::home_dir().unwrap_or_default()))
} }

View file

@ -80,7 +80,7 @@ pub async fn call_builtin(
"path_parent" => io::path_parent(args), "path_parent" => io::path_parent(args),
"path_filename" => io::path_filename(args), "path_filename" => io::path_filename(args),
"path_extension" => io::path_extension(args), "path_extension" => io::path_extension(args),
"home" => io::home(), "home_dir" => io::home_dir(),
"config_dir" => io::config_dir(), "config_dir" => io::config_dir(),
"data_dir" => io::data_dir(), "data_dir" => io::data_dir(),
"cache_dir" => io::cache_dir(), "cache_dir" => io::cache_dir(),

View file

@ -319,8 +319,10 @@ pub struct DotfileConfig {
pub deploy: DeployMode, pub deploy: DeployMode,
pub link_patterns: Vec<String>, pub link_patterns: Vec<String>,
pub copy_patterns: Vec<String>, pub copy_patterns: Vec<String>,
/// Files to skip during directory deploy (specialized by explicit dotfile blocks). /// Target paths to skip during directory deploy (specialized by explicit dotfile blocks).
pub exclude_paths: Vec<PathBuf>, pub exclude_paths: Vec<PathBuf>,
/// Source paths to skip during directory deploy (when explicit block targets elsewhere).
pub exclude_sources: Vec<PathBuf>,
} }
/// Evaluated package configuration. /// Evaluated package configuration.
@ -679,6 +681,7 @@ impl Evaluator {
link_patterns: dotfile.link_patterns.clone(), link_patterns: dotfile.link_patterns.clone(),
copy_patterns: dotfile.copy_patterns.clone(), copy_patterns: dotfile.copy_patterns.clone(),
exclude_paths: vec![], exclude_paths: vec![],
exclude_sources: vec![],
}); });
} }
} }

View file

@ -66,6 +66,9 @@ impl MacroExpander {
deploy: dotfile.deploy, deploy: dotfile.deploy,
link_patterns: dotfile.link_patterns.clone(), link_patterns: dotfile.link_patterns.clone(),
copy_patterns: dotfile.copy_patterns.clone(), copy_patterns: dotfile.copy_patterns.clone(),
source_span: dotfile.source_span.clone(),
target_span: dotfile.target_span.clone(),
when_span: dotfile.when_span.clone(),
}), }),
Statement::Package(pkg) => Statement::Package(Box::new(Package { Statement::Package(pkg) => Statement::Package(Box::new(Package {

View file

@ -224,7 +224,7 @@ impl Parser {
fn dotfile_parser() -> impl chumsky::Parser<Token, Dotfile, Error = Simple<Token>> { fn dotfile_parser() -> impl chumsky::Parser<Token, Dotfile, Error = Simple<Token>> {
let field = Self::field_name_parser() let field = Self::field_name_parser()
.then_ignore(just(Token::Eq)) .then_ignore(just(Token::Eq))
.then(Self::expr_parser()); .then(Self::expr_parser().map_with_span(|expr, span| (expr, span)));
just(Token::Dotfile) just(Token::Dotfile)
.ignore_then(just(Token::Colon)) .ignore_then(just(Token::Colon))
@ -249,12 +249,24 @@ impl Parser {
deploy: DeployMode::default(), deploy: DeployMode::default(),
link_patterns: Vec::new(), link_patterns: Vec::new(),
copy_patterns: Vec::new(), copy_patterns: Vec::new(),
source_span: None,
target_span: None,
when_span: None,
}; };
for (name, value) in fields { for (name, (value, span)) in fields {
match name.as_str() { match name.as_str() {
"source" => dotfile.source = value, "source" => {
"target" => dotfile.target = value, dotfile.source = value;
"when" => dotfile.when = Some(value), dotfile.source_span = Some(span);
}
"target" => {
dotfile.target = value;
dotfile.target_span = Some(span);
}
"when" => {
dotfile.when = Some(value);
dotfile.when_span = Some(span);
}
"template" => { "template" => {
if let Expr::Literal(Literal::Bool(b)) = value { if let Expr::Literal(Literal::Bool(b)) = value {
dotfile.template = Some(b); dotfile.template = Some(b);

View file

@ -297,6 +297,7 @@ mod tests {
link_patterns: Vec::new(), link_patterns: Vec::new(),
copy_patterns: Vec::new(), copy_patterns: Vec::new(),
exclude_paths: Vec::new(), exclude_paths: Vec::new(),
exclude_sources: Vec::new(),
} }
} }

View file

@ -285,7 +285,8 @@ impl TypeChecker {
} }
Statement::Dotfile(dotfile) => { Statement::Dotfile(dotfile) => {
let source_ty = self.infer_expr(&dotfile.source, &stmt.span); let source_span = dotfile.source_span.as_ref().unwrap_or(&stmt.span);
let source_ty = self.infer_expr(&dotfile.source, source_span);
// dotfile: source accepts path, str (pattern with wildcards), or list // dotfile: source accepts path, str (pattern with wildcards), or list
if !matches!( if !matches!(
source_ty, source_ty,
@ -294,24 +295,26 @@ impl TypeChecker {
self.errors.push(TypeError::TypeMismatch { self.errors.push(TypeError::TypeMismatch {
expected: "path, str, or [path]".to_string(), expected: "path, str, or [path]".to_string(),
got: source_ty.display(), got: source_ty.display(),
span: stmt.span.clone(), span: source_span.clone(),
}); });
} }
let target_ty = self.infer_expr(&dotfile.target, &stmt.span); let target_span = dotfile.target_span.as_ref().unwrap_or(&stmt.span);
let target_ty = self.infer_expr(&dotfile.target, target_span);
if matches!(target_ty, Type::List(_)) { if matches!(target_ty, Type::List(_)) {
self.errors.push(TypeError::TypeMismatch { self.errors.push(TypeError::TypeMismatch {
expected: "path".to_string(), expected: "path".to_string(),
got: target_ty.display(), got: target_ty.display(),
span: stmt.span.clone(), span: target_span.clone(),
}); });
} }
if let Some(ref when) = dotfile.when { if let Some(ref when) = dotfile.when {
let when_ty = self.infer_expr(when, &stmt.span); let when_span = dotfile.when_span.as_ref().unwrap_or(&stmt.span);
let when_ty = self.infer_expr(when, when_span);
if !when_ty.is_compatible(&Type::Bool) { if !when_ty.is_compatible(&Type::Bool) {
self.errors.push(TypeError::TypeMismatch { self.errors.push(TypeError::TypeMismatch {
expected: "bool".to_string(), expected: "bool".to_string(),
got: when_ty.display(), got: when_ty.display(),
span: stmt.span.clone(), span: when_span.clone(),
}); });
} }
} }
@ -739,7 +742,7 @@ impl TypeChecker {
"read_file" | "read_file_lines" => Type::Str, "read_file" | "read_file_lines" => Type::Str,
"file_exists" | "dir_exists" | "is_symlink" => Type::Bool, "file_exists" | "dir_exists" | "is_symlink" => Type::Bool,
"list_dir" | "walk_dir" => Type::List(Box::new(Type::Path)), "list_dir" | "walk_dir" => Type::List(Box::new(Type::Path)),
"home" | "config_dir" | "data_dir" | "cache_dir" | "temp_dir" | "temp_file" => { "home_dir" | "config_dir" | "data_dir" | "cache_dir" | "temp_dir" | "temp_file" => {
Type::Path Type::Path
} }
"path_join" | "path_parent" | "path_filename" | "path_extension" | "read_link" => { "path_join" | "path_parent" | "path_filename" | "path_extension" | "read_link" => {