diff --git a/crates/doot-cli/src/commands/apply.rs b/crates/doot-cli/src/commands/apply.rs index 20712d9..4c18781 100644 --- a/crates/doot-cli/src/commands/apply.rs +++ b/crates/doot-cli/src/commands/apply.rs @@ -1,6 +1,6 @@ use super::{find_config_file, parse_config, type_check}; 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::evaluator::{DotfileConfig, DotfilesPattern, DotfilesSource, HookConfig}; use doot_lang::{DotfileConflict, Evaluator, validate_dotfile_targets}; @@ -121,6 +121,28 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: if full_source.is_dir() { 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_changes = false; @@ -310,85 +332,15 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: } } - // Dry-run: show what would be done and exit - 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 = 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(()); - } + let dry_prefix = if dry_run { "[dry-run] " } else { "" }; // 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() { - println!("\nNothing to deploy (all files synced)."); + println!("\n{}all dotfiles synced, nothing to deploy", dry_prefix); } else { // Filter parallel batches to only include items in deploy_set let filtered_batches: Vec> = validation @@ -406,56 +358,92 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: let deployer = Deployer::new(config, result.sandbox); - let pb = ProgressBar::new(deploy_set.len() as u64); - pb.set_style( - ProgressStyle::default_bar() - .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") - .unwrap() - .progress_chars("=>-"), - ); - - pb.set_message("deploying dotfiles"); + let progress = if !dry_run { + let pb = ProgressBar::new(deploy_set.len() as u64); + pb.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") + .unwrap() + .progress_chars("=>-"), + ); + pb.set_message("deploying dotfiles"); + Some(pb) + } else { + None + }; let deploy_result = - deployer.deploy_batches(&result.dotfiles, &filtered_batches, Some(&pb))?; - pb.finish_with_message("done"); + deployer.deploy_batches(&result.dotfiles, &filtered_batches, progress.as_ref())?; - println!("\ndeployment complete:"); - println!(" deployed: {}", deploy_result.deployed.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" - ); + if let Some(pb) = progress { + pb.finish_with_message("done"); } - for skipped in &deploy_result.skipped { - println!(" [skip] {} ({})", skipped.target.display(), skipped.reason); - } + if dry_run { + // 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 { - tracing::error!( - source = %error.source.display(), - target = %error.target.display(), - error = %error.error, - "deployment failed" - ); + if !deploy_result.errors.is_empty() { + println!("\n{}errors:", dry_prefix); + for error in &deploy_result.errors { + println!(" {} ({})", error.target.display(), error.error); + } + } + } 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_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() { - // Run before_package hooks - run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?; + if !dry_run { + run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?; + } if let Some(manager) = doot_core::package::detect_package_manager() { - // Filter out already installed packages let mut to_install = Vec::new(); let mut already_installed = Vec::new(); @@ -469,39 +457,55 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: } if !already_installed.is_empty() { - tracing::debug!( - count = already_installed.len(), - "packages already installed" - ); - for pkg in &already_installed { - tracing::debug!(package = %pkg, "already installed"); + if dry_run { + println!("\n{}packages already installed:", dry_prefix); + for pkg in &already_installed { + println!(" {}", pkg); + } + } 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() { - println!( - "\nall {} packages already installed", - already_installed.len() - ); + if !dry_run { + println!( + "\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 { println!("\ninstalling {} packages...", to_install.len()); manager.install(&to_install)?; println!("installed {} packages", to_install.len()); } - // Record all managed packages in state (both newly installed and already installed) - let mut state = StateStore::new(&state_file); - let manager_name = manager.name(); - for pkg in to_install.iter().chain(already_installed.iter()) { - state.record_package(pkg, manager_name); + if !dry_run { + let mut state = StateStore::new(&state_file); + let manager_name = manager.name(); + for pkg in to_install.iter().chain(already_installed.iter()) { + state.record_package(pkg, manager_name); + } + state.save()?; } - state.save()?; } else { println!("no supported package manager found"); } - // Run after_package hooks - run_hooks(&result.hooks, HookStage::AfterPackage, &hook_env)?; + if !dry_run { + run_hooks(&result.hooks, HookStage::AfterPackage, &hook_env)?; + } } // Prune packages removed from config @@ -521,35 +525,42 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: .collect(); if !to_prune.is_empty() { - println!("\n{} package(s) removed from config:", to_prune.len()); - for (name, _) in &to_prune { - println!(" {}", name); - } + if dry_run { + println!("\n{}would uninstall removed packages:", dry_prefix); + 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(); - for (name, mgr_name) in &to_prune { - let should_uninstall = if prune { true } else { prompt_uninstall(name)? }; + let mut uninstalled = Vec::new(); + for (name, mgr_name) in &to_prune { + let should_uninstall = if prune { true } else { prompt_uninstall(name)? }; - if should_uninstall { - if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) { - mgr.uninstall(std::slice::from_ref(name))?; - println!("uninstalled {}", name); - uninstalled.push(name.clone()); - } else { - tracing::warn!( - package = %name, manager = %mgr_name, - "cannot uninstall: package manager not available" - ); + if should_uninstall { + if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) { + mgr.uninstall(std::slice::from_ref(name))?; + println!("uninstalled {}", name); + uninstalled.push(name.clone()); + } else { + tracing::warn!( + package = %name, manager = %mgr_name, + "cannot uninstall: package manager not available" + ); + } } } - } - if !uninstalled.is_empty() { - let mut state = StateStore::new(&state_file); - for name in &uninstalled { - state.remove_package(name); + if !uninstalled.is_empty() { + let mut state = StateStore::new(&state_file); + for name in &uninstalled { + state.remove_package(name); + } + state.save()?; } - state.save()?; } } } @@ -767,6 +778,7 @@ fn expand_dotfile_patterns( link_patterns: pattern.link_patterns.clone(), copy_patterns: pattern.copy_patterns.clone(), exclude_paths: vec![], + exclude_sources: vec![], }); } } @@ -775,9 +787,11 @@ fn expand_dotfile_patterns( /// Merges explicit dotfile blocks into glob-expanded entries. /// -/// Two merge cases: +/// Three merge cases: /// 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, glob_count: usize) { let total = dotfiles.len(); let explicit_end = total - glob_count; @@ -801,7 +815,7 @@ fn merge_specializations(dotfiles: &mut Vec, 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 glob_idx in glob_start..total { if glob_to_remove.contains(&glob_idx) { @@ -811,9 +825,24 @@ fn merge_specializations(dotfiles: &mut Vec, glob_count: usize) { let exp_target = dotfiles[exp_idx].target.clone(); 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 { 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()); + } } } diff --git a/crates/doot-core/src/deploy/mod.rs b/crates/doot-core/src/deploy/mod.rs index a91ccd9..2a8c118 100644 --- a/crates/doot-core/src/deploy/mod.rs +++ b/crates/doot-core/src/deploy/mod.rs @@ -165,7 +165,9 @@ impl Deployer { } } - self.state.lock().unwrap().save()?; + if !self.config.dry_run { + self.state.lock().unwrap().save()?; + } 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) } @@ -320,16 +324,40 @@ impl Deployer { let mut any_updated = false; let mut any_created = false; - for (src_file, tgt_file, status) in changed_files { - // Skip files that have explicit specializations - if dotfile - .exclude_paths - .iter() - .any(|ex| tgt_file.starts_with(ex) || *ex == tgt_file) - { - continue; - } + // Filter out excluded files before processing + let changed_files: Vec<_> = changed_files + .into_iter() + .filter(|(src_file, tgt_file, _)| { + // Skip files excluded by target path + if dotfile + .exclude_paths + .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 { SyncStatus::NotDeployed | SyncStatus::TargetMissing diff --git a/crates/doot-core/src/lib.rs b/crates/doot-core/src/lib.rs index f922854..be3109b 100644 --- a/crates/doot-core/src/lib.rs +++ b/crates/doot-core/src/lib.rs @@ -12,7 +12,7 @@ pub mod package; pub mod state; pub use config::Config; -pub use deploy::{DeployResult, Deployer}; +pub use deploy::{DeployAction, DeployResult, Deployer}; pub use encryption::AgeEncryption; pub use hooks::HookRunner; pub use os::OsInfo; diff --git a/crates/doot-lang/src/ast.rs b/crates/doot-lang/src/ast.rs index 39b4691..f39da10 100644 --- a/crates/doot-lang/src/ast.rs +++ b/crates/doot-lang/src/ast.rs @@ -145,6 +145,12 @@ pub struct Dotfile { pub deploy: DeployMode, pub link_patterns: Vec, pub copy_patterns: Vec, + /// Span of the source expression (for error reporting). + pub source_span: Option, + /// Span of the target expression (for error reporting). + pub target_span: Option, + /// Span of the when expression (for error reporting). + pub when_span: Option, } /// Package installation declaration. diff --git a/crates/doot-lang/src/builtins/io.rs b/crates/doot-lang/src/builtins/io.rs index b92a8bb..6357d9d 100644 --- a/crates/doot-lang/src/builtins/io.rs +++ b/crates/doot-lang/src/builtins/io.rs @@ -174,7 +174,7 @@ pub fn path_extension(args: &[Value]) -> Result { } #[tracing::instrument(level = "trace", skip_all)] -pub fn home() -> Result { +pub fn home_dir() -> Result { Ok(Value::Path(dirs::home_dir().unwrap_or_default())) } diff --git a/crates/doot-lang/src/builtins/mod.rs b/crates/doot-lang/src/builtins/mod.rs index 03f45f9..ea04e9a 100644 --- a/crates/doot-lang/src/builtins/mod.rs +++ b/crates/doot-lang/src/builtins/mod.rs @@ -80,7 +80,7 @@ pub async fn call_builtin( "path_parent" => io::path_parent(args), "path_filename" => io::path_filename(args), "path_extension" => io::path_extension(args), - "home" => io::home(), + "home_dir" => io::home_dir(), "config_dir" => io::config_dir(), "data_dir" => io::data_dir(), "cache_dir" => io::cache_dir(), diff --git a/crates/doot-lang/src/evaluator.rs b/crates/doot-lang/src/evaluator.rs index 86ccf4b..cf1cc6c 100644 --- a/crates/doot-lang/src/evaluator.rs +++ b/crates/doot-lang/src/evaluator.rs @@ -319,8 +319,10 @@ pub struct DotfileConfig { pub deploy: DeployMode, pub link_patterns: Vec, pub copy_patterns: Vec, - /// 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, + /// Source paths to skip during directory deploy (when explicit block targets elsewhere). + pub exclude_sources: Vec, } /// Evaluated package configuration. @@ -679,6 +681,7 @@ impl Evaluator { link_patterns: dotfile.link_patterns.clone(), copy_patterns: dotfile.copy_patterns.clone(), exclude_paths: vec![], + exclude_sources: vec![], }); } } diff --git a/crates/doot-lang/src/macros.rs b/crates/doot-lang/src/macros.rs index bfd7806..f311a55 100644 --- a/crates/doot-lang/src/macros.rs +++ b/crates/doot-lang/src/macros.rs @@ -66,6 +66,9 @@ impl MacroExpander { deploy: dotfile.deploy, link_patterns: dotfile.link_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 { diff --git a/crates/doot-lang/src/parser.rs b/crates/doot-lang/src/parser.rs index 504129d..7c39a41 100644 --- a/crates/doot-lang/src/parser.rs +++ b/crates/doot-lang/src/parser.rs @@ -224,7 +224,7 @@ impl Parser { fn dotfile_parser() -> impl chumsky::Parser> { let field = Self::field_name_parser() .then_ignore(just(Token::Eq)) - .then(Self::expr_parser()); + .then(Self::expr_parser().map_with_span(|expr, span| (expr, span))); just(Token::Dotfile) .ignore_then(just(Token::Colon)) @@ -249,12 +249,24 @@ impl Parser { deploy: DeployMode::default(), link_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() { - "source" => dotfile.source = value, - "target" => dotfile.target = value, - "when" => dotfile.when = Some(value), + "source" => { + dotfile.source = 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" => { if let Expr::Literal(Literal::Bool(b)) = value { dotfile.template = Some(b); diff --git a/crates/doot-lang/src/planner/scheduler.rs b/crates/doot-lang/src/planner/scheduler.rs index e809621..3e1cab5 100644 --- a/crates/doot-lang/src/planner/scheduler.rs +++ b/crates/doot-lang/src/planner/scheduler.rs @@ -297,6 +297,7 @@ mod tests { link_patterns: Vec::new(), copy_patterns: Vec::new(), exclude_paths: Vec::new(), + exclude_sources: Vec::new(), } } diff --git a/crates/doot-lang/src/type_checker.rs b/crates/doot-lang/src/type_checker.rs index 673a4f1..ed3c6e8 100644 --- a/crates/doot-lang/src/type_checker.rs +++ b/crates/doot-lang/src/type_checker.rs @@ -285,7 +285,8 @@ impl TypeChecker { } 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 if !matches!( source_ty, @@ -294,24 +295,26 @@ impl TypeChecker { self.errors.push(TypeError::TypeMismatch { expected: "path, str, or [path]".to_string(), 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(_)) { self.errors.push(TypeError::TypeMismatch { expected: "path".to_string(), got: target_ty.display(), - span: stmt.span.clone(), + span: target_span.clone(), }); } 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) { self.errors.push(TypeError::TypeMismatch { expected: "bool".to_string(), 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, "file_exists" | "dir_exists" | "is_symlink" => Type::Bool, "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 } "path_join" | "path_parent" | "path_filename" | "path_extension" | "read_link" => {