Finished project

This commit is contained in:
ManOfGoldForever 2026-04-01 00:46:54 -04:00
parent 6849f1afae
commit 31179d64e5
12 changed files with 2207 additions and 3 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
/target
target/
Cargo.lock
CLAUDE.md

View file

@ -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"

84
README.md Normal file
View file

@ -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 <theme> [--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/<timestamp>/`.
```
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/<timestamp>/`.
## 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).

115
TODO.md Normal file
View file

@ -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 <theme>`, `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 <theme> --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/<timestamp>/...`
- 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

727
src/apply.rs Normal file
View file

@ -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<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> {
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<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 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<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"));
}
}

38
src/cli.rs Normal file
View file

@ -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<Commands>,
}
#[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,
}

View file

@ -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(())
}

147
src/state.rs Normal file
View file

@ -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<StateEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StateEntry {
pub source: String,
pub target: String,
pub backup: Option<String>,
}
pub fn state_path() -> Result<PathBuf> {
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<StateEntry>) -> 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<Option<State>> {
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") };
}
}

4
src/test_utils.rs Normal file
View file

@ -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(());

141
src/theme.rs Normal file
View file

@ -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<PathBuf> {
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<Vec<String>> {
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<Vec<(PathBuf, PathBuf)>> {
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<PathBuf> = 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<PathBuf> = files.into_iter().map(|(r, _)| r).collect();
let mut sorted = relatives.clone();
sorted.sort();
assert_eq!(relatives, sorted);
}
}

538
src/tui.rs Normal file
View file

@ -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<String>,
scroll: u16,
},
}
enum MessageKind {
Success,
Error,
Info,
}
struct Message {
text: String,
kind: MessageKind,
}
struct App {
themes: Vec<String>,
list_state: ListState,
active_state: Option<State>,
message: Option<Message>,
force: bool,
view: View,
should_quit: bool,
}
impl App {
fn new() -> Result<Self> {
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<String>, 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<CrosstermBackend<std::io::Stdout>>) -> 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<ListItem> = 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<Line> = 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<Line> = 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]
}

280
tests/integration_test.rs Normal file
View file

@ -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");
}