Finished project
This commit is contained in:
parent
6849f1afae
commit
31179d64e5
12 changed files with 2207 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@
|
|||
/target
|
||||
target/
|
||||
Cargo.lock
|
||||
CLAUDE.md
|
||||
|
|
|
|||
13
Cargo.toml
13
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"
|
||||
|
|
|
|||
84
README.md
Normal file
84
README.md
Normal 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
115
TODO.md
Normal 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
727
src/apply.rs
Normal 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(×tamp)?;
|
||||
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(×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"));
|
||||
}
|
||||
}
|
||||
38
src/cli.rs
Normal file
38
src/cli.rs
Normal 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,
|
||||
}
|
||||
122
src/main.rs
122
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(())
|
||||
}
|
||||
|
|
|
|||
147
src/state.rs
Normal file
147
src/state.rs
Normal 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
4
src/test_utils.rs
Normal 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
141
src/theme.rs
Normal 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
538
src/tui.rs
Normal 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
280
tests/integration_test.rs
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue