doot/crates/doot-cli/tests/e2e.rs
2026-02-05 22:35:09 -06:00

320 lines
8.7 KiB
Rust

use std::path::PathBuf;
use std::process::Command;
struct Sandbox {
path: PathBuf,
}
impl Sandbox {
fn new(name: &str) -> Self {
let path = std::env::temp_dir().join(format!("doot-test-{}", name));
if path.exists() {
std::fs::remove_dir_all(&path).unwrap();
}
std::fs::create_dir_all(&path).unwrap();
Self { path }
}
fn run(&self, args: &[&str]) -> std::process::Output {
let doot = env!("CARGO_BIN_EXE_doot");
Command::new(doot)
.args(args)
.env("DOOT_HOME", &self.path)
.env("DOOT_TEST_MODE", "1")
.output()
.expect("failed to run doot")
}
fn config_dir(&self) -> PathBuf {
self.path.join(".config/doot")
}
fn state_dir(&self) -> PathBuf {
self.path.join(".local/state/doot")
}
fn config_file(&self) -> PathBuf {
self.config_dir().join("doot.doot")
}
fn write_config(&self, content: &str) {
std::fs::create_dir_all(self.config_dir()).unwrap();
std::fs::write(self.config_file(), content).unwrap();
}
fn write_source(&self, path: &str, content: &str) {
let full_path = self.config_dir().join(path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(full_path, content).unwrap();
}
fn is_symlink(&self, path: &PathBuf) -> bool {
path.is_symlink()
}
fn symlink_target(&self, path: &PathBuf) -> Option<PathBuf> {
std::fs::read_link(path).ok()
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn test_init_creates_structure() {
let sandbox = Sandbox::new("init");
let output = sandbox.run(&["init"]);
assert!(output.status.success(), "init failed: {:?}", output);
assert!(sandbox.config_file().exists(), "config file not created");
assert!(
sandbox.config_dir().join("config").exists(),
"config dir not created"
);
assert!(
sandbox.state_dir().join("backups").exists(),
"backups dir not created"
);
assert!(
sandbox.state_dir().join("snapshots").exists(),
"snapshots dir not created"
);
}
#[test]
fn test_check_valid_config() {
let sandbox = Sandbox::new("check-valid");
sandbox.write_config(
r#"
package: "ripgrep"
package: "fd"
"#,
);
let output = sandbox.run(&["check"]);
assert!(output.status.success(), "check failed: {:?}", output);
}
#[test]
fn test_apply_dry_run() {
let sandbox = Sandbox::new("apply-dry");
sandbox.write_config(
r#"
dotfile:
source = "config/test.conf"
target = "~/.config/test/test.conf"
"#,
);
sandbox.write_source("config/test.conf", "test content");
let output = sandbox.run(&["apply", "-n"]);
assert!(output.status.success(), "apply -n failed: {:?}", output);
let target = sandbox.path.join(".config/test/test.conf");
assert!(!target.exists(), "dry run should not create files");
}
#[test]
fn test_apply_creates_symlink() {
let sandbox = Sandbox::new("apply-symlink");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
deploy = "link"
"#,
);
sandbox.write_source("config/app.conf", "app config content");
let output = sandbox.run(&["apply"]);
assert!(output.status.success(), "apply failed: {:?}", output);
let target = sandbox.path.join(".config/app/app.conf");
assert!(sandbox.is_symlink(&target), "target should be symlink");
let expected_source = sandbox.config_dir().join("config/app.conf");
assert_eq!(
sandbox.symlink_target(&target),
Some(expected_source),
"symlink should point to source"
);
}
#[test]
fn test_apply_unchanged_on_rerun() {
let sandbox = Sandbox::new("apply-unchanged");
sandbox.write_config(
"dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n deploy = \"link\"\n",
);
sandbox.write_source("config/app.conf", "content");
let first = sandbox.run(&["apply"]);
assert!(first.status.success(), "first apply failed");
let second = sandbox.run(&["apply"]);
assert!(second.status.success(), "second apply failed");
// Second apply should succeed (symlink already exists and points correctly)
let target = sandbox.path.join(".config/app/app.conf");
assert!(
target.is_symlink(),
"target should still be symlink after second apply"
);
}
#[test]
fn test_apply_creates_copy() {
let sandbox = Sandbox::new("apply-copy");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
);
sandbox.write_source("config/app.conf", "app config content");
let output = sandbox.run(&["apply"]);
assert!(output.status.success(), "apply failed: {:?}", output);
let target = sandbox.path.join(".config/app/app.conf");
assert!(target.exists(), "target should exist");
assert!(
!target.is_symlink(),
"target should be a copy, not a symlink"
);
let content = std::fs::read_to_string(&target).unwrap();
assert_eq!(content, "app config content", "content should match source");
}
#[test]
fn test_apply_copy_unchanged_on_rerun() {
let sandbox = Sandbox::new("apply-copy-unchanged");
sandbox.write_config(
"dotfile:\n source = \"config/app.conf\"\n target = \"~/.config/app/app.conf\"\n",
);
sandbox.write_source("config/app.conf", "content");
let first = sandbox.run(&["apply"]);
assert!(first.status.success(), "first apply failed");
let second = sandbox.run(&["apply"]);
assert!(second.status.success(), "second apply failed");
let target = sandbox.path.join(".config/app/app.conf");
assert!(target.exists(), "target should exist after second apply");
assert!(!target.is_symlink(), "target should still be a copy");
}
#[test]
fn test_status_shows_state() {
let sandbox = Sandbox::new("status");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
);
sandbox.write_source("config/app.conf", "content");
sandbox.run(&["apply"]);
let output = sandbox.run(&["status"]);
assert!(output.status.success(), "status failed: {:?}", output);
}
#[test]
fn test_snapshot_and_rollback() {
let sandbox = Sandbox::new("snapshot");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
);
sandbox.write_source("config/app.conf", "v1");
sandbox.run(&["apply"]);
let snap_output = sandbox.run(&["snapshot", "v1"]);
assert!(
snap_output.status.success(),
"snapshot failed: {:?}",
snap_output
);
let snapshot_file = sandbox.state_dir().join("snapshots/v1.json");
assert!(snapshot_file.exists(), "snapshot file not created");
}
#[test]
fn test_dotfile_with_when_condition() {
let sandbox = Sandbox::new("conditional");
// Test that 'when' condition works - only deploy if condition is true
let config = r#"dotfile:
source = "config/test.conf"
target = "~/.config/test.conf"
when = true
"#;
sandbox.write_config(config);
sandbox.write_source("config/test.conf", "test content");
let output = sandbox.run(&["apply"]);
assert!(output.status.success(), "apply failed: {:?}", output);
let target = sandbox.path.join(".config/test.conf");
assert!(
target.exists(),
"file should be deployed when condition is true"
);
}
#[test]
fn test_dotfile_when_false_skips() {
let sandbox = Sandbox::new("when-false");
let config = r#"dotfile:
source = "config/skip.conf"
target = "~/.config/skip.conf"
when = false
"#;
sandbox.write_config(config);
sandbox.write_source("config/skip.conf", "should not deploy");
let output = sandbox.run(&["apply"]);
assert!(output.status.success(), "apply failed: {:?}", output);
let target = sandbox.path.join(".config/skip.conf");
assert!(
!target.exists(),
"file should NOT be deployed when condition is false"
);
}
#[test]
fn test_diff_shows_changes() {
let sandbox = Sandbox::new("diff");
sandbox.write_config(
r#"
dotfile:
source = "config/app.conf"
target = "~/.config/app/app.conf"
"#,
);
sandbox.write_source("config/app.conf", "new content");
let target_dir = sandbox.path.join(".config/app");
std::fs::create_dir_all(&target_dir).unwrap();
std::fs::write(target_dir.join("app.conf"), "old content").unwrap();
let output = sandbox.run(&["diff"]);
assert!(output.status.success(), "diff failed: {:?}", output);
}