821 lines
29 KiB
Rust
821 lines
29 KiB
Rust
use anyhow::{Context, Result};
|
|
use chrono::Utc;
|
|
use std::collections::HashSet;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use crate::state::{State, StateEntry, build_state};
|
|
use crate::theme::{enumerate_theme_files, themes_root};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug)]
|
|
pub enum PlannedAction {
|
|
/// Target does not exist; create a new symlink.
|
|
CreateLink { source: PathBuf, target: PathBuf },
|
|
/// Target is already the correct symlink; nothing to do.
|
|
Skip { source: PathBuf, target: PathBuf },
|
|
/// Target is a managed symlink pointing to a different theme; replace it.
|
|
Replace { source: PathBuf, target: PathBuf },
|
|
/// Target exists and is unmanaged; back it up then replace with symlink.
|
|
BackupAndReplace { source: PathBuf, target: PathBuf },
|
|
/// Target exists and is unmanaged; blocked without `--force`.
|
|
Conflict { target: PathBuf },
|
|
}
|
|
|
|
pub struct ApplyPlan {
|
|
pub theme_name: String,
|
|
pub actions: Vec<PlannedAction>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct ApplySummary {
|
|
pub created: usize,
|
|
pub replaced: usize,
|
|
pub backed_up: usize,
|
|
pub skipped: usize,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Path helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Return the effective home directory, respecting `DM_HOME` for tests.
|
|
pub fn effective_home() -> Result<PathBuf> {
|
|
if let Ok(h) = std::env::var("DM_HOME") {
|
|
return Ok(PathBuf::from(h));
|
|
}
|
|
dirs::home_dir().context("failed to find home directory")
|
|
}
|
|
|
|
fn backup_base_for(timestamp: &str) -> Result<PathBuf> {
|
|
let home = effective_home()?;
|
|
Ok(home
|
|
.join(".config")
|
|
.join("theme-manager")
|
|
.join("backups")
|
|
.join(timestamp))
|
|
}
|
|
|
|
fn backup_path_for(backup_base: &Path, target: &Path, home: &Path) -> Result<PathBuf> {
|
|
let relative = target
|
|
.strip_prefix(home)
|
|
.with_context(|| format!("target {} is not under home", target.display()))?;
|
|
Ok(backup_base.join(relative))
|
|
}
|
|
|
|
fn ensure_parent(path: &Path) -> Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("failed to create directory: {}", parent.display()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn create_symlink(source: &Path, target: &Path) -> Result<()> {
|
|
std::os::unix::fs::symlink(source, target).with_context(|| {
|
|
format!(
|
|
"failed to create symlink {} -> {}",
|
|
target.display(),
|
|
source.display()
|
|
)
|
|
})
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn create_symlink(_source: &Path, _target: &Path) -> Result<()> {
|
|
anyhow::bail!("symlinks are only supported on Unix")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Planning
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build an `ApplyPlan` describing what `apply <theme>` would do.
|
|
///
|
|
/// Pass `force = true` to allow overwriting unmanaged files (after backup).
|
|
/// Pass `current_state` so that managed targets can be replaced without `--force`.
|
|
pub fn plan_apply(
|
|
theme_name: &str,
|
|
force: bool,
|
|
current_state: Option<&State>,
|
|
) -> Result<ApplyPlan> {
|
|
if theme_name == "base" {
|
|
anyhow::bail!("\"base\" is a reserved layer name and cannot be applied as a theme");
|
|
}
|
|
|
|
let root = themes_root()?;
|
|
let theme_dir = root.join(theme_name);
|
|
if !theme_dir.is_dir() {
|
|
anyhow::bail!("theme not found: {}", theme_name);
|
|
}
|
|
|
|
let home = effective_home()?;
|
|
|
|
// Build a merged file map: base files first, theme files overlay (and win on conflict).
|
|
// Using BTreeMap keeps the iteration order deterministic.
|
|
let mut file_map: std::collections::BTreeMap<PathBuf, PathBuf> =
|
|
std::collections::BTreeMap::new();
|
|
|
|
let base_dir = root.join("base");
|
|
if base_dir.is_dir() {
|
|
for (rel, src) in enumerate_theme_files(&base_dir)? {
|
|
file_map.insert(rel, src);
|
|
}
|
|
}
|
|
|
|
for (rel, src) in enumerate_theme_files(&theme_dir)? {
|
|
file_map.insert(rel, src);
|
|
}
|
|
|
|
let managed_targets: HashSet<PathBuf> = current_state
|
|
.map(|s| s.entries.iter().map(|e| PathBuf::from(&e.target)).collect())
|
|
.unwrap_or_default();
|
|
|
|
let mut actions = Vec::new();
|
|
|
|
for (relative, source) in file_map {
|
|
let target = home.join(&relative);
|
|
|
|
match target.symlink_metadata() {
|
|
Err(_) => {
|
|
// Target does not exist.
|
|
actions.push(PlannedAction::CreateLink { source, target });
|
|
}
|
|
Ok(meta) => {
|
|
if meta.file_type().is_symlink() {
|
|
match fs::read_link(&target) {
|
|
Ok(dest) if dest == source => {
|
|
// Already pointing to the right place.
|
|
actions.push(PlannedAction::Skip { source, target });
|
|
}
|
|
_ => {
|
|
if managed_targets.contains(&target) {
|
|
// Our symlink, just re-point it.
|
|
actions.push(PlannedAction::Replace { source, target });
|
|
} else if force {
|
|
actions.push(PlannedAction::BackupAndReplace { source, target });
|
|
} else {
|
|
actions.push(PlannedAction::Conflict { target });
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Regular file or directory.
|
|
if force {
|
|
actions.push(PlannedAction::BackupAndReplace { source, target });
|
|
} else {
|
|
actions.push(PlannedAction::Conflict { target });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(ApplyPlan {
|
|
theme_name: theme_name.to_string(),
|
|
actions,
|
|
})
|
|
}
|
|
|
|
/// Return the dry-run plan as a list of human-readable lines (no I/O side effects).
|
|
pub fn plan_lines(plan: &ApplyPlan) -> Vec<String> {
|
|
plan.actions
|
|
.iter()
|
|
.map(|action| match action {
|
|
PlannedAction::CreateLink { source, target } => {
|
|
format!(" create {} -> {}", target.display(), source.display())
|
|
}
|
|
PlannedAction::Skip { target, .. } => {
|
|
format!(" skip {} (already correct)", target.display())
|
|
}
|
|
PlannedAction::Replace { source, target } => {
|
|
format!(" replace {} -> {}", target.display(), source.display())
|
|
}
|
|
PlannedAction::BackupAndReplace { source, target } => {
|
|
format!(
|
|
" backup {} then link -> {}",
|
|
target.display(),
|
|
source.display()
|
|
)
|
|
}
|
|
PlannedAction::Conflict { target } => {
|
|
format!(" CONFLICT {}", target.display())
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Print a human-readable dry-run summary of the plan.
|
|
pub fn print_plan(plan: &ApplyPlan) {
|
|
for line in plan_lines(plan) {
|
|
println!("{line}");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Execution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
enum Undo {
|
|
RemoveFile(PathBuf),
|
|
RestoreFile {
|
|
from_backup: PathBuf,
|
|
to_target: PathBuf,
|
|
},
|
|
RestoreSymlink {
|
|
old_dest: PathBuf,
|
|
target: PathBuf,
|
|
},
|
|
}
|
|
|
|
/// Execute the plan. Returns the new state and a summary on success.
|
|
/// On failure, attempts to roll back all completed actions before returning the error.
|
|
pub fn execute_apply(plan: &ApplyPlan, verbose: bool) -> Result<(State, ApplySummary)> {
|
|
// Reject if there are any conflicts.
|
|
let conflicts: Vec<&PathBuf> = plan
|
|
.actions
|
|
.iter()
|
|
.filter_map(|a| {
|
|
if let PlannedAction::Conflict { target } = a {
|
|
Some(target)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if !conflicts.is_empty() {
|
|
for t in &conflicts {
|
|
eprintln!(
|
|
"error: unmanaged file would be overwritten: {} (use --force)",
|
|
t.display()
|
|
);
|
|
}
|
|
anyhow::bail!(
|
|
"{} conflict(s) found; use --force to overwrite unmanaged files",
|
|
conflicts.len()
|
|
);
|
|
}
|
|
|
|
let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
|
|
let backup_base = backup_base_for(×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"));
|
|
}
|
|
|
|
// ---- base layer tests -----------------------------------------------
|
|
|
|
fn make_base_file(env: &TestEnv, rel: &str, content: &str) -> PathBuf {
|
|
make_theme_file(env, "base", rel, content)
|
|
}
|
|
|
|
#[test]
|
|
fn base_files_applied_alongside_theme() {
|
|
let env = TestEnv::new();
|
|
make_base_file(&env, ".config/hypr/keybinds.conf", "# base keybinds");
|
|
make_theme_file(&env, "dracula", ".zshrc", "# dracula");
|
|
|
|
let plan = plan_apply("dracula", false, None).unwrap();
|
|
assert_eq!(plan.actions.len(), 2);
|
|
|
|
let (_, summary) = execute_apply(&plan, false).unwrap();
|
|
assert_eq!(summary.created, 2);
|
|
|
|
let keybinds = fs::read_link(env.home.path().join(".config/hypr/keybinds.conf")).unwrap();
|
|
assert!(keybinds.to_string_lossy().contains("base"));
|
|
|
|
let zshrc = fs::read_link(env.home.path().join(".zshrc")).unwrap();
|
|
assert!(zshrc.to_string_lossy().contains("dracula"));
|
|
}
|
|
|
|
#[test]
|
|
fn theme_overrides_base_file() {
|
|
let env = TestEnv::new();
|
|
make_base_file(&env, ".zshrc", "# base zshrc");
|
|
make_theme_file(&env, "dracula", ".zshrc", "# dracula zshrc");
|
|
|
|
let plan = plan_apply("dracula", false, None).unwrap();
|
|
// Only one action — theme wins over base for same path.
|
|
assert_eq!(plan.actions.len(), 1);
|
|
|
|
execute_apply(&plan, false).unwrap();
|
|
let link = fs::read_link(env.home.path().join(".zshrc")).unwrap();
|
|
assert!(link.to_string_lossy().contains("dracula"));
|
|
}
|
|
|
|
#[test]
|
|
fn base_file_stays_when_theme_has_no_override() {
|
|
let env = TestEnv::new();
|
|
make_base_file(&env, ".config/hypr/keybinds.conf", "# base keybinds");
|
|
make_theme_file(&env, "dracula", ".zshrc", "# dracula");
|
|
make_theme_file(&env, "catppuccin", ".zshrc", "# catppuccin");
|
|
|
|
// Apply dracula
|
|
let plan = plan_apply("dracula", false, None).unwrap();
|
|
let (dracula_state, _) = execute_apply(&plan, false).unwrap();
|
|
|
|
// Switch to catppuccin — neither theme has keybinds.conf, so base symlink stays
|
|
let plan2 = plan_apply("catppuccin", false, Some(&dracula_state)).unwrap();
|
|
|
|
let keybinds_action = plan2
|
|
.actions
|
|
.iter()
|
|
.find(|a| match a {
|
|
PlannedAction::Skip { target, .. } => {
|
|
target.to_string_lossy().contains("keybinds")
|
|
}
|
|
_ => false,
|
|
});
|
|
assert!(keybinds_action.is_some(), "keybinds.conf should be skipped (already correct)");
|
|
}
|
|
|
|
#[test]
|
|
fn base_is_rejected_as_theme_name() {
|
|
let env = TestEnv::new();
|
|
make_base_file(&env, ".zshrc", "# base");
|
|
let result = plan_apply("base", false, None);
|
|
assert!(result.is_err());
|
|
}
|
|
}
|