From 31179d64e5b6c432f857c4841d768dfc92591501 Mon Sep 17 00:00:00 2001 From: ManOfGoldForever Date: Wed, 1 Apr 2026 00:46:54 -0400 Subject: [PATCH] Finished project --- .gitignore | 1 + Cargo.toml | 13 +- README.md | 84 +++++ TODO.md | 115 ++++++ src/apply.rs | 727 ++++++++++++++++++++++++++++++++++++++ src/cli.rs | 38 ++ src/main.rs | 122 ++++++- src/state.rs | 147 ++++++++ src/test_utils.rs | 4 + src/theme.rs | 141 ++++++++ src/tui.rs | 538 ++++++++++++++++++++++++++++ tests/integration_test.rs | 280 +++++++++++++++ 12 files changed, 2207 insertions(+), 3 deletions(-) create mode 100644 README.md create mode 100644 TODO.md create mode 100644 src/apply.rs create mode 100644 src/cli.rs create mode 100644 src/state.rs create mode 100644 src/test_utils.rs create mode 100644 src/theme.rs create mode 100644 src/tui.rs create mode 100644 tests/integration_test.rs diff --git a/.gitignore b/.gitignore index 5121daf..ebec9b3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /target target/ Cargo.lock +CLAUDE.md diff --git a/Cargo.toml b/Cargo.toml index 8197698..58ae910 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,17 @@ [package] -name = "dotfile-theme-manager" +name = "dm" version = "0.1.0" edition = "2024" [dependencies] +anyhow = "1.0.102" +chrono = { version = "0.4.44", features = ["serde"] } +clap = { version = "4.5.60", features = ["derive"] } +dirs = "6.0.0" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +shellexpand = "3.1.2" +tempfile = "3.26.0" +thiserror = "2.0.18" +crossterm = "0.28" +ratatui = "0.29" diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa49098 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# dm + +`dm` is a Rust CLI for switching between dotfile themes by symlinking a selected theme tree into the matching locations under `$HOME`. + +## Theme Layout + +Themes are discovered from subdirectories under `~/dotfiles/themes`. + +```text +~/dotfiles/themes/ + dracula/ + .config/ + alacritty/ + alacritty.toml + nvim/ + init.lua + .zshrc + catppuccin/ + .config/ + alacritty/ + alacritty.toml +``` + +The directory name is the theme name. The relative path inside the theme becomes the target path under `$HOME`. + +Examples: +- `~/dotfiles/themes/dracula/.config/nvim/init.lua` → `~/.config/nvim/init.lua` +- `~/dotfiles/themes/dracula/.zshrc` → `~/.zshrc` + +## Commands + +``` +dm list +``` +List all themes discovered under `~/dotfiles/themes`. + +``` +dm status +``` +Show the active theme, when it was applied, and the number of tracked symlinks. +Also reports any broken links. + +``` +dm apply [--dry-run] [--force] [--verbose] +``` +Apply a theme by creating symlinks under `$HOME`. + +- `--dry-run` — print what would happen without making changes. +- `--force` — overwrite unmanaged files (they are backed up first). +- `--verbose` — print each action as it executes. + +On conflict (an unmanaged file would be overwritten), `apply` aborts with a clear +error message. Use `--force` to allow it. + +On any mid-apply failure, all completed changes are rolled back automatically. + +``` +dm backup +``` +Manually snapshot all currently managed files to +`~/.config/theme-manager/backups//`. + +``` +dm rollback +``` +Restore the backup set recorded in the last `apply` run, then clear state. +Files that were newly created as symlinks (no prior content) are removed. + +## State + +State is persisted to `~/.config/theme-manager/state.json`. +Backups live under `~/.config/theme-manager/backups//`. + +## Development + +``` +cargo build +cargo run -- --help +cargo test +cargo fmt +cargo clippy --all-targets --all-features -- -D warnings +``` + +Implementation order is tracked in [`TODO.md`](TODO.md). diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e99fc7e --- /dev/null +++ b/TODO.md @@ -0,0 +1,115 @@ +# Dotfile Theme Manager TODO + +## Stage 0: Initialize the Project +1. Create base structure: + - `src/main.rs` + - `src/cli.rs` + - `src/theme.rs` + - `src/state.rs` + - `src/apply.rs` + - `tests/` +2. Add crates: + - `clap`, `anyhow`, `thiserror`, `serde`, `serde_json`, `dirs`, `shellexpand`, `chrono`, `tempfile`. +3. Wire a basic CLI with subcommands: + - `list`, `status`, `apply `, `backup`, `rollback`. +4. Exit criteria: + - `cargo build` and `cargo run -- --help` work. + +## Stage 1: Theme Discovery + Path Mapping +1. Decide the themes root: + - start with `~/dotfiles/themes`. +2. Implement theme discovery from subdirectory names under the themes root. +3. Define the mapping rule: + - the path inside a theme maps directly onto `$HOME`. +4. Add `list` command to print available themes. +5. Exit criteria: + - Non-theme entries are ignored. + - `list` shows valid themes. + +## Stage 2: State Tracking +1. Create `state.json` model: + - `active_theme`, `applied_at`, `entries`. +2. Add read/write helpers in `state.rs`. +3. Implement `status` command: + - active theme + - last apply time + - missing/broken link detection (optional for now). +4. Exit criteria: + - `status` works before and after apply. + +## Stage 3: Dry-Run Planner +1. Build planner in `apply.rs`: + - recursively enumerate files in the selected theme + - resolve source absolute paths + - map each theme-relative path onto `$HOME` + - produce action list (`CreateLink`, `BackupExisting`, `Replace`, `Skip`). +2. Add `apply --dry-run`. +3. Print exactly what would happen. +4. Exit criteria: + - dry-run output is deterministic and readable. + +## Stage 4: Real Apply with Safety +1. Implement apply execution: + - ensure parent dirs exist + - backup conflicting targets to `~/.config/theme-manager/backups//...` + - create symlinks (Unix first). +2. If any step fails: + - rollback changed targets from backups. +3. Only write `state.json` after full success. +4. Exit criteria: + - Switching themes works. + - Partial failure restores previous state. + +## Stage 5: Rollback Command +1. Implement `rollback` to restore most recent backup set. +2. Restore target files and clear/update state. +3. Exit criteria: + - Manual rollback works after a bad apply. + +## Stage 6: Force Mode + Conflict Policy +1. Add `--force` for replacing unmanaged files. +2. Default behavior without `--force`: + - refuse overwrite of unknown real files. +3. Add clear conflict messages with target path. +4. Exit criteria: + - Safe by default; force is explicit. + +## Stage 7: Tests (Required Before v0.1) +1. Unit tests: + - theme discovery + - path mapping + - planner decisions + - state serialization/deserialization. +2. Integration tests (`tests/` with `tempfile`): + - apply from empty state + - switch dracula -> catppuccin + - conflict without `--force` + - rollback on simulated failure. +3. Exit criteria: + - `cargo test` green. + +## Stage 8: UX Polish +1. Improve CLI output: + - summary counts (`created`, `replaced`, `backed_up`, `skipped`). +2. Add `--verbose`. +3. Add `--json` output for scripting (optional). +4. Exit criteria: + - command output is clear and actionable. + +## Stage 9: Release Readiness +1. Add README examples and directory layout. +2. Add a sample theme tree under `examples/` or in README docs. +3. Add `cargo clippy -- -D warnings` and `cargo fmt --check` in CI. +4. Tag `v0.1.0`. + +## Suggested Strict Order +1. Stage 0 +2. Stage 1 +3. Stage 3 +4. Stage 4 +5. Stage 2 +6. Stage 5 +7. Stage 6 +8. Stage 7 +9. Stage 8 +10. Stage 9 diff --git a/src/apply.rs b/src/apply.rs new file mode 100644 index 0000000..61432ce --- /dev/null +++ b/src/apply.rs @@ -0,0 +1,727 @@ +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 { + let theme_dir = themes_root()?.join(theme_name); + if !theme_dir.is_dir() { + anyhow::bail!("theme not found: {}", theme_name); + } + + let home = effective_home()?; + let files = enumerate_theme_files(&theme_dir)?; + + 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 files { + 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")); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..461afe0 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,38 @@ +use clap::{Args, Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "dm")] +#[command(about = "A simple, blazingly fast dotfile theme manager CLI")] +pub struct Cli { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand)] +pub enum Commands { + /// List all available dotfile themes + List, + /// Show the active theme and apply metadata + Status, + /// Apply a dotfile theme + Apply(ApplyArgs), + /// Back up currently managed files + Backup, + /// Restore the most recent backup + Rollback, +} + +#[derive(Args)] +pub struct ApplyArgs { + /// The name of the theme to apply + pub name: String, + /// Show what would happen without making changes + #[arg(long)] + pub dry_run: bool, + /// Overwrite unmanaged files (backs them up first) + #[arg(long)] + pub force: bool, + /// Print each action as it is executed + #[arg(long, short)] + pub verbose: bool, +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..ba72f1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,121 @@ -fn main() { - println!("Hello, world!"); +mod apply; +mod cli; +mod state; +#[cfg(test)] +mod test_utils; +mod theme; +mod tui; + +use clap::Parser; +use cli::{Cli, Commands}; +use state::{clear_state, save_state, try_load_state}; +use theme::discover_themes; + +fn main() -> anyhow::Result<()> { + let cli: Cli = Cli::parse(); + + match cli.command { + // ---------------------------------------------------------------- + // list + // ---------------------------------------------------------------- + Some(Commands::List) => { + let themes = discover_themes()?; + if themes.is_empty() { + println!("No themes found."); + } else { + for theme in themes { + println!("{theme}"); + } + } + } + + // ---------------------------------------------------------------- + // status + // ---------------------------------------------------------------- + Some(Commands::Status) => match try_load_state()? { + Some(state) => { + println!("Active theme: {}", state.active_theme); + println!("Applied at: {}", state.applied_at); + println!("Tracked files: {}", state.entries.len()); + + let broken: Vec<_> = state + .entries + .iter() + .filter(|e| { + let t = std::path::Path::new(&e.target); + !t.symlink_metadata() + .is_ok_and(|m| m.file_type().is_symlink()) + }) + .collect(); + + if !broken.is_empty() { + println!("Broken links: {}", broken.len()); + for e in broken { + println!(" {}", e.target); + } + } + } + None => { + println!("No theme has been applied yet."); + } + }, + + // ---------------------------------------------------------------- + // apply + // ---------------------------------------------------------------- + Some(Commands::Apply(args)) => { + let current_state = try_load_state()?; + + let plan = apply::plan_apply(&args.name, args.force, current_state.as_ref())?; + + if args.dry_run { + println!("Dry run — no changes will be made."); + println!("Theme: {}", args.name); + apply::print_plan(&plan); + return Ok(()); + } + + let (new_state, summary) = apply::execute_apply(&plan, args.verbose)?; + save_state(&new_state)?; + + println!("Applied theme: {}", args.name); + println!( + " created={} replaced={} backed_up={} skipped={}", + summary.created, summary.replaced, summary.backed_up, summary.skipped + ); + } + + // ---------------------------------------------------------------- + // backup + // ---------------------------------------------------------------- + Some(Commands::Backup) => match try_load_state()? { + Some(state) => { + let (path, count) = apply::create_manual_backup(&state)?; + println!("Backed up {count} file(s) to {}", path.display()); + } + None => { + println!("No active theme; nothing to back up."); + } + }, + + // ---------------------------------------------------------------- + // rollback + // ---------------------------------------------------------------- + Some(Commands::Rollback) => match try_load_state()? { + Some(state) => { + let n = apply::rollback_state(&state, true)?; + clear_state()?; + println!("Rolled back {} file(s).", n); + } + None => { + println!("No active theme; nothing to roll back."); + } + }, + + None => { + tui::run()?; + } + } + + Ok(()) } diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..6f6d127 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,147 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use dirs::home_dir; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::ErrorKind; +use std::path::PathBuf; + +#[derive(Debug, Serialize, Deserialize)] +pub struct State { + pub active_theme: String, + pub applied_at: String, + pub entries: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct StateEntry { + pub source: String, + pub target: String, + pub backup: Option, +} + +pub fn state_path() -> Result { + if let Ok(p) = std::env::var("DM_STATE_FILE") { + return Ok(PathBuf::from(p)); + } + let home = home_dir().context("failed to find home directory")?; + Ok(home + .join(".config") + .join("theme-manager") + .join("state.json")) +} + +pub fn build_state(active_theme: String, entries: Vec) -> State { + State { + active_theme, + applied_at: Utc::now().to_rfc3339(), + entries, + } +} + +pub fn save_state(state: &State) -> Result<()> { + let path = state_path()?; + + let parent = path + .parent() + .with_context(|| format!("state path has no parent: {}", path.display()))?; + + fs::create_dir_all(parent) + .with_context(|| format!("failed to create state directory: {}", parent.display()))?; + + let json = serde_json::to_string_pretty(state).context("failed to serialize state")?; + + fs::write(&path, json) + .with_context(|| format!("failed to write state file: {}", path.display()))?; + + Ok(()) +} + +pub fn clear_state() -> Result<()> { + let path = state_path()?; + if path.exists() { + fs::remove_file(&path) + .with_context(|| format!("failed to remove state file: {}", path.display()))?; + } + Ok(()) +} + +pub fn try_load_state() -> Result> { + let path = state_path()?; + + let contents = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(e) + .with_context(|| format!("failed to read state file: {}", path.display())); + } + }; + + let state = serde_json::from_str(&contents) + .with_context(|| format!("failed to parse state file: {}", path.display()))?; + + Ok(Some(state)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::ENV_MUTEX; + use tempfile::TempDir; + + #[test] + fn round_trip_state() { + let _guard = ENV_MUTEX.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + let state_file = tmp.path().join("state.json"); + unsafe { std::env::set_var("DM_STATE_FILE", &state_file) }; + + let state = build_state( + "dracula".to_string(), + vec![StateEntry { + source: "/a/b".to_string(), + target: "/c/d".to_string(), + backup: Some("/e/f".to_string()), + }], + ); + save_state(&state).unwrap(); + + let loaded = try_load_state().unwrap().unwrap(); + assert_eq!(loaded.active_theme, "dracula"); + assert_eq!(loaded.entries.len(), 1); + assert_eq!(loaded.entries[0].backup, Some("/e/f".to_string())); + + unsafe { std::env::remove_var("DM_STATE_FILE") }; + } + + #[test] + fn try_load_state_returns_none_when_missing() { + let _guard = ENV_MUTEX.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + let state_file = tmp.path().join("nonexistent.json"); + unsafe { std::env::set_var("DM_STATE_FILE", &state_file) }; + + let result = try_load_state().unwrap(); + assert!(result.is_none()); + + unsafe { std::env::remove_var("DM_STATE_FILE") }; + } + + #[test] + fn clear_state_removes_file() { + let _guard = ENV_MUTEX.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + let state_file = tmp.path().join("state.json"); + unsafe { std::env::set_var("DM_STATE_FILE", &state_file) }; + + let state = build_state("x".to_string(), vec![]); + save_state(&state).unwrap(); + assert!(state_file.exists()); + + clear_state().unwrap(); + assert!(!state_file.exists()); + + unsafe { std::env::remove_var("DM_STATE_FILE") }; + } +} diff --git a/src/test_utils.rs b/src/test_utils.rs new file mode 100644 index 0000000..5811c9f --- /dev/null +++ b/src/test_utils.rs @@ -0,0 +1,4 @@ +/// Shared mutex that serialises all tests touching env vars. +/// All three modules (apply, state, theme) read the same DM_* vars, so they +/// must not run concurrently. +pub static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..5610181 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,141 @@ +use anyhow::{Context, Result}; +use dirs::home_dir; +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn themes_root() -> Result { + if let Ok(root) = std::env::var("DM_THEMES_ROOT") { + return Ok(PathBuf::from(root)); + } + let home = home_dir().context("failed to find home directory")?; + Ok(home.join("dotfiles").join("themes")) +} + +pub fn discover_themes() -> Result> { + let root = themes_root()?; + let entries = fs::read_dir(&root) + .with_context(|| format!("failed to read themes directory: {}", root.display()))?; + + let mut themes = Vec::new(); + + for entry in entries { + let entry = entry.with_context(|| { + format!( + "failed to read an entry from themes directory: {}", + root.display() + ) + })?; + let path = entry.path(); + + if path.is_dir() { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + themes.push(name.to_string()); + } + } + + themes.sort(); + Ok(themes) +} + +/// Recursively enumerate all files under `theme_dir`. +/// Returns `(theme_relative_path, absolute_source_path)` pairs sorted by path. +pub fn enumerate_theme_files(theme_dir: &Path) -> Result> { + let mut files = Vec::new(); + enumerate_recursive(theme_dir, theme_dir, &mut files)?; + files.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(files) +} + +fn enumerate_recursive( + root: &Path, + current: &Path, + files: &mut Vec<(PathBuf, PathBuf)>, +) -> Result<()> { + let entries = fs::read_dir(current) + .with_context(|| format!("failed to read directory: {}", current.display()))?; + + for entry in entries { + let entry = + entry.with_context(|| format!("failed to read entry from: {}", current.display()))?; + let path = entry.path(); + + // Skip symlinks inside a theme source tree + if path + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + continue; + } + + if path.is_dir() { + enumerate_recursive(root, &path, files)?; + } else if path.is_file() { + let relative = path + .strip_prefix(root) + .with_context(|| format!("failed to strip prefix from: {}", path.display()))?; + files.push((relative.to_path_buf(), path)); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::ENV_MUTEX; + use std::fs; + use tempfile::TempDir; + + fn make_theme(root: &Path, theme: &str, files: &[&str]) { + for rel in files { + let p = root.join(theme).join(rel); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(&p, "").unwrap(); + } + } + + #[test] + fn discover_themes_returns_directory_names() { + let _guard = ENV_MUTEX.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + fs::create_dir(tmp.path().join("dracula")).unwrap(); + fs::create_dir(tmp.path().join("catppuccin")).unwrap(); + fs::write(tmp.path().join("not-a-dir.txt"), "").unwrap(); + + unsafe { std::env::set_var("DM_THEMES_ROOT", tmp.path()) }; + let themes = discover_themes().unwrap(); + unsafe { std::env::remove_var("DM_THEMES_ROOT") }; + + assert_eq!(themes, vec!["catppuccin", "dracula"]); + } + + #[test] + fn enumerate_theme_files_returns_relative_paths() { + let tmp = TempDir::new().unwrap(); + make_theme(tmp.path(), "dracula", &[".config/nvim/init.lua", ".zshrc"]); + + let theme_dir = tmp.path().join("dracula"); + let files = enumerate_theme_files(&theme_dir).unwrap(); + let relatives: Vec = files.into_iter().map(|(r, _)| r).collect(); + + assert!(relatives.contains(&PathBuf::from(".config/nvim/init.lua"))); + assert!(relatives.contains(&PathBuf::from(".zshrc"))); + } + + #[test] + fn enumerate_theme_files_is_sorted() { + let tmp = TempDir::new().unwrap(); + make_theme(tmp.path(), "t", &["b.txt", "a.txt", ".config/z.txt"]); + + let theme_dir = tmp.path().join("t"); + let files = enumerate_theme_files(&theme_dir).unwrap(); + let relatives: Vec = files.into_iter().map(|(r, _)| r).collect(); + let mut sorted = relatives.clone(); + sorted.sort(); + assert_eq!(relatives, sorted); + } +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..9bdeb4a --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,538 @@ +use anyhow::Result; +use crossterm::{ + event::{self, Event, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, + Frame, Terminal, +}; + +use crate::apply::{create_manual_backup, execute_apply, plan_apply, plan_lines, rollback_state}; +use crate::state::{clear_state, save_state, try_load_state, State}; +use crate::theme::discover_themes; + +// --------------------------------------------------------------------------- +// App state +// --------------------------------------------------------------------------- + +enum View { + Main, + DryRun { + theme: String, + lines: Vec, + scroll: u16, + }, +} + +enum MessageKind { + Success, + Error, + Info, +} + +struct Message { + text: String, + kind: MessageKind, +} + +struct App { + themes: Vec, + list_state: ListState, + active_state: Option, + message: Option, + force: bool, + view: View, + should_quit: bool, +} + +impl App { + fn new() -> Result { + let themes = discover_themes().unwrap_or_default(); + let active_state = try_load_state()?; + + // Pre-select the active theme in the list, or the first entry. + let selected = active_state + .as_ref() + .and_then(|s| themes.iter().position(|t| *t == s.active_theme)) + .unwrap_or(0); + + let mut list_state = ListState::default(); + if !themes.is_empty() { + list_state.select(Some(selected)); + } + + Ok(App { + themes, + list_state, + active_state, + message: None, + force: false, + view: View::Main, + should_quit: false, + }) + } + + fn selected_theme(&self) -> Option<&str> { + self.list_state + .selected() + .and_then(|i| self.themes.get(i)) + .map(String::as_str) + } + + fn move_up(&mut self) { + if self.themes.is_empty() { + return; + } + let i = self.list_state.selected().unwrap_or(0); + let prev = if i == 0 { self.themes.len() - 1 } else { i - 1 }; + self.list_state.select(Some(prev)); + } + + fn move_down(&mut self) { + if self.themes.is_empty() { + return; + } + let i = self.list_state.selected().unwrap_or(0); + let next = (i + 1) % self.themes.len(); + self.list_state.select(Some(next)); + } + + fn apply_selected(&mut self) { + let Some(theme) = self.selected_theme().map(str::to_owned) else { + self.msg("No theme selected.", MessageKind::Error); + return; + }; + + let plan = match plan_apply(&theme, self.force, self.active_state.as_ref()) { + Ok(p) => p, + Err(e) => { + self.msg(e.to_string(), MessageKind::Error); + return; + } + }; + + match execute_apply(&plan, false) { + Err(e) => { + let text = if e.to_string().contains("--force") { + "Conflict — press f to enable force mode, then Enter to retry".to_string() + } else { + e.to_string() + }; + self.msg(text, MessageKind::Error); + } + Ok((state, summary)) => { + let text = format!( + "Applied \"{}\" created={} replaced={} backed_up={} skipped={}", + theme, + summary.created, + summary.replaced, + summary.backed_up, + summary.skipped, + ); + if let Err(e) = save_state(&state) { + self.msg( + format!("Apply succeeded but state save failed: {e}"), + MessageKind::Error, + ); + return; + } + self.active_state = Some(state); + self.msg(text, MessageKind::Success); + } + } + } + + fn dry_run_selected(&mut self) { + let Some(theme) = self.selected_theme().map(str::to_owned) else { + return; + }; + + match plan_apply(&theme, self.force, self.active_state.as_ref()) { + Err(e) => self.msg(e.to_string(), MessageKind::Error), + Ok(plan) => { + let lines = plan_lines(&plan); + if lines.is_empty() { + self.msg("Nothing to do.", MessageKind::Info); + } else { + self.view = View::DryRun { + theme, + lines, + scroll: 0, + }; + } + } + } + } + + fn do_rollback(&mut self) { + let Some(state) = self.active_state.take() else { + self.msg("No active theme to roll back.", MessageKind::Info); + return; + }; + + match rollback_state(&state, false) { + Err(e) => { + self.active_state = Some(state); + self.msg(e.to_string(), MessageKind::Error); + } + Ok(n) => { + let _ = clear_state(); + self.msg(format!("Rolled back {n} file(s)."), MessageKind::Success); + } + } + } + + fn do_backup(&mut self) { + let Some(state) = &self.active_state else { + self.msg("No active theme; nothing to back up.", MessageKind::Info); + return; + }; + + match create_manual_backup(state) { + Ok((path, count)) => self.msg( + format!("Backed up {count} file(s) → {}", path.display()), + MessageKind::Success, + ), + Err(e) => self.msg(e.to_string(), MessageKind::Error), + } + } + + fn toggle_force(&mut self) { + self.force = !self.force; + let label = if self.force { "ON" } else { "OFF" }; + self.msg(format!("Force mode: {label}"), MessageKind::Info); + } + + fn msg(&mut self, text: impl Into, kind: MessageKind) { + self.message = Some(Message { + text: text.into(), + kind, + }); + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +pub fn run() -> Result<()> { + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.hide_cursor()?; + + let result = run_loop(&mut terminal); + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + result +} + +fn run_loop(terminal: &mut Terminal>) -> Result<()> { + let mut app = App::new()?; + + loop { + terminal.draw(|f| render(f, &mut app))?; + + if event::poll(std::time::Duration::from_millis(200))? + && let Event::Key(key) = event::read()? + { + handle_key(&mut app, key.code); + } + + if app.should_quit { + break; + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +fn handle_key(app: &mut App, code: KeyCode) { + match &app.view { + View::DryRun { .. } => match code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => { + app.view = View::Main; + } + KeyCode::Up | KeyCode::Char('k') => { + if let View::DryRun { scroll, .. } = &mut app.view { + *scroll = scroll.saturating_sub(1); + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let View::DryRun { scroll, lines, .. } = &mut app.view { + let max = lines.len().saturating_sub(1) as u16; + *scroll = (*scroll + 1).min(max); + } + } + _ => {} + }, + View::Main => match code { + KeyCode::Char('q') | KeyCode::Char('Q') => app.should_quit = true, + KeyCode::Up | KeyCode::Char('k') => app.move_up(), + KeyCode::Down | KeyCode::Char('j') => app.move_down(), + KeyCode::Enter => app.apply_selected(), + KeyCode::Char('d') | KeyCode::Char('D') => app.dry_run_selected(), + KeyCode::Char('r') | KeyCode::Char('R') => app.do_rollback(), + KeyCode::Char('b') | KeyCode::Char('B') => app.do_backup(), + KeyCode::Char('f') | KeyCode::Char('F') => app.toggle_force(), + _ => {} + }, + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +fn render(f: &mut Frame, app: &mut App) { + let area = f.area(); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // title bar + Constraint::Min(4), // theme list + status + Constraint::Length(2), // last message + Constraint::Length(1), // help bar + ]) + .split(area); + + render_title(f, app, layout[0]); + render_content(f, app, layout[1]); + render_message(f, app, layout[2]); + render_help(f, app, layout[3]); + + if matches!(app.view, View::DryRun { .. }) { + render_dry_run_popup(f, app); + } +} + +fn render_title(f: &mut Frame, app: &App, area: Rect) { + let mut spans = vec![Span::styled( + " dm — dotfile theme manager", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]; + + if app.force { + spans.push(Span::styled( + " [FORCE ON]", + Style::default() + .fg(Color::Red) + .add_modifier(Modifier::BOLD), + )); + } + + f.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn render_content(f: &mut Frame, app: &mut App, area: Rect) { + let halves = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + render_theme_list(f, app, halves[0]); + render_status_panel(f, app, halves[1]); +} + +fn render_theme_list(f: &mut Frame, app: &mut App, area: Rect) { + let active_name = app.active_state.as_ref().map(|s| s.active_theme.as_str()); + + let items: Vec = if app.themes.is_empty() { + vec![ListItem::new(Span::styled( + " No themes found — add subdirs to ~/dotfiles/themes", + Style::default().fg(Color::DarkGray), + ))] + } else { + app.themes + .iter() + .map(|name| { + if Some(name.as_str()) == active_name { + ListItem::new(Line::from(vec![ + Span::styled( + name.as_str(), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" ✓", Style::default().fg(Color::Green)), + ])) + } else { + ListItem::new(name.as_str()) + } + }) + .collect() + }; + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" Themes ")) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + f.render_stateful_widget(list, area, &mut app.list_state); +} + +fn render_status_panel(f: &mut Frame, app: &App, area: Rect) { + let lines: Vec = match &app.active_state { + None => vec![ + Line::from(""), + Line::from(Span::styled( + " No theme applied.", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(Span::styled( + " Select a theme and press Enter.", + Style::default().fg(Color::DarkGray), + )), + ], + Some(state) => { + let applied = state.applied_at.get(..16).unwrap_or(&state.applied_at); + vec![ + Line::from(""), + Line::from(vec![ + Span::raw(" Active: "), + Span::styled( + &state.active_theme, + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(""), + Line::from(vec![Span::raw(" Applied: "), Span::raw(applied)]), + Line::from(""), + Line::from(vec![ + Span::raw(" Files: "), + Span::raw(state.entries.len().to_string()), + ]), + ] + } + }; + + f.render_widget( + Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(" Status ")), + area, + ); +} + +fn render_message(f: &mut Frame, app: &App, area: Rect) { + let (text, color) = match &app.message { + None => (" Ready.", Color::DarkGray), + Some(msg) => { + let color = match msg.kind { + MessageKind::Success => Color::Green, + MessageKind::Error => Color::Red, + MessageKind::Info => Color::Yellow, + }; + (msg.text.as_str(), color) + } + }; + + f.render_widget( + Paragraph::new(format!(" {text}")) + .style(Style::default().fg(color)) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_help(f: &mut Frame, app: &App, area: Rect) { + let force = if app.force { "f Force:ON " } else { "f Force:OFF" }; + let help = format!( + " ↑/↓ Navigate Enter Apply d Dry-run r Rollback b Backup {force} q Quit" + ); + f.render_widget( + Paragraph::new(help).style(Style::default().fg(Color::DarkGray)), + area, + ); +} + +fn render_dry_run_popup(f: &mut Frame, app: &App) { + let View::DryRun { + theme, + lines, + scroll, + } = &app.view + else { + return; + }; + + let popup = centered_rect(84, 78, f.area()); + f.render_widget(Clear, popup); + + // Reserve bottom row for help text + let content_area = Rect { + height: popup.height.saturating_sub(1), + ..popup + }; + let help_area = Rect { + y: popup.y + popup.height.saturating_sub(1), + height: 1, + ..popup + }; + + let text: Vec = lines.iter().map(|l| Line::from(l.as_str())).collect(); + + f.render_widget( + Paragraph::new(text) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Dry Run: {theme} ")), + ) + .scroll((*scroll, 0)), + content_area, + ); + + f.render_widget( + Paragraph::new(" ↑/↓ Scroll q/Esc Close") + .style(Style::default().fg(Color::DarkGray)), + help_area, + ); +} + +fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect { + let margin_v = (100 - pct_y) / 2; + let margin_h = (100 - pct_x) / 2; + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(margin_v), + Constraint::Percentage(pct_y), + Constraint::Percentage(margin_v), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(margin_h), + Constraint::Percentage(pct_x), + Constraint::Percentage(margin_h), + ]) + .split(rows[1])[1] +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..542405f --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,280 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +struct TestEnv { + home: TempDir, + themes: TempDir, + state_file: PathBuf, +} + +impl TestEnv { + fn new() -> Self { + let home = TempDir::new().unwrap(); + let themes = TempDir::new().unwrap(); + let state_file = home.path().join(".config/theme-manager/state.json"); + TestEnv { + home, + themes, + state_file, + } + } + + fn make_theme_file(&self, theme: &str, rel: &str, content: &str) -> PathBuf { + let p = self.themes.path().join(theme).join(rel); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(&p, content).unwrap(); + p + } + + fn target(&self, rel: &str) -> PathBuf { + self.home.path().join(rel) + } + + fn dm(&self, args: &[&str]) -> std::process::Output { + let bin = env!("CARGO_BIN_EXE_dm"); + Command::new(bin) + .args(args) + .env("DM_HOME", self.home.path()) + .env("DM_THEMES_ROOT", self.themes.path()) + .env("DM_STATE_FILE", &self.state_file) + .output() + .unwrap() + } + + fn dm_stdout(&self, args: &[&str]) -> String { + let out = self.dm(args); + String::from_utf8_lossy(&out.stdout).to_string() + } + + fn dm_success(&self, args: &[&str]) { + let out = self.dm(args); + if !out.status.success() { + panic!( + "dm {:?} failed:\nstdout: {}\nstderr: {}", + args, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + } + } +} + +fn is_symlink(p: &Path) -> bool { + p.symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) +} + +// --------------------------------------------------------------------------- +// list +// --------------------------------------------------------------------------- + +#[test] +fn list_shows_available_themes() { + let env = TestEnv::new(); + fs::create_dir(env.themes.path().join("dracula")).unwrap(); + fs::create_dir(env.themes.path().join("catppuccin")).unwrap(); + + let out = env.dm_stdout(&["list"]); + assert!(out.contains("dracula"), "expected dracula in: {out}"); + assert!(out.contains("catppuccin"), "expected catppuccin in: {out}"); +} + +// --------------------------------------------------------------------------- +// status +// --------------------------------------------------------------------------- + +#[test] +fn status_before_apply_reports_no_theme() { + let env = TestEnv::new(); + let out = env.dm_stdout(&["status"]); + assert!(out.contains("No theme"), "got: {out}"); +} + +#[test] +fn status_after_apply_shows_theme_name() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", "# dracula"); + + env.dm_success(&["apply", "dracula"]); + let out = env.dm_stdout(&["status"]); + assert!(out.contains("dracula"), "got: {out}"); +} + +// --------------------------------------------------------------------------- +// apply from empty state +// --------------------------------------------------------------------------- + +#[test] +fn apply_creates_symlinks_under_home() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", "# zsh"); + env.make_theme_file("dracula", ".config/nvim/init.lua", "-- nvim"); + + env.dm_success(&["apply", "dracula"]); + + assert!(is_symlink(&env.target(".zshrc"))); + assert!(is_symlink(&env.target(".config/nvim/init.lua"))); +} + +#[test] +fn apply_output_shows_summary_counts() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", ""); + + let out = env.dm_stdout(&["apply", "dracula"]); + assert!(out.contains("created=1"), "expected created=1 in: {out}"); +} + +// --------------------------------------------------------------------------- +// dry-run +// --------------------------------------------------------------------------- + +#[test] +fn dry_run_makes_no_changes() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", "# zsh"); + + let out = env.dm_stdout(&["apply", "dracula", "--dry-run"]); + assert!(out.contains("Dry run"), "got: {out}"); + assert!( + !env.target(".zshrc").exists(), + "symlink should not exist after dry-run" + ); +} + +#[test] +fn dry_run_shows_create_action() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", ""); + + let out = env.dm_stdout(&["apply", "dracula", "--dry-run"]); + assert!(out.contains("create"), "got: {out}"); +} + +// --------------------------------------------------------------------------- +// conflict without --force +// --------------------------------------------------------------------------- + +#[test] +fn apply_conflicts_on_unmanaged_file() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", "# theme"); + fs::write(env.target(".zshrc"), "original").unwrap(); + + let out = env.dm(&["apply", "dracula"]); + assert!(!out.status.success(), "should fail on conflict"); + + // Original file must be untouched. + let content = fs::read_to_string(env.target(".zshrc")).unwrap(); + assert_eq!(content, "original"); +} + +#[test] +fn apply_force_backs_up_and_replaces() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", "# theme"); + fs::write(env.target(".zshrc"), "original").unwrap(); + + env.dm_success(&["apply", "dracula", "--force"]); + assert!(is_symlink(&env.target(".zshrc"))); +} + +// --------------------------------------------------------------------------- +// switch dracula → catppuccin +// --------------------------------------------------------------------------- + +#[test] +fn switch_themes_replaces_managed_symlinks() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", "# dracula"); + env.dm_success(&["apply", "dracula"]); + + env.make_theme_file("catppuccin", ".zshrc", "# catppuccin"); + env.dm_success(&["apply", "catppuccin"]); + + let link = fs::read_link(env.target(".zshrc")).unwrap(); + assert!( + link.to_string_lossy().contains("catppuccin"), + "link should point to catppuccin, got: {}", + link.display() + ); +} + +#[test] +fn status_updates_after_theme_switch() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", ""); + env.dm_success(&["apply", "dracula"]); + + env.make_theme_file("catppuccin", ".zshrc", ""); + env.dm_success(&["apply", "catppuccin"]); + + let out = env.dm_stdout(&["status"]); + assert!(out.contains("catppuccin"), "got: {out}"); +} + +// --------------------------------------------------------------------------- +// rollback +// --------------------------------------------------------------------------- + +#[test] +fn rollback_restores_backed_up_file() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", "# theme"); + fs::write(env.target(".zshrc"), "original content").unwrap(); + + env.dm_success(&["apply", "dracula", "--force"]); + assert!(is_symlink(&env.target(".zshrc"))); + + env.dm_success(&["rollback"]); + let content = fs::read_to_string(env.target(".zshrc")).unwrap(); + assert_eq!(content, "original content"); +} + +#[test] +fn rollback_removes_newly_created_symlinks() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", "# theme"); + + env.dm_success(&["apply", "dracula"]); + assert!(is_symlink(&env.target(".zshrc"))); + + env.dm_success(&["rollback"]); + assert!(env.target(".zshrc").symlink_metadata().is_err()); +} + +#[test] +fn rollback_clears_state() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", ""); + + env.dm_success(&["apply", "dracula"]); + env.dm_success(&["rollback"]); + + let out = env.dm_stdout(&["status"]); + assert!(out.contains("No theme"), "got: {out}"); +} + +// --------------------------------------------------------------------------- +// backup command +// --------------------------------------------------------------------------- + +#[test] +fn backup_command_copies_managed_files() { + let env = TestEnv::new(); + env.make_theme_file("dracula", ".zshrc", "# theme"); + fs::write(env.target(".zshrc"), "original").unwrap(); + + env.dm_success(&["apply", "dracula", "--force"]); + // Apply already backed up; now apply a second time to switch, creating backup dir + // Just check the backup command runs without error when state exists. + let out = env.dm(&["backup"]); + assert!(out.status.success(), "backup should succeed"); +}