dotfile-theme-manager/src/apply.rs

821 lines
29 KiB
Rust

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<PlannedAction>,
}
#[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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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 <theme>` 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<ApplyPlan> {
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<PathBuf, PathBuf> =
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<PathBuf> = 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<String> {
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(&timestamp)?;
let home = effective_home()?;
let mut undo_stack: Vec<Undo> = Vec::new();
let mut state_entries: Vec<StateEntry> = 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<usize> {
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(&timestamp)?;
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());
}
}