use super::{find_config_file, parse_config, type_check}; use doot_core::{ Config, deploy::{Linker, TemplateEngine}, state::{DeployMode, StateStore}, }; use doot_lang::Evaluator; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::Command; #[tracing::instrument(skip_all, fields(target = %target, auto_apply, skip_prompt))] pub fn run( config_path: Option, target: String, auto_apply: bool, skip_prompt: bool, ) -> anyhow::Result<()> { let path = find_config_file(config_path)?; let source = std::fs::read_to_string(&path)?; let program = parse_config(&path)?; type_check(&program, &source, &path.display().to_string())?; let mut evaluator = Evaluator::new(); let result = evaluator.eval_sync(&program)?; let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); let config = Config::default(); let state = StateStore::new(&config.state_file); let target_path = expand_tilde(&target); let (source_file, dotfile) = find_source_and_dotfile(&target_path, &result.dotfiles, &source_dir, &state)?; tracing::debug!(source = %source_file.display(), "editing source file"); // Get hash before editing let hash_before = hash_file(&source_file); // Open in editor let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); let status = Command::new(&editor).arg(&source_file).status()?; if !status.success() { anyhow::bail!("editor exited with non-zero status"); } // Check if file changed let hash_after = hash_file(&source_file); if hash_before == hash_after { println!("no changes made"); return Ok(()); } // Determine if we should apply let should_apply = if auto_apply { true } else if skip_prompt { false } else { prompt_apply()? }; if should_apply { if let Some(df) = dotfile { apply_single(&source_file, &df.target, df, &config)?; println!("applied changes to {}", df.target.display()); } else { println!("hint: run 'doot apply' to deploy changes"); } } else { println!("hint: run 'doot apply' to deploy changes"); } Ok(()) } fn prompt_apply() -> anyhow::Result { print!("Apply changes? [y/N] "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; Ok(input.trim().eq_ignore_ascii_case("y") || input.trim().eq_ignore_ascii_case("yes")) } fn hash_file(path: &PathBuf) -> String { std::fs::read(path) .map(|content| blake3::hash(&content).to_hex().to_string()) .unwrap_or_default() } #[tracing::instrument(skip_all, fields(source = %source.display(), target = %target.display()))] fn apply_single( source: &PathBuf, target: &PathBuf, dotfile: &doot_lang::evaluator::DotfileConfig, config: &Config, ) -> anyhow::Result<()> { let deploy_mode = match dotfile.deploy { doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy, doot_lang::evaluator::DeployMode::Link => DeployMode::Link, }; let mut state = StateStore::new(&config.state_file); // Handle templates specially if dotfile.template { if let Some(parent) = target.parent() { std::fs::create_dir_all(parent)?; } let content = std::fs::read_to_string(source)?; let engine = TemplateEngine::new(); let rendered = engine .render(&content) .map_err(|e| anyhow::anyhow!("template error: {}", e))?; std::fs::write(target, rendered)?; tracing::debug!( source = %source.display(), target = %target.display(), "rendered template" ); state.record_deployment_with_template(source, target, DeployMode::Copy, true); state.save()?; return Ok(()); } match deploy_mode { DeployMode::Link => { let linker = Linker::new(config.clone()); linker.link(source, target)?; tracing::debug!( source = %source.display(), target = %target.display(), "linked" ); } DeployMode::Copy => { if let Some(parent) = target.parent() { std::fs::create_dir_all(parent)?; } if source.is_dir() { copy_dir_recursive(source, target)?; } else { std::fs::copy(source, target)?; } tracing::debug!( source = %source.display(), target = %target.display(), "copied" ); } } state.record_deployment(source, target, deploy_mode); state.save()?; Ok(()) } fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> std::io::Result<()> { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; let ty = entry.file_type()?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); if ty.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; } else { std::fs::copy(&src_path, &dst_path)?; } } Ok(()) } #[tracing::instrument(level = "trace")] fn expand_tilde(path: &str) -> PathBuf { if path.starts_with("~/") && let Some(home) = dirs::home_dir() { return home.join(&path[2..]); } PathBuf::from(path) } #[tracing::instrument(skip_all)] fn find_source_and_dotfile<'a>( target: &PathBuf, dotfiles: &'a [doot_lang::evaluator::DotfileConfig], source_dir: &Path, state: &StateStore, ) -> anyhow::Result<(PathBuf, Option<&'a doot_lang::evaluator::DotfileConfig>)> { // Exact match with dotfile targets for df in dotfiles { if &df.target == target { return Ok((source_dir.join(&df.source), Some(df))); } } // Match by name let target_str = target.to_string_lossy(); for df in dotfiles { let target_name = df .target .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); if target_name == target_str.as_ref() { return Ok((source_dir.join(&df.source), Some(df))); } let source_name = df .source .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); if source_name == target_str.as_ref() { return Ok((source_dir.join(&df.source), Some(df))); } } // State lookup if let Some(record) = state.get_deployment(target) { return Ok((record.source.clone(), None)); } // Partial path matching for df in dotfiles { if target.starts_with(&df.target) { let relative = target.strip_prefix(&df.target).unwrap_or(target); return Ok((source_dir.join(&df.source).join(relative), Some(df))); } } anyhow::bail!( "could not find source for '{}'\n\nAvailable dotfiles:\n{}", target.display(), dotfiles .iter() .map(|df| format!(" {} -> {}", df.source.display(), df.target.display())) .collect::>() .join("\n") ) }