use anyhow::{Context, Result}; use chrono::Utc; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use crate::state::{State, StateEntry, build_state}; use crate::theme::{enumerate_theme_files, themes_root}; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- #[derive(Debug)] pub enum PlannedAction { /// Target does not exist; create a new symlink. CreateLink { source: PathBuf, target: PathBuf }, /// Target is already the correct symlink; nothing to do. Skip { source: PathBuf, target: PathBuf }, /// Target is a managed symlink pointing to a different theme; replace it. Replace { source: PathBuf, target: PathBuf }, /// Target exists and is unmanaged; back it up then replace with symlink. BackupAndReplace { source: PathBuf, target: PathBuf }, /// Target exists and is unmanaged; blocked without `--force`. Conflict { target: PathBuf }, } pub struct ApplyPlan { pub theme_name: String, pub actions: Vec, } #[derive(Default)] pub struct ApplySummary { pub created: usize, pub replaced: usize, pub backed_up: usize, pub skipped: usize, } // --------------------------------------------------------------------------- // Path helpers // --------------------------------------------------------------------------- /// Return the effective home directory, respecting `DM_HOME` for tests. pub fn effective_home() -> Result { if let Ok(h) = std::env::var("DM_HOME") { return Ok(PathBuf::from(h)); } dirs::home_dir().context("failed to find home directory") } fn backup_base_for(timestamp: &str) -> Result { let home = effective_home()?; Ok(home .join(".config") .join("theme-manager") .join("backups") .join(timestamp)) } fn backup_path_for(backup_base: &Path, target: &Path, home: &Path) -> Result { let relative = target .strip_prefix(home) .with_context(|| format!("target {} is not under home", target.display()))?; Ok(backup_base.join(relative)) } fn ensure_parent(path: &Path) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create directory: {}", parent.display()))?; } Ok(()) } #[cfg(unix)] fn create_symlink(source: &Path, target: &Path) -> Result<()> { std::os::unix::fs::symlink(source, target).with_context(|| { format!( "failed to create symlink {} -> {}", target.display(), source.display() ) }) } #[cfg(not(unix))] fn create_symlink(_source: &Path, _target: &Path) -> Result<()> { anyhow::bail!("symlinks are only supported on Unix") } // --------------------------------------------------------------------------- // Planning // --------------------------------------------------------------------------- /// Build an `ApplyPlan` describing what `apply ` would do. /// /// Pass `force = true` to allow overwriting unmanaged files (after backup). /// Pass `current_state` so that managed targets can be replaced without `--force`. pub fn plan_apply( theme_name: &str, force: bool, current_state: Option<&State>, ) -> Result { if theme_name == "base" { anyhow::bail!("\"base\" is a reserved layer name and cannot be applied as a theme"); } let root = themes_root()?; let theme_dir = root.join(theme_name); if !theme_dir.is_dir() { anyhow::bail!("theme not found: {}", theme_name); } let home = effective_home()?; // Build a merged file map: base files first, theme files overlay (and win on conflict). // Using BTreeMap keeps the iteration order deterministic. let mut file_map: std::collections::BTreeMap = std::collections::BTreeMap::new(); let base_dir = root.join("base"); if base_dir.is_dir() { for (rel, src) in enumerate_theme_files(&base_dir)? { file_map.insert(rel, src); } } for (rel, src) in enumerate_theme_files(&theme_dir)? { file_map.insert(rel, src); } let managed_targets: HashSet = current_state .map(|s| s.entries.iter().map(|e| PathBuf::from(&e.target)).collect()) .unwrap_or_default(); let mut actions = Vec::new(); for (relative, source) in file_map { let target = home.join(&relative); match target.symlink_metadata() { Err(_) => { // Target does not exist. actions.push(PlannedAction::CreateLink { source, target }); } Ok(meta) => { if meta.file_type().is_symlink() { match fs::read_link(&target) { Ok(dest) if dest == source => { // Already pointing to the right place. actions.push(PlannedAction::Skip { source, target }); } _ => { if managed_targets.contains(&target) { // Our symlink, just re-point it. actions.push(PlannedAction::Replace { source, target }); } else if force { actions.push(PlannedAction::BackupAndReplace { source, target }); } else { actions.push(PlannedAction::Conflict { target }); } } } } else { // Regular file or directory. if force { actions.push(PlannedAction::BackupAndReplace { source, target }); } else { actions.push(PlannedAction::Conflict { target }); } } } } } Ok(ApplyPlan { theme_name: theme_name.to_string(), actions, }) } /// Return the dry-run plan as a list of human-readable lines (no I/O side effects). pub fn plan_lines(plan: &ApplyPlan) -> Vec { plan.actions .iter() .map(|action| match action { PlannedAction::CreateLink { source, target } => { format!(" create {} -> {}", target.display(), source.display()) } PlannedAction::Skip { target, .. } => { format!(" skip {} (already correct)", target.display()) } PlannedAction::Replace { source, target } => { format!(" replace {} -> {}", target.display(), source.display()) } PlannedAction::BackupAndReplace { source, target } => { format!( " backup {} then link -> {}", target.display(), source.display() ) } PlannedAction::Conflict { target } => { format!(" CONFLICT {}", target.display()) } }) .collect() } /// Print a human-readable dry-run summary of the plan. pub fn print_plan(plan: &ApplyPlan) { for line in plan_lines(plan) { println!("{line}"); } } // --------------------------------------------------------------------------- // Execution // --------------------------------------------------------------------------- enum Undo { RemoveFile(PathBuf), RestoreFile { from_backup: PathBuf, to_target: PathBuf, }, RestoreSymlink { old_dest: PathBuf, target: PathBuf, }, } /// Execute the plan. Returns the new state and a summary on success. /// On failure, attempts to roll back all completed actions before returning the error. pub fn execute_apply(plan: &ApplyPlan, verbose: bool) -> Result<(State, ApplySummary)> { // Reject if there are any conflicts. let conflicts: Vec<&PathBuf> = plan .actions .iter() .filter_map(|a| { if let PlannedAction::Conflict { target } = a { Some(target) } else { None } }) .collect(); if !conflicts.is_empty() { for t in &conflicts { eprintln!( "error: unmanaged file would be overwritten: {} (use --force)", t.display() ); } anyhow::bail!( "{} conflict(s) found; use --force to overwrite unmanaged files", conflicts.len() ); } let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); let backup_base = backup_base_for(×tamp)?; let home = effective_home()?; let mut undo_stack: Vec = Vec::new(); let mut state_entries: Vec = Vec::new(); let mut summary = ApplySummary::default(); let result: Result<()> = (|| { for action in &plan.actions { match action { PlannedAction::CreateLink { source, target } => { ensure_parent(target)?; create_symlink(source, target)?; undo_stack.push(Undo::RemoveFile(target.clone())); state_entries.push(StateEntry { source: source.display().to_string(), target: target.display().to_string(), backup: None, }); summary.created += 1; if verbose { println!(" created {}", target.display()); } } PlannedAction::Skip { source, target } => { state_entries.push(StateEntry { source: source.display().to_string(), target: target.display().to_string(), backup: None, }); summary.skipped += 1; if verbose { println!(" skipped {}", target.display()); } } PlannedAction::Replace { source, target } => { let old_dest = fs::read_link(target) .with_context(|| format!("failed to read link: {}", target.display()))?; fs::remove_file(target).with_context(|| { format!("failed to remove old link: {}", target.display()) })?; undo_stack.push(Undo::RestoreSymlink { old_dest, target: target.clone(), }); create_symlink(source, target)?; state_entries.push(StateEntry { source: source.display().to_string(), target: target.display().to_string(), backup: None, }); summary.replaced += 1; if verbose { println!(" replaced {}", target.display()); } } PlannedAction::BackupAndReplace { source, target } => { let bp = backup_path_for(&backup_base, target, &home)?; ensure_parent(&bp)?; // For broken symlinks there is no content to copy; just remove. let is_symlink = target .symlink_metadata() .map(|m| m.file_type().is_symlink()) .unwrap_or(false); let backup_made = if !is_symlink || target.exists() { fs::copy(target, &bp) .with_context(|| format!("failed to back up: {}", target.display()))?; undo_stack.push(Undo::RestoreFile { from_backup: bp.clone(), to_target: target.clone(), }); true } else { // Broken symlink — nothing to back up. false }; fs::remove_file(target) .with_context(|| format!("failed to remove: {}", target.display()))?; if !backup_made { undo_stack.push(Undo::RemoveFile(target.clone())); } create_symlink(source, target)?; state_entries.push(StateEntry { source: source.display().to_string(), target: target.display().to_string(), backup: if backup_made { Some(bp.display().to_string()) } else { None }, }); summary.backed_up += 1; summary.replaced += 1; if verbose { println!(" backed-up {}", target.display()); println!(" replaced {}", target.display()); } } PlannedAction::Conflict { .. } => unreachable!("conflicts filtered above"), } } Ok(()) })(); if let Err(e) = result { eprintln!("apply failed: {e:#}"); eprintln!("rolling back..."); rollback_undo(&undo_stack); return Err(e); } let state = build_state(plan.theme_name.clone(), state_entries); Ok((state, summary)) } fn rollback_undo(stack: &[Undo]) { for undo in stack.iter().rev() { let res: Result<()> = match undo { Undo::RemoveFile(path) => fs::remove_file(path) .with_context(|| format!("rollback: failed to remove {}", path.display())), Undo::RestoreFile { from_backup, to_target, } => { let _ = fs::remove_file(to_target); if let Some(parent) = to_target.parent() { let _ = fs::create_dir_all(parent); } fs::copy(from_backup, to_target) .map(|_| ()) .with_context(|| format!("rollback: failed to restore {}", to_target.display())) } Undo::RestoreSymlink { old_dest, target } => { let _ = fs::remove_file(target); create_symlink(old_dest, target).with_context(|| { format!("rollback: failed to restore symlink {}", target.display()) }) } }; if let Err(e) = res { eprintln!(" {e:#}"); } } } // --------------------------------------------------------------------------- // Rollback command // --------------------------------------------------------------------------- /// Restore the backup set recorded in `state`, then clear state. pub fn rollback_state(state: &State, verbose: bool) -> Result { let mut errors = 0; let mut restored = 0; for entry in &state.entries { let target = PathBuf::from(&entry.target); match &entry.backup { Some(backup_path) => { let bp = PathBuf::from(backup_path); if !bp.exists() { eprintln!( "warning: backup not found, skipping restore of {}", target.display() ); errors += 1; continue; } // Remove current target (should be our symlink). if target.symlink_metadata().is_ok() && let Err(e) = fs::remove_file(&target) { eprintln!("warning: could not remove {}: {}", target.display(), e); errors += 1; continue; } if let Err(e) = ensure_parent(&target) { eprintln!("warning: {}", e); errors += 1; continue; } match fs::copy(&bp, &target) { Ok(_) => { let _ = fs::remove_file(&bp); restored += 1; if verbose { println!(" restored {}", target.display()); } } Err(e) => { eprintln!( "error: could not restore {} from backup: {}", target.display(), e ); errors += 1; } } } None => { // No backup; this was a freshly created symlink — remove it. if target.symlink_metadata().is_ok() { match fs::remove_file(&target) { Ok(()) => { restored += 1; if verbose { println!(" removed {}", target.display()); } } Err(e) => { eprintln!("warning: could not remove {}: {}", target.display(), e); errors += 1; } } } } } } if errors > 0 { anyhow::bail!("rollback completed with {} error(s)", errors); } Ok(restored) } // --------------------------------------------------------------------------- // Manual backup command // --------------------------------------------------------------------------- /// Create a snapshot of all currently managed files. /// Returns `(backup_dir, file_count)`. pub fn create_manual_backup(state: &State) -> Result<(PathBuf, usize)> { let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); let backup_base = backup_base_for(×tamp)?; let home = effective_home()?; let mut count = 0; for entry in &state.entries { let target = PathBuf::from(&entry.target); if target.is_file() { let bp = backup_path_for(&backup_base, &target, &home)?; ensure_parent(&bp)?; match fs::copy(&target, &bp) { Ok(_) => count += 1, Err(e) => { eprintln!("warning: could not back up {}: {}", target.display(), e) } } } } Ok((backup_base, count)) } // --------------------------------------------------------------------------- // Unit tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::test_utils::ENV_MUTEX; use std::os::unix::fs::symlink; use tempfile::TempDir; struct TestEnv { pub home: TempDir, pub themes: TempDir, #[allow(dead_code)] pub state_file: PathBuf, _guard: std::sync::MutexGuard<'static, ()>, } impl TestEnv { fn new() -> Self { let guard = ENV_MUTEX.lock().unwrap(); let home = TempDir::new().unwrap(); let themes = TempDir::new().unwrap(); let state_file = home.path().join(".config/theme-manager/state.json"); unsafe { std::env::set_var("DM_HOME", home.path()); std::env::set_var("DM_THEMES_ROOT", themes.path()); std::env::set_var("DM_STATE_FILE", &state_file); } TestEnv { home, themes, state_file, _guard: guard, } } } impl Drop for TestEnv { fn drop(&mut self) { unsafe { std::env::remove_var("DM_HOME"); std::env::remove_var("DM_THEMES_ROOT"); std::env::remove_var("DM_STATE_FILE"); } } } fn make_theme_file(env: &TestEnv, theme: &str, rel: &str, content: &str) -> PathBuf { let p = env.themes.path().join(theme).join(rel); fs::create_dir_all(p.parent().unwrap()).unwrap(); fs::write(&p, content).unwrap(); p } #[test] fn plan_creates_link_for_new_target() { let env = TestEnv::new(); make_theme_file(&env, "dracula", ".zshrc", "# zsh"); let plan = plan_apply("dracula", false, None).unwrap(); assert_eq!(plan.actions.len(), 1); assert!(matches!(plan.actions[0], PlannedAction::CreateLink { .. })); } #[test] fn plan_skips_already_correct_symlink() { let env = TestEnv::new(); let source = make_theme_file(&env, "dracula", ".zshrc", "# zsh"); let target = env.home.path().join(".zshrc"); symlink(&source, &target).unwrap(); let plan = plan_apply("dracula", false, None).unwrap(); assert!(matches!(plan.actions[0], PlannedAction::Skip { .. })); } #[test] fn plan_conflicts_on_unmanaged_file_without_force() { let env = TestEnv::new(); make_theme_file(&env, "dracula", ".zshrc", "# zsh"); fs::write(env.home.path().join(".zshrc"), "existing").unwrap(); let plan = plan_apply("dracula", false, None).unwrap(); assert!(matches!(plan.actions[0], PlannedAction::Conflict { .. })); } #[test] fn plan_backup_and_replace_with_force() { let env = TestEnv::new(); make_theme_file(&env, "dracula", ".zshrc", "# zsh"); fs::write(env.home.path().join(".zshrc"), "existing").unwrap(); let plan = plan_apply("dracula", true, None).unwrap(); assert!(matches!( plan.actions[0], PlannedAction::BackupAndReplace { .. } )); } #[test] fn plan_replaces_managed_symlink_without_force() { let env = TestEnv::new(); // Set up catppuccin as current theme let cat_source = make_theme_file(&env, "catppuccin", ".zshrc", "# cat"); let target = env.home.path().join(".zshrc"); symlink(&cat_source, &target).unwrap(); let state = build_state( "catppuccin".to_string(), vec![StateEntry { source: cat_source.display().to_string(), target: target.display().to_string(), backup: None, }], ); // Now plan to switch to dracula make_theme_file(&env, "dracula", ".zshrc", "# dracula"); let plan = plan_apply("dracula", false, Some(&state)).unwrap(); assert!(matches!(plan.actions[0], PlannedAction::Replace { .. })); } #[test] fn execute_apply_creates_symlink() { let env = TestEnv::new(); make_theme_file(&env, "dracula", ".zshrc", "# zsh"); let plan = plan_apply("dracula", false, None).unwrap(); let (state, summary) = execute_apply(&plan, false).unwrap(); let target = env.home.path().join(".zshrc"); assert!(target.symlink_metadata().unwrap().file_type().is_symlink()); assert_eq!(state.active_theme, "dracula"); assert_eq!(summary.created, 1); assert_eq!(summary.skipped, 0); } #[test] fn execute_apply_with_backup() { let env = TestEnv::new(); make_theme_file(&env, "dracula", ".zshrc", "# zsh"); fs::write(env.home.path().join(".zshrc"), "existing content").unwrap(); let plan = plan_apply("dracula", true, None).unwrap(); let (state, summary) = execute_apply(&plan, false).unwrap(); let target = env.home.path().join(".zshrc"); assert!(target.symlink_metadata().unwrap().file_type().is_symlink()); assert!(state.entries[0].backup.is_some()); assert_eq!(summary.backed_up, 1); assert_eq!(summary.replaced, 1); } #[test] fn execute_apply_conflicts_abort_without_changes() { let env = TestEnv::new(); make_theme_file(&env, "dracula", ".zshrc", "# zsh"); fs::write(env.home.path().join(".zshrc"), "existing").unwrap(); let plan = plan_apply("dracula", false, None).unwrap(); let result = execute_apply(&plan, false); assert!(result.is_err()); // Original file untouched let content = fs::read_to_string(env.home.path().join(".zshrc")).unwrap(); assert_eq!(content, "existing"); } #[test] fn rollback_restores_backup() { let env = TestEnv::new(); make_theme_file(&env, "dracula", ".zshrc", "# zsh"); fs::write(env.home.path().join(".zshrc"), "original").unwrap(); let plan = plan_apply("dracula", true, None).unwrap(); let (state, _) = execute_apply(&plan, false).unwrap(); rollback_state(&state, false).unwrap(); let content = fs::read_to_string(env.home.path().join(".zshrc")).unwrap(); assert_eq!(content, "original"); } #[test] fn rollback_removes_new_symlinks() { let env = TestEnv::new(); make_theme_file(&env, "dracula", ".zshrc", "# zsh"); let plan = plan_apply("dracula", false, None).unwrap(); let (state, _) = execute_apply(&plan, false).unwrap(); rollback_state(&state, false).unwrap(); assert!(env.home.path().join(".zshrc").symlink_metadata().is_err()); } #[test] fn switch_themes_replaces_symlinks() { let env = TestEnv::new(); // Apply dracula make_theme_file(&env, "dracula", ".zshrc", "# dracula"); let plan = plan_apply("dracula", false, None).unwrap(); let (dracula_state, _) = execute_apply(&plan, false).unwrap(); // Switch to catppuccin make_theme_file(&env, "catppuccin", ".zshrc", "# catppuccin"); let plan2 = plan_apply("catppuccin", false, Some(&dracula_state)).unwrap(); assert!(matches!(plan2.actions[0], PlannedAction::Replace { .. })); let (cat_state, summary) = execute_apply(&plan2, false).unwrap(); assert_eq!(cat_state.active_theme, "catppuccin"); assert_eq!(summary.replaced, 1); // Verify symlink points to catppuccin let link = fs::read_link(env.home.path().join(".zshrc")).unwrap(); assert!(link.to_string_lossy().contains("catppuccin")); } // ---- base layer tests ----------------------------------------------- fn make_base_file(env: &TestEnv, rel: &str, content: &str) -> PathBuf { make_theme_file(env, "base", rel, content) } #[test] fn base_files_applied_alongside_theme() { let env = TestEnv::new(); make_base_file(&env, ".config/hypr/keybinds.conf", "# base keybinds"); make_theme_file(&env, "dracula", ".zshrc", "# dracula"); let plan = plan_apply("dracula", false, None).unwrap(); assert_eq!(plan.actions.len(), 2); let (_, summary) = execute_apply(&plan, false).unwrap(); assert_eq!(summary.created, 2); let keybinds = fs::read_link(env.home.path().join(".config/hypr/keybinds.conf")).unwrap(); assert!(keybinds.to_string_lossy().contains("base")); let zshrc = fs::read_link(env.home.path().join(".zshrc")).unwrap(); assert!(zshrc.to_string_lossy().contains("dracula")); } #[test] fn theme_overrides_base_file() { let env = TestEnv::new(); make_base_file(&env, ".zshrc", "# base zshrc"); make_theme_file(&env, "dracula", ".zshrc", "# dracula zshrc"); let plan = plan_apply("dracula", false, None).unwrap(); // Only one action — theme wins over base for same path. assert_eq!(plan.actions.len(), 1); execute_apply(&plan, false).unwrap(); let link = fs::read_link(env.home.path().join(".zshrc")).unwrap(); assert!(link.to_string_lossy().contains("dracula")); } #[test] fn base_file_stays_when_theme_has_no_override() { let env = TestEnv::new(); make_base_file(&env, ".config/hypr/keybinds.conf", "# base keybinds"); make_theme_file(&env, "dracula", ".zshrc", "# dracula"); make_theme_file(&env, "catppuccin", ".zshrc", "# catppuccin"); // Apply dracula let plan = plan_apply("dracula", false, None).unwrap(); let (dracula_state, _) = execute_apply(&plan, false).unwrap(); // Switch to catppuccin — neither theme has keybinds.conf, so base symlink stays let plan2 = plan_apply("catppuccin", false, Some(&dracula_state)).unwrap(); let keybinds_action = plan2 .actions .iter() .find(|a| match a { PlannedAction::Skip { target, .. } => { target.to_string_lossy().contains("keybinds") } _ => false, }); assert!(keybinds_action.is_some(), "keybinds.conf should be skipped (already correct)"); } #[test] fn base_is_rejected_as_theme_name() { let env = TestEnv::new(); make_base_file(&env, ".zshrc", "# base"); let result = plan_apply("base", false, None); assert!(result.is_err()); } }