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
|
||||||
target/
|
target/
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
|
CLAUDE.md
|
||||||
|
|
|
||||||
13
Cargo.toml
13
Cargo.toml
|
|
@ -1,6 +1,17 @@
|
||||||
[package]
|
[package]
|
||||||
name = "dotfile-theme-manager"
|
name = "dm"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[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() {
|
mod apply;
|
||||||
println!("Hello, world!");
|
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