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 { 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); }