use similar_asserts::assert_eq;
use smol_str::SmolStr;
use zenops::{
Cmd,
config_files::{ConfigFilePath, ConfigFilesError},
error::Error,
git::GitFileStatus,
output::{PkgStatus, Status},
prompt::{PreApplyAnswer, parse_pre_apply_input},
};
use zenops_safe_relative_path::srpath;
use test_env::{Entry, Output, paths};
mod test_env;
#[test]
fn missing_config() {
let env = test_env::TestEnv::load();
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Err(Error::Config(zenops::error::ConfigError::OpenDb(
env.resolve_path(paths::ZENOPS_CONFIG),
std::io::ErrorKind::NotFound.into()
)))
);
env.write_zenops_file(srpath!("config.toml"), "", None);
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output { entries: vec![] }),
);
env.init_config("");
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![env.git_repo_clean_entry()],
}),
);
}
#[test]
fn config_dir_git_status() {
let env = test_env::TestEnv::load();
env.init_config("");
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![env.git_repo_clean_entry()],
})
);
env.append_zenops_file(srpath!("config.toml"), "# Modification", None);
env.write_zenops_file(srpath!("untracked-file"), "# Untracked file", None);
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![
Entry::Status(Status::Git {
repo: env.cfpath("", ConfigFilePath::Zenops),
status: GitFileStatus::Modified(srpath!("config.toml").into()),
}),
Entry::Status(Status::Git {
repo: env.cfpath("", ConfigFilePath::Zenops),
status: GitFileStatus::Untracked(srpath!("untracked-file").into()),
})
]
})
);
}
#[test]
fn apply_warns_on_uncommitted_changes_with_yes_and_allow_dirty() {
let env = test_env::TestEnv::load();
env.init_config("");
env.append_zenops_file(srpath!("config.toml"), "# Local tweak", None);
env.write_zenops_file(srpath!("local-note"), "wip", None);
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
}),
Ok(Output {
entries: vec![
Entry::Status(Status::Git {
repo: env.cfpath("", ConfigFilePath::Zenops),
status: GitFileStatus::Modified(srpath!("config.toml").into()),
}),
Entry::Status(Status::Git {
repo: env.cfpath("", ConfigFilePath::Zenops),
status: GitFileStatus::Untracked(srpath!("local-note").into()),
}),
]
})
);
}
#[test]
fn apply_yes_on_dirty_repo_errors_without_allow_dirty() {
let env = test_env::TestEnv::load();
env.init_config("");
env.append_zenops_file(srpath!("config.toml"), "# Local tweak", None);
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: false,
}),
Err(Error::DirtyRepoRequiresAllowDirty(
env.resolve_path(paths::ZENOPS_DIR)
)),
);
}
#[test]
fn apply_warns_on_deleted_tracked_file() {
let env = test_env::TestEnv::load();
env.init_config("");
env.write_zenops_file(srpath!("extra"), "to be deleted", Some("add extra"));
env.delete_file(paths::ZENOPS_DIR.safe_join(srpath!("extra")));
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
}),
Ok(Output {
entries: vec![Entry::Status(Status::Git {
repo: env.cfpath("", ConfigFilePath::Zenops),
status: GitFileStatus::Deleted(srpath!("extra").into()),
})]
})
);
}
#[test]
fn apply_clean_repo_with_yes_does_not_require_allow_dirty() {
let env = test_env::TestEnv::load();
env.init_config("");
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: false,
}),
Ok(Output { entries: vec![] })
);
}
#[test]
fn parse_pre_apply_input_covers_expected_answers() {
assert_eq!(parse_pre_apply_input("c"), Some(PreApplyAnswer::Commit));
assert_eq!(parse_pre_apply_input("C"), Some(PreApplyAnswer::Commit));
assert_eq!(
parse_pre_apply_input("commit\n"),
Some(PreApplyAnswer::Commit)
);
assert_eq!(parse_pre_apply_input(""), Some(PreApplyAnswer::Continue));
assert_eq!(parse_pre_apply_input("\n"), Some(PreApplyAnswer::Continue));
assert_eq!(parse_pre_apply_input("y"), Some(PreApplyAnswer::Continue));
assert_eq!(parse_pre_apply_input("YES"), Some(PreApplyAnswer::Continue));
assert_eq!(parse_pre_apply_input("n"), Some(PreApplyAnswer::Abort));
assert_eq!(parse_pre_apply_input("abort"), Some(PreApplyAnswer::Abort));
assert_eq!(parse_pre_apply_input("maybe"), None);
}
#[test]
fn apply_emits_pkg_missing_when_on_plus_detect_misses() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[pkg.ghosttool]
enable = "on"
[pkg.ghosttool.install_hint.brew]
packages = ["ghosttool"]
[pkg.ghosttool.detect]
exists = "/definitely/does/not/exist/zenops-test-ghosttool"
"#,
);
let brew_available = std::env::var("PATH")
.unwrap_or_default()
.split(':')
.any(|dir| std::path::Path::new(dir).join("brew").is_file());
let out = env
.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
let expected_install_command = brew_available.then(|| "brew install ghosttool".to_string());
assert!(
out.entries.contains(&Entry::Status(Status::Pkg {
pkg: SmolStr::new("ghosttool"),
status: PkgStatus::Missing {
install_command: expected_install_command,
},
})),
"expected Pkg{{Missing}} for ghosttool, got: {:?}",
out.entries
);
}
#[test]
fn apply_is_silent_for_detect_variant_miss() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[pkg.quietpkg]
enable = "detect"
[pkg.quietpkg.install_hint.brew]
packages = ["quietpkg"]
[pkg.quietpkg.detect]
exists = "/definitely/does/not/exist/zenops-test-quietpkg"
"#,
);
let out = env
.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
assert!(
!out.entries.iter().any(|e| matches!(
e,
Entry::Status(Status::Pkg { pkg, .. }) if pkg == "quietpkg"
)),
"detect-miss should not push any Pkg event, got: {:?}",
out.entries
);
}
#[test]
fn apply_pkg_missing_with_no_install_hint_has_no_command() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[pkg.hintless]
enable = "on"
[pkg.hintless.install_hint.brew]
packages = []
[pkg.hintless.detect]
exists = "/definitely/does/not/exist/zenops-test-hintless"
"#,
);
let out = env
.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
assert!(
out.entries.contains(&Entry::Status(Status::Pkg {
pkg: SmolStr::new("hintless"),
status: PkgStatus::Missing {
install_command: None,
},
})),
"expected Pkg{{Missing}} without install_command, got: {:?}",
out.entries
);
}
#[test]
fn apply_no_pkg_missing_when_detect_is_empty() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[pkg.metapkg]
enable = "on"
[pkg.metapkg.install_hint.brew]
packages = []
"#,
);
let out = env
.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
assert!(
!out.entries.iter().any(|e| matches!(
e,
Entry::Status(Status::Pkg { pkg, .. }) if pkg == "metapkg"
)),
"empty-detect pkg should not emit any Pkg event, got: {:?}",
out.entries
);
}
#[test]
fn apply_filters_pkg_by_when_shell() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "zsh"
[shell.environment]
[shell.alias]
[pkg.alien-shell]
enable = "on"
when = "bash"
[pkg.alien-shell.install_hint.brew]
packages = []
[[pkg.alien-shell.shell.interactive_init.zsh]]
type = "line"
line = "echo wrong-shell"
"#,
);
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
let zshrc = std::fs::read_to_string(env.resolve_path(srpath!("home/bob/.zshrc")))
.expect("zshrc should exist");
assert!(
!zshrc.contains("echo wrong-shell"),
"pkg gated by when=\"bash\" must not contribute on the wrong shell, got:\n{zshrc}"
);
}
#[test]
fn apply_filters_pkg_by_when_os() {
let other_os = if cfg!(target_os = "macos") {
"linux"
} else {
"macos"
};
let env = test_env::TestEnv::load();
env.init_config(&format!(
r#"
[shell]
type = "zsh"
[shell.environment]
[shell.alias]
[pkg.alien]
enable = "on"
when = "{other_os}"
[pkg.alien.install_hint.brew]
packages = []
[[pkg.alien.shell.env_init.zsh]]
type = "line"
line = "echo wrong-os"
"#
));
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
let zshenv = std::fs::read_to_string(env.resolve_path(srpath!("home/bob/.zshenv")))
.expect("zshenv should exist");
assert!(
!zshenv.contains("echo wrong-os"),
"pkg gated by when=\"<other-os>\" must not contribute on the wrong OS, got:\n{zshenv}"
);
}
#[test]
fn apply_updates_generated_file_when_disk_content_differs() {
use zenops::output::AppliedAction;
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "bash"
[shell.environment]
[shell.alias]
"#,
);
let stale = "# stale content from a previous apply\nexport STALE=1\n";
env.write_file(srpath!("home/bob/.zenops_bash_profile"), stale);
let out = env
.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply must succeed");
let updates: Vec<_> = out
.entries
.iter()
.filter_map(|e| match e {
Entry::AppliedAction(AppliedAction::UpdatedFile(p)) => Some(p),
_ => None,
})
.collect();
assert_eq!(
updates.len(),
1,
"expected exactly one UpdatedFile event, got: {:?}",
out.entries,
);
assert_eq!(
updates[0].path,
ConfigFilePath::in_home(srpath!(".zenops_bash_profile")),
);
let disk = std::fs::read_to_string(env.resolve_path(srpath!("home/bob/.zenops_bash_profile")))
.unwrap();
assert!(
!disk.contains("STALE"),
"expected stale content to be replaced, got:\n{disk}"
);
}
#[test]
fn apply_dry_run_does_not_rewrite_modified_files() {
use zenops::output::AppliedAction;
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "bash"
[shell.environment]
[shell.alias]
"#,
);
let stale = "# stale content from a previous apply\nexport STALE=1\n";
env.write_file(srpath!("home/bob/.zenops_bash_profile"), stale);
let out = env
.run(&Cmd::Apply {
pull_config: false,
yes: false,
dry_run: true,
allow_dirty: true,
})
.expect("dry-run apply must succeed");
let any_update = out
.entries
.iter()
.any(|e| matches!(e, Entry::AppliedAction(AppliedAction::UpdatedFile(_))));
assert!(
!any_update,
"dry-run apply must not emit UpdatedFile, got: {:?}",
out.entries,
);
let disk = std::fs::read_to_string(env.resolve_path(srpath!("home/bob/.zenops_bash_profile")))
.unwrap();
assert_eq!(disk, stale, "dry-run must leave the file untouched");
}
#[test]
fn apply_dry_run_skips_create_file() {
use zenops::output::AppliedAction;
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "bash"
[shell.environment]
[shell.alias]
"#,
);
let out = env
.run(&Cmd::Apply {
pull_config: false,
yes: false,
dry_run: true,
allow_dirty: true,
})
.expect("dry-run apply must succeed");
let any_create = out.entries.iter().any(|e| {
matches!(
e,
Entry::AppliedAction(AppliedAction::CreatedFile(_))
| Entry::AppliedAction(AppliedAction::UpdatedFile(_))
)
});
assert!(
!any_create,
"dry-run apply must not emit CreatedFile/UpdatedFile, got: {:?}",
out.entries,
);
let target = env.resolve_path(srpath!("home/bob/.zenops_bash_profile"));
assert!(
target.symlink_metadata().is_err(),
"dry-run must not create {target:?}"
);
}
#[test]
fn entry_status_propagates_unreadable_generated_file() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "bash"
[shell.environment]
[shell.alias]
"#,
);
env.create_dir(srpath!("home/bob/.zenops_bash_profile"));
let result = env.run(&Cmd::Status {
diff: false,
all: false,
});
match result {
Err(Error::ConfigFiles(ConfigFilesError::FailedToReadConfig(p, e))) => {
assert_eq!(
p.path,
ConfigFilePath::in_home(srpath!(".zenops_bash_profile"))
);
assert!(
matches!(
e.kind(),
std::io::ErrorKind::IsADirectory | std::io::ErrorKind::Other,
),
"unexpected I/O error kind: {:?}",
e.kind(),
);
}
other => panic!("expected FailedToReadConfig, got {other:?}"),
}
}
#[test]
fn apply_journey_import_edit_reconcile_apply() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "bash"
[shell.environment]
[shell.alias]
"#,
);
env.write_file(
srpath!("home/bob/.config/helix/config.toml"),
b"theme = 'dark'\n",
);
env.write_file(
srpath!("home/bob/.config/helix/themes/dark.toml"),
b"name = 'dark'\n",
);
env.write_file(srpath!("home/bob/.config/helix/init.lua"), b"-- editor\n");
let import = Cmd::Import {
path: env.resolve_path(srpath!("home/bob/.config/helix")),
pkg: None,
source: None,
brew: vec!["helix".into()],
no_install_hint: false,
yes: true,
dry_run: false,
};
env.run(&import).expect("phase A: import should succeed");
let cfg_path = env.resolve_path(srpath!("home/bob/.config/zenops/config.toml"));
let parse_helix_symlinks = || -> Vec<String> {
let text = std::fs::read_to_string(&cfg_path).unwrap();
let cfg: toml::Value = toml::from_str(&text).unwrap();
let mut s: Vec<String> = cfg["pkg"]["helix"]["configs"][0]["symlinks"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
s.sort();
s
};
assert_eq!(
parse_helix_symlinks(),
vec![
"config.toml".to_string(),
"init.lua".to_string(),
"themes/dark.toml".to_string(),
],
);
for rel in ["config.toml", "init.lua", "themes/dark.toml"] {
let home_rel: zenops_safe_relative_path::SafeRelativePathBuf =
format!("home/bob/.config/helix/{rel}").parse().unwrap();
let repo_rel: zenops_safe_relative_path::SafeRelativePathBuf =
format!("home/bob/.config/zenops/configs/helix/{rel}")
.parse()
.unwrap();
assert!(
std::fs::symlink_metadata(env.resolve_path(&home_rel))
.unwrap()
.file_type()
.is_symlink(),
"{rel} should be a symlink after import",
);
assert!(
env.resolve_path(&repo_rel).exists(),
"{rel} repo copy should exist after import",
);
}
let helix_cfg_home = env.resolve_path(srpath!("home/bob/.config/helix/config.toml"));
let helix_cfg_repo =
env.resolve_path(srpath!("home/bob/.config/zenops/configs/helix/config.toml"));
std::fs::write(&helix_cfg_home, b"theme = 'dracula'\n").unwrap();
assert_eq!(
std::fs::read(&helix_cfg_home).unwrap(),
b"theme = 'dracula'\n"
);
assert_eq!(
std::fs::read(&helix_cfg_repo).unwrap(),
b"theme = 'dracula'\n"
);
assert!(
std::fs::symlink_metadata(&helix_cfg_home)
.unwrap()
.file_type()
.is_symlink(),
"phase B: write through must not break the symlink",
);
env.write_file(
srpath!("home/bob/.config/helix/themes/light.toml"),
b"name = 'light'\n",
);
let reconcile = Cmd::Import {
path: env.resolve_path(srpath!("home/bob/.config/helix")),
pkg: None,
source: None,
brew: Vec::new(),
no_install_hint: false,
yes: true,
dry_run: false,
};
env.run(&reconcile)
.expect("phase C: reconcile should succeed");
assert_eq!(
parse_helix_symlinks(),
vec![
"config.toml".to_string(),
"init.lua".to_string(),
"themes/dark.toml".to_string(),
"themes/light.toml".to_string(),
],
);
let new_link = env.resolve_path(srpath!("home/bob/.config/helix/themes/light.toml"));
let new_repo = env.resolve_path(srpath!(
"home/bob/.config/zenops/configs/helix/themes/light.toml"
));
assert!(
std::fs::symlink_metadata(&new_link)
.unwrap()
.file_type()
.is_symlink(),
);
assert_eq!(std::fs::read_link(&new_link).unwrap(), new_repo);
env.delete_file(srpath!("home/bob/.config/helix/themes/dark.toml"));
env.delete_file(srpath!(
"home/bob/.config/zenops/configs/helix/themes/dark.toml"
));
env.run(&reconcile)
.expect("phase D: reconcile should succeed");
assert_eq!(
parse_helix_symlinks(),
vec![
"config.toml".to_string(),
"init.lua".to_string(),
"themes/light.toml".to_string(),
],
);
assert!(
!env.resolve_path(srpath!(
"home/bob/.config/zenops/configs/helix/themes/dark.toml"
))
.exists(),
);
assert!(
env.resolve_path(srpath!("home/bob/.config/zenops/configs/helix/themes"))
.is_dir(),
);
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("phase E: apply should succeed");
for rel in ["config.toml", "init.lua", "themes/light.toml"] {
let home_rel: zenops_safe_relative_path::SafeRelativePathBuf =
format!("home/bob/.config/helix/{rel}").parse().unwrap();
let repo_rel: zenops_safe_relative_path::SafeRelativePathBuf =
format!("home/bob/.config/zenops/configs/helix/{rel}")
.parse()
.unwrap();
let home_path = env.resolve_path(&home_rel);
let repo_path = env.resolve_path(&repo_rel);
assert!(
std::fs::symlink_metadata(&home_path)
.unwrap()
.file_type()
.is_symlink(),
"{rel} should still be a symlink after apply",
);
assert_eq!(std::fs::read_link(&home_path).unwrap(), repo_path);
}
assert_eq!(
std::fs::read(&helix_cfg_home).unwrap(),
b"theme = 'dracula'\n",
"phase B's edit must still be on disk after apply",
);
}