From 77b25771c3094e4b84bf98b54216fad6b0036e92 Mon Sep 17 00:00:00 2001 From: Ray Andrew Date: Fri, 20 Feb 2026 18:44:48 -0600 Subject: [PATCH] fix(bugs): bugs in template apply --- crates/doot-cli/src/commands/apply.rs | 76 +++++++++++++++++++++++--- crates/doot-cli/src/commands/status.rs | 33 ++++++++++- crates/doot-cli/tests/e2e.rs | 63 +++++++++++++++++++-- 3 files changed, 155 insertions(+), 17 deletions(-) diff --git a/crates/doot-cli/src/commands/apply.rs b/crates/doot-cli/src/commands/apply.rs index c62368e..08fbd21 100644 --- a/crates/doot-cli/src/commands/apply.rs +++ b/crates/doot-cli/src/commands/apply.rs @@ -1,4 +1,5 @@ -use super::{find_config_file, parse_config, type_check}; +use super::{decrypt_encrypted_vars_with_source_dir, find_config_file, parse_config, type_check}; +use doot_core::deploy::TemplateEngine; use doot_core::state::{StateStore, SyncStatus}; use doot_core::{Config, DeployAction, Deployer}; use doot_lang::ast::HookStage; @@ -99,6 +100,19 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: let state_file = config.state_file.clone(); let state = StateStore::new(&state_file); + // Prepare template variables early for use in both deployer and preview + let mut template_vars = evaluator.get_template_variables(); + decrypt_encrypted_vars_with_source_dir( + &result, + &config, + &mut template_vars, + Some(&source_dir), + )?; + + // Initialize preview TemplateEngine + let mut preview_engine = TemplateEngine::new(); + preview_engine.set_doot_variables(&template_vars); + // Check for conflicts before deploying (track by original index) let mut deploy_set: HashSet = HashSet::new(); let mut conflicts: Vec<(usize, SyncStatus)> = Vec::new(); @@ -195,7 +209,16 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: } } else { // Single file handling - match status { + // Check for template rendering drift when sync status is Synced and file is a template + let mut final_status = status; + if status == SyncStatus::Synced + && dotfile.template + && template_outdated(&state, &preview_engine, &full_source, &dotfile.target)? + { + final_status = SyncStatus::SourceChanged; + } + + match final_status { SyncStatus::Synced => { tracing::debug!(target = %dotfile.target.display(), "synced"); } @@ -435,13 +458,6 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: .filter(|batch| !batch.is_empty()) .collect(); - let mut template_vars = evaluator.get_template_variables(); - super::decrypt_encrypted_vars_with_source_dir( - &result, - &config, - &mut template_vars, - Some(&source_dir), - )?; let deployer = Deployer::new(config, result.sandbox, Some(&template_vars)); let progress = if !dry_run { @@ -869,6 +885,48 @@ fn common_path_prefix(paths: &[PathBuf]) -> PathBuf { prefix } +/// Renders a template file and returns its BLAKE3 hash. +fn rendered_template_hash( + engine: &TemplateEngine, + source_path: &Path, +) -> anyhow::Result> { + if !source_path.is_file() { + return Ok(None); + } + let content = std::fs::read_to_string(source_path)?; + let rendered = engine + .render(&content) + .map_err(|e| anyhow::anyhow!("template render error: {}", e))?; + let hash = blake3::hash(rendered.as_bytes()).to_hex().to_string(); + Ok(Some(hash)) +} + +/// Returns true if the rendered template output differs from the last deployed content. +pub(crate) fn template_outdated( + state: &StateStore, + engine: &TemplateEngine, + source_path: &Path, + target_path: &Path, +) -> anyhow::Result { + if !source_path.is_file() { + return Ok(false); + } + + let Some(record) = state.get_deployment(target_path) else { + return Ok(false); + }; + + if !record.template { + return Ok(false); + } + + if let Some(rendered_hash) = rendered_template_hash(engine, source_path)? { + return Ok(rendered_hash != record.target_hash); + } + + Ok(false) +} + /// Expands dotfile patterns (from `dotfiles:` blocks) into individual DotfileConfig entries. /// Returns the number of entries added. fn expand_dotfile_patterns( diff --git a/crates/doot-cli/src/commands/status.rs b/crates/doot-cli/src/commands/status.rs index efc93ce..fcd1b7d 100644 --- a/crates/doot-cli/src/commands/status.rs +++ b/crates/doot-cli/src/commands/status.rs @@ -1,4 +1,9 @@ -use super::{find_config_file, parse_config, type_check}; +use super::{ + apply::template_outdated, decrypt_encrypted_vars_with_source_dir, find_config_file, + parse_config, type_check, +}; +use doot_core::Config; +use doot_core::deploy::TemplateEngine; use doot_core::state::{StateStore, SyncStatus}; use doot_lang::Evaluator; use std::path::PathBuf; @@ -16,9 +21,24 @@ pub fn run(config_path: Option) -> anyhow::Result<()> { let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); let result = evaluator.eval_sync(&program)?; - let state_file = source_dir.join(".doot-state.json"); + + // Prepare template variables early for preview rendering + let config = Config::new(source_dir.clone()); + let state_file = config.state_file.clone(); let state = StateStore::new(&state_file); + let mut template_vars = evaluator.get_template_variables(); + decrypt_encrypted_vars_with_source_dir( + &result, + &config, + &mut template_vars, + Some(&source_dir), + )?; + + // Initialize preview TemplateEngine + let mut preview_engine = TemplateEngine::new(); + preview_engine.set_doot_variables(&template_vars); + println!("doot status"); println!("===========\n"); @@ -27,7 +47,7 @@ pub fn run(config_path: Option) -> anyhow::Result<()> { let target = &dotfile.target; let full_source = source_dir.join(&dotfile.source); - let sync = state.check_sync_status_with_config( + let mut sync = state.check_sync_status_with_config( &full_source, target, Some(dotfile.template), @@ -36,6 +56,13 @@ pub fn run(config_path: Option) -> anyhow::Result<()> { dotfile.owner.as_deref(), ); + // Check for template rendering changes + if sync == SyncStatus::Synced && dotfile.template { + if template_outdated(&state, &preview_engine, &full_source, target)? { + sync = SyncStatus::SourceChanged; + } + } + let (status, extra) = if target.is_symlink() { let link_target = std::fs::read_link(target).ok(); if link_target.as_ref() == Some(&full_source) { diff --git a/crates/doot-cli/tests/e2e.rs b/crates/doot-cli/tests/e2e.rs index d290ff7..d0b540d 100644 --- a/crates/doot-cli/tests/e2e.rs +++ b/crates/doot-cli/tests/e2e.rs @@ -15,14 +15,22 @@ impl Sandbox { Self { path } } - fn run(&self, args: &[&str]) -> std::process::Output { + fn run_with_env(&self, args: &[&str], configure: F) -> std::process::Output + where + F: FnOnce(&mut Command), + { let doot = env!("CARGO_BIN_EXE_doot"); - Command::new(doot) + let mut command = Command::new(doot); + command .args(args) .env("DOOT_HOME", &self.path) - .env("DOOT_TEST_MODE", "1") - .output() - .expect("failed to run doot") + .env("DOOT_TEST_MODE", "1"); + configure(&mut command); + command.output().expect("failed to run doot") + } + + fn run(&self, args: &[&str]) -> std::process::Output { + self.run_with_env(args, |_| {}) } fn config_dir(&self) -> PathBuf { @@ -213,6 +221,51 @@ fn test_apply_copy_unchanged_on_rerun() { assert!(!target.is_symlink(), "target should still be a copy"); } +#[test] +fn test_template_redeploys_when_env_changes() { + let sandbox = Sandbox::new("template-env-change"); + sandbox.write_config( + r#" +dotfile: + source = "templates/app.conf" + target = "~/.config/app/app.conf" + template = true +"#, + ); + sandbox.write_source("templates/app.conf", "value = {{ env.TEMPLATE_VAL }}\n"); + + let first = sandbox.run_with_env(&["apply"], |cmd| { + cmd.env("TEMPLATE_VAL", "one"); + }); + assert!(first.status.success(), "first apply failed: {:?}", first); + + let target = sandbox.path.join(".config/app/app.conf"); + assert!(target.exists(), "template target missing after first apply"); + let first_content = std::fs::read_to_string(&target).unwrap(); + assert!( + first_content.contains("one"), + "expected first render to contain env value" + ); + + let second = sandbox.run_with_env(&["apply"], |cmd| { + cmd.env("TEMPLATE_VAL", "two"); + }); + assert!(second.status.success(), "second apply failed: {:?}", second); + + let stdout = String::from_utf8_lossy(&second.stdout); + assert!( + stdout.contains("[source changed]"), + "expected source change notice, got: {}", + stdout + ); + + let updated = std::fs::read_to_string(&target).unwrap(); + assert!( + updated.contains("two"), + "template should reflect new env value" + ); +} + #[test] fn test_status_shows_state() { let sandbox = Sandbox::new("status");