Finished project
This commit is contained in:
parent
6849f1afae
commit
31179d64e5
12 changed files with 2207 additions and 3 deletions
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"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue