use similar_asserts::assert_eq;
use smol_str::SmolStr;
use std::sync::Arc;
use zenops::{
Cmd,
config_files::ConfigFilePath,
error::Error,
git::{GitCmd, GitFileStatus},
output::{
AppliedAction, FileStatus, PkgEntry, PkgEntryState, PkgStatus, Status, SymlinkStatus,
},
pkg_list,
prompt::{PreApplyAnswer, parse_pre_apply_input},
};
use zenops_safe_relative_path::srpath;
use test_env::{Entry, Output, paths};
fn pkg_row(entry: &PkgEntry) -> Option<(&str, PkgEntryState)> {
if let PkgEntry::Pkg { name, state, .. } = entry {
Some((name.as_str(), *state))
} else {
None
}
}
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::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 repo_commit_records_staged_changes_via_all_flag() {
let env = test_env::TestEnv::load();
env.init_config("");
env.append_zenops_file(srpath!("config.toml"), "\n# added", None);
env.run(&Cmd::Repo {
command: GitCmd::Commit {
all: true,
message: Some("update config".into()),
},
})
.expect("repo commit should succeed");
let zenops = env.resolve_path(paths::ZENOPS_DIR);
let head_subject = env.git_out(&zenops, &["log", "-1", "--pretty=%s"]);
assert_eq!(head_subject.trim(), "update config");
}
#[test]
fn repo_push_uploads_local_commits_to_bare_remote() {
let env = test_env::TestEnv::load();
let bare = env.init_config_with_remote("");
env.write_zenops_file(srpath!("note"), "hi", Some("add note"));
env.run(&Cmd::Repo {
command: GitCmd::Push {},
})
.expect("repo push should succeed");
let remote_log = env.git_out(&bare, &["log", "--oneline", "main"]);
assert!(
remote_log.contains("add note"),
"remote did not receive the pushed commit: {remote_log:?}",
);
}
#[test]
fn repo_pull_rebase_fast_forwards_remote_commits() {
let env = test_env::TestEnv::load();
let bare = env.init_config_with_remote("");
env.seed_remote_commit(&bare, "from-remote", "hello\n", "remote commit");
let zenops = env.resolve_path(paths::ZENOPS_DIR);
assert!(
!env.git_out(&zenops, &["log", "--oneline"])
.contains("remote commit"),
"precondition: local repo should not yet have the remote commit",
);
env.run(&Cmd::Repo {
command: GitCmd::Pull {
rebase: Some(String::new()),
},
})
.expect("repo pull --rebase should succeed");
let local_log = env.git_out(&zenops, &["log", "--oneline"]);
assert!(
local_log.contains("remote commit"),
"local repo did not pick up the remote commit: {local_log:?}",
);
assert!(
zenops.join("from-remote").exists(),
"pulled file should exist in the local working tree",
);
}
#[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 pkg_list_shows_defaults_as_missing() {
let env = test_env::TestEnv::load();
env.init_config("");
let entries = env
.run_pkg_list(pkg_list::Options::default())
.expect("pkg list should succeed");
let names: Vec<&str> = entries.iter().filter_map(pkg_row).map(|(n, _)| n).collect();
assert!(
names.contains(&"cargo"),
"expected cargo in entries: {names:?}"
);
assert!(names.contains(&"sk"), "expected sk in entries: {names:?}");
assert!(
names.contains(&"starship"),
"expected starship in entries: {names:?}",
);
let starship_desc = entries.iter().find_map(|e| match e {
PkgEntry::Pkg {
name, description, ..
} if name == "starship" => description.as_deref(),
_ => None,
});
assert_eq!(
starship_desc.unwrap_or_default(),
"starship — cross-shell prompt.",
"starship entry should carry its description verbatim",
);
}
#[test]
fn pkg_list_aggregates_missing_packages_into_footer() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[pkg.alpha]
enable = "detect"
description = "Alpha test pkg."
[pkg.alpha.detect]
type = "file"
path = "~/.alpha-marker"
[pkg.alpha.install_hint.brew]
packages = ["alpha-formula"]
[pkg.bravo]
enable = "detect"
description = "Bravo test pkg."
[pkg.bravo.detect]
type = "file"
path = "~/.bravo-marker"
[pkg.bravo.install_hint.brew]
packages = ["bravo-formula"]
"#,
);
let entries = env
.run_pkg_list(pkg_list::Options::default())
.expect("pkg list should succeed");
let brew_available = std::env::var("PATH")
.unwrap_or_default()
.split(':')
.any(|dir| std::path::Path::new(dir).join("brew").is_file());
let aggregate = entries.iter().find_map(|e| match e {
PkgEntry::AggregateInstall {
command, packages, ..
} => Some((command.as_str(), packages.as_slice())),
_ => None,
});
if brew_available {
let (command, packages) =
aggregate.unwrap_or_else(|| panic!("expected aggregate install entry: {entries:?}"));
assert!(
packages.iter().any(|p| p == "alpha-formula")
&& packages.iter().any(|p| p == "bravo-formula"),
"aggregate should list both missing pkgs, got: {packages:?}",
);
assert!(
command.starts_with("brew install"),
"aggregate command should be a brew install line, got: {command:?}",
);
} else {
assert!(
aggregate.is_none(),
"expected no aggregate install without brew on PATH, got: {entries:?}",
);
assert!(
entries
.iter()
.any(|e| matches!(e, PkgEntry::NoPackageManagerDetected { .. })),
"expected NoPackageManagerDetected event, got: {entries:?}",
);
}
}
#[test]
fn pkg_list_all_flag_surfaces_disabled_pkgs() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[pkg.ghost]
enable = "disabled"
description = "A pkg the user opted out of."
[pkg.ghost.install_hint.brew]
packages = []
"#,
);
let default_entries = env
.run_pkg_list(pkg_list::Options::default())
.expect("pkg list should succeed");
assert!(
!default_entries
.iter()
.any(|e| matches!(pkg_row(e), Some(("ghost", _)))),
"disabled pkg should be hidden by default, got: {default_entries:?}",
);
let all_entries = env
.run_pkg_list(pkg_list::Options {
all: true,
..Default::default()
})
.expect("pkg list --all should succeed");
let ghost_state = all_entries
.iter()
.find_map(|e| match pkg_row(e) {
Some(("ghost", state)) => Some(state),
_ => None,
})
.unwrap_or_else(|| panic!("disabled pkg should appear with --all, got: {all_entries:?}"));
assert_eq!(
ghost_state,
PkgEntryState::Disabled,
"disabled pkg should carry PkgEntryState::Disabled",
);
}
#[test]
fn pkg_list_pattern_filters_by_substring() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[pkg.alpha]
enable = "detect"
[pkg.alpha.detect]
type = "file"
path = "~/.alpha-marker"
[pkg.alpha.install_hint.brew]
packages = []
[pkg.bravo]
enable = "detect"
[pkg.bravo.detect]
type = "file"
path = "~/.bravo-marker"
[pkg.bravo.install_hint.brew]
packages = []
"#,
);
let pkg_names = |entries: &[PkgEntry]| -> Vec<String> {
entries
.iter()
.filter_map(pkg_row)
.map(|(n, _)| n.to_string())
.collect()
};
let only_test_pkgs = |names: Vec<String>| -> Vec<String> {
names
.into_iter()
.filter(|n| n == "alpha" || n == "bravo")
.collect()
};
let entries = env
.run_pkg_list(pkg_list::Options {
pattern: vec!["alpha".into()],
..Default::default()
})
.expect("filter should succeed");
assert_eq!(
only_test_pkgs(pkg_names(&entries)),
vec!["alpha".to_string()],
"filter excludes bravo, got: {entries:?}",
);
let entries = env
.run_pkg_list(pkg_list::Options {
pattern: vec!["ALPHA".into()],
..Default::default()
})
.unwrap();
assert!(
entries
.iter()
.any(|e| matches!(pkg_row(e), Some(("alpha", _)))),
"uppercase pattern should match alpha, got: {entries:?}",
);
let entries = env
.run_pkg_list(pkg_list::Options {
pattern: vec!["alpha".into(), "bravo".into()],
..Default::default()
})
.unwrap();
let mut both = only_test_pkgs(pkg_names(&entries));
both.sort();
assert_eq!(both, vec!["alpha".to_string(), "bravo".to_string()]);
let entries = env
.run_pkg_list(pkg_list::Options {
pattern: vec!["zzz-nothing".into()],
..Default::default()
})
.unwrap();
assert!(
entries.iter().all(|e| !matches!(e, PkgEntry::Pkg { .. })),
"no rows should match 'zzz-nothing', got: {entries:?}",
);
}
#[test]
fn pkg_list_hides_pkgs_gated_to_other_os() {
let other_os = if cfg!(target_os = "macos") {
"linux"
} else {
"macos"
};
let env = test_env::TestEnv::load();
env.init_config(&format!(
r#"
[pkg.alien]
enable = "on"
supported_os = ["{other_os}"]
description = "Only applies on the other OS."
[pkg.alien.install_hint.brew]
packages = []
"#
));
let entries = env
.run_pkg_list(pkg_list::Options {
all: true,
..Default::default()
})
.expect("pkg list --all should succeed");
assert!(
!entries
.iter()
.any(|e| matches!(pkg_row(e), Some(("alien", _)))),
"pkg gated to the other OS must not appear in the list, got: {entries:?}",
);
}
#[test]
fn pkg_list_hides_pkgs_gated_to_other_shell() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "zsh"
[shell.environment]
[shell.alias]
[pkg.bash-only]
enable = "on"
supported_shells = ["bash"]
description = "Bash-only pkg."
[pkg.bash-only.install_hint.brew]
packages = []
"#,
);
let entries = env
.run_pkg_list(pkg_list::Options {
all: true,
..Default::default()
})
.expect("pkg list --all should succeed");
assert!(
!entries
.iter()
.any(|e| matches!(pkg_row(e), Some(("bash-only", _)))),
"pkg gated to other shell must not appear in list, got: {entries:?}",
);
}
#[test]
fn pkg_list_shell_filter_is_independent_of_shell_actions() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "bash"
[shell.environment]
[shell.alias]
[pkg.dual-actions]
enable = "on"
supported_shells = ["zsh"]
description = "Has both shell actions but gated to zsh."
[pkg.dual-actions.install_hint.brew]
packages = []
[[pkg.dual-actions.shell.interactive_init.bash]]
type = "line"
line = "echo from-bash"
[[pkg.dual-actions.shell.interactive_init.zsh]]
type = "line"
line = "echo from-zsh"
"#,
);
let entries = env
.run_pkg_list(pkg_list::Options::default())
.expect("pkg list should succeed");
assert!(
!entries
.iter()
.any(|e| matches!(pkg_row(e), Some(("dual-actions", _)))),
"shell gate must hide pkg even when bash actions exist, got: {entries:?}",
);
}
#[test]
fn apply_filters_pkg_by_supported_shells() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "zsh"
[shell.environment]
[shell.alias]
[pkg.alien-shell]
enable = "on"
supported_shells = ["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 supported_shells must not contribute on the wrong shell, got:\n{zshrc}"
);
}
#[test]
fn pkg_list_renders_name_override_instead_of_key() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[pkg.verbose-key-name]
enable = "on"
name = "short"
description = "Pkg with a display-name override."
[pkg.verbose-key-name.install_hint.brew]
packages = []
"#,
);
let entries = env
.run_pkg_list(pkg_list::Options::default())
.expect("pkg list should succeed");
let entry = entries
.iter()
.find_map(|e| match e {
PkgEntry::Pkg { name, key, .. } if key == "verbose-key-name" => {
Some(name.as_str().to_string())
}
_ => None,
})
.unwrap_or_else(|| panic!("expected pkg entry for verbose-key-name, got: {entries:?}"));
assert_eq!(entry, "short", "display name override should win over key");
}
#[test]
fn symlinked_configs() {
let env = test_env::TestEnv::load();
let dummy_config_symlink = paths::CONFIG_DIR.safe_join(srpath!("dummy-util/dummy-util.toml"));
let dummy_real = env.cfpath("configs/dummy-util/dummy-util.toml", ConfigFilePath::Zenops);
let dummy_symlink = env.cfpath("dummy-util/dummy-util.toml", ConfigFilePath::DotConfig);
let dummy2_config_symlink = paths::HOME_DIR.safe_join(srpath!(".dummy2/dummy2.toml"));
let dummy2_real = env.cfpath("configs/dummy2/dummy2.toml", ConfigFilePath::Zenops);
let dummy2_symlink = env.cfpath(".dummy2/dummy2.toml", ConfigFilePath::Home);
env.init_config(
r#"
[pkg.dummy-util]
enable = "on"
[pkg.dummy-util.install_hint.brew]
packages = []
[[pkg.dummy-util.configs]]
type = ".config"
source = "configs/dummy-util"
symlinks = [
"dummy-util.toml"
]
[pkg.dummy2]
enable = "on"
[pkg.dummy2.install_hint.brew]
packages = []
[[pkg.dummy2.configs]]
type = "home"
dir = ".dummy2"
source = "configs/dummy2"
symlinks = [
"dummy2.toml"
]
"#,
);
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![
env.git_repo_clean_entry(),
Entry::Status(Status::Symlink {
real: dummy_real.clone(),
symlink: dummy_symlink.clone(),
status: SymlinkStatus::DstDirIsMissing
}),
Entry::Status(Status::Symlink {
real: dummy2_real.clone(),
symlink: dummy2_symlink.clone(),
status: SymlinkStatus::DstDirIsMissing
})
]
})
);
env.create_symlink(
paths::ZENOPS_DIR.safe_join(srpath!("configs/dummy-util/dummy-util.toml")),
&dummy_config_symlink,
);
env.create_symlink(
paths::ZENOPS_DIR.safe_join(srpath!("configs/dummy2/dummy2.toml")),
&dummy2_config_symlink,
);
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![
env.git_repo_clean_entry(),
Entry::Status(Status::Symlink {
real: dummy_real.clone(),
symlink: dummy_symlink.clone(),
status: SymlinkStatus::RealPathIsMissing
}),
Entry::Status(Status::Symlink {
real: dummy2_real.clone(),
symlink: dummy2_symlink.clone(),
status: SymlinkStatus::RealPathIsMissing
})
]
})
);
env.write_zenops_file(
srpath!("configs/dummy-util/dummy-util.toml"),
"# hello",
Some("Added dummy-util.toml"),
);
env.write_zenops_file(
srpath!("configs/dummy2/dummy2.toml"),
"# hello2",
Some("Added dummy2.toml"),
);
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![
env.git_repo_clean_entry(),
Entry::Status(Status::Symlink {
real: dummy_real.clone(),
symlink: dummy_symlink.clone(),
status: SymlinkStatus::Ok
}),
Entry::Status(Status::Symlink {
real: dummy2_real.clone(),
symlink: dummy2_symlink.clone(),
status: SymlinkStatus::Ok
})
]
})
);
env.delete_file(&dummy_config_symlink);
env.delete_file(&dummy2_config_symlink);
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![
env.git_repo_clean_entry(),
Entry::Status(Status::Symlink {
real: dummy_real.clone(),
symlink: dummy_symlink.clone(),
status: SymlinkStatus::New
}),
Entry::Status(Status::Symlink {
real: dummy2_real.clone(),
symlink: dummy2_symlink.clone(),
status: SymlinkStatus::New
})
]
})
);
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
}),
Ok(Output {
entries: vec![
Entry::AppliedAction(AppliedAction::CreatedSymlink {
real: dummy_real.clone(),
symlink: dummy_symlink.clone(),
}),
Entry::AppliedAction(AppliedAction::CreatedSymlink {
real: dummy2_real.clone(),
symlink: dummy2_symlink.clone(),
})
]
})
);
env.delete_dir_all(dummy_config_symlink.safe_parent().unwrap());
env.delete_dir_all(dummy2_config_symlink.safe_parent().unwrap());
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
}),
Ok(Output {
entries: vec![
Entry::AppliedAction(AppliedAction::CreatedDir(dummy_symlink.parent().unwrap())),
Entry::AppliedAction(AppliedAction::CreatedSymlink {
real: dummy_real.clone(),
symlink: dummy_symlink.clone(),
}),
Entry::AppliedAction(AppliedAction::CreatedDir(dummy2_symlink.parent().unwrap())),
Entry::AppliedAction(AppliedAction::CreatedSymlink {
real: dummy2_real.clone(),
symlink: dummy2_symlink.clone(),
})
]
})
);
}
#[test]
fn pkg_configs_default_dir_name_to_pkg_key() {
let env = test_env::TestEnv::load();
let real = env.cfpath("configs/helix/config.toml", ConfigFilePath::Zenops);
let symlink = env.cfpath("helix/config.toml", ConfigFilePath::DotConfig);
env.init_config(
r#"
[pkg.helix]
enable = "on"
[pkg.helix.install_hint.brew]
packages = ["helix"]
[[pkg.helix.configs]]
type = ".config"
source = "configs/helix"
symlinks = ["config.toml"]
"#,
);
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![
env.git_repo_clean_entry(),
Entry::Status(Status::Symlink {
real,
symlink,
status: SymlinkStatus::DstDirIsMissing,
}),
],
})
);
}
#[test]
fn pkg_configs_explicit_name_overrides_pkg_key() {
let env = test_env::TestEnv::load();
let real = env.cfpath("configs/nvim/init.lua", ConfigFilePath::Zenops);
let symlink = env.cfpath("nvim/init.lua", ConfigFilePath::DotConfig);
env.init_config(
r#"
[pkg.neovim]
enable = "on"
[pkg.neovim.install_hint.brew]
packages = ["neovim"]
[[pkg.neovim.configs]]
type = ".config"
name = "nvim"
source = "configs/nvim"
symlinks = ["init.lua"]
"#,
);
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![
env.git_repo_clean_entry(),
Entry::Status(Status::Symlink {
real,
symlink,
status: SymlinkStatus::DstDirIsMissing,
}),
],
})
);
}
#[test]
fn disabled_pkg_skips_its_configs() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[pkg.ghost]
enable = "disabled"
[pkg.ghost.install_hint.brew]
packages = []
[[pkg.ghost.configs]]
type = ".config"
source = "configs/ghost"
symlinks = ["ghost.toml"]
"#,
);
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![env.git_repo_clean_entry()],
})
);
}
#[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]
type = "file"
path = "/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]
type = "file"
path = "/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]
type = "file"
path = "/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 completions_subcommand_generates_bash_script() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_zenops"))
.args(["completions", "bash"])
.output()
.expect("running zenops completions bash should succeed");
assert!(
output.status.success(),
"zenops completions bash exited {}; stderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
let script = String::from_utf8(output.stdout).unwrap();
assert!(
script.contains("_zenops"),
"expected _zenops function in bash completions, got:\n{script}"
);
assert!(
script.contains("complete "),
"expected `complete` directive in bash completions, got:\n{script}"
);
}
#[test]
fn completions_subcommand_generates_zsh_script() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_zenops"))
.args(["completions", "zsh"])
.output()
.expect("running zenops completions zsh should succeed");
assert!(output.status.success());
let script = String::from_utf8(output.stdout).unwrap();
assert!(
script.contains("#compdef zenops"),
"expected `#compdef zenops` directive in zsh completions, got:\n{script}"
);
}
#[test]
fn apply_emits_zsh_compinit_via_line_action() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "zsh"
[shell.environment]
[shell.alias]
"#,
);
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
let zshrc_path = env.resolve_path(srpath!("home/bob/.zshrc"));
let zshrc = std::fs::read_to_string(&zshrc_path)
.unwrap_or_else(|e| panic!("failed to read {zshrc_path:?}: {e}"));
assert!(
zshrc.contains("# Initialize Zsh completions"),
"expected compinit comment in generated zshrc, got:\n{zshrc}"
);
assert!(
zshrc.contains("autoload -Uz compinit && compinit"),
"expected verbatim compinit line in generated zshrc, got:\n{zshrc}"
);
}
#[test]
fn apply_emits_path_actions_inline_grouped_with_comments() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "bash"
[shell.environment]
[shell.alias]
[pkg.front]
enable = "on"
[pkg.front.install_hint.brew]
packages = []
[[pkg.front.shell.env_init.bash]]
type = "comment"
text = "Front setup"
[[pkg.front.shell.env_init.bash]]
type = "path_prepend"
value = "/opt/front/bin"
[pkg.back]
enable = "on"
[pkg.back.install_hint.brew]
packages = []
[[pkg.back.shell.env_init.bash]]
type = "comment"
text = "Back setup"
[[pkg.back.shell.env_init.bash]]
type = "path_append"
value = "/opt/back/bin"
"#,
);
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
let rc_path = env.resolve_path(srpath!("home/bob/.zenops_bash_profile"));
let rc = std::fs::read_to_string(&rc_path)
.unwrap_or_else(|e| panic!("failed to read {rc_path:?}: {e}"));
assert!(
rc.contains(r#"export PATH="/opt/front/bin:$PATH""#),
"expected inline prepend export line, got:\n{rc}"
);
assert!(
rc.contains(r#"export PATH="$PATH:/opt/back/bin""#),
"expected inline append export line, got:\n{rc}"
);
assert!(
rc.contains(r#"export PATH="$PATH:$HOME/.local/bin""#),
"expected local-bin append with $HOME translation, got:\n{rc}"
);
let front_comment_idx = rc.find("# Front setup").expect("front comment");
let front_export_idx = rc
.find(r#"export PATH="/opt/front/bin:$PATH""#)
.expect("front export");
let back_comment_idx = rc.find("# Back setup").expect("back comment");
let back_export_idx = rc
.find(r#"export PATH="$PATH:/opt/back/bin""#)
.expect("back export");
assert!(front_comment_idx < front_export_idx);
assert!(front_export_idx < back_comment_idx);
assert!(back_comment_idx < back_export_idx);
}
#[test]
fn apply_emits_login_init_actions_for_bash() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "bash"
[shell.environment]
[shell.alias]
"#,
);
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
let rc_path = env.resolve_path(srpath!("home/bob/.zenops_bash_profile"));
let rc = std::fs::read_to_string(&rc_path)
.unwrap_or_else(|e| panic!("failed to read {rc_path:?}: {e}"));
assert!(
rc.contains("[ -f ~/.bashrc ] && source ~/.bashrc"),
"expected bashrc-chain line in bash profile, got:\n{rc}"
);
}
#[test]
fn apply_skips_zprofile_when_no_login_init_zsh_actions() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "zsh"
[shell.environment]
[shell.alias]
"#,
);
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
let zprofile_path = env.resolve_path(srpath!("home/bob/.zprofile"));
let brew_present = std::path::Path::new("/opt/homebrew/bin/brew").exists()
|| std::path::Path::new("/usr/local/bin/brew").exists()
|| std::path::Path::new("/home/linuxbrew/.linuxbrew/bin/brew").exists();
if brew_present && cfg!(target_os = "macos") {
assert!(
zprofile_path.exists(),
"on macOS with brew, .zprofile should be generated"
);
} else {
assert!(
!zprofile_path.exists(),
"no login_init.zsh actions → .zprofile must not be written"
);
}
}
#[test]
fn apply_routes_login_init_zsh_action_to_zprofile() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "zsh"
[shell.environment]
[shell.alias]
[pkg.greeter]
enable = "on"
[pkg.greeter.install_hint.brew]
packages = []
[[pkg.greeter.shell.login_init.zsh]]
type = "line"
line = "echo hello-from-login"
"#,
);
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
let zprofile = std::fs::read_to_string(env.resolve_path(srpath!("home/bob/.zprofile")))
.expect("zprofile should be generated when a pkg contributes login_init.zsh");
let zshenv = std::fs::read_to_string(env.resolve_path(srpath!("home/bob/.zshenv")))
.expect("zshenv should exist");
let zshrc = std::fs::read_to_string(env.resolve_path(srpath!("home/bob/.zshrc")))
.expect("zshrc should exist");
assert!(
zprofile.contains("echo hello-from-login"),
"login_init.zsh action must appear in .zprofile, got:\n{zprofile}"
);
assert!(
!zshenv.contains("echo hello-from-login"),
"login_init.zsh action must NOT appear in .zshenv, got:\n{zshenv}"
);
assert!(
!zshrc.contains("echo hello-from-login"),
"login_init.zsh action must NOT appear in .zshrc, got:\n{zshrc}"
);
}
#[test]
fn apply_filters_pkg_by_supported_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"
supported_os = ["{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 supported_os must not contribute on the wrong OS, got:\n{zshenv}"
);
}
#[test]
fn apply_injects_zenops_completions_into_generated_bash_profile() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "bash"
[shell.environment]
[shell.alias]
"#,
);
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
})
.expect("apply should succeed");
let rc_path = env.resolve_path(srpath!("home/bob/.zenops_bash_profile"));
let rc = std::fs::read_to_string(&rc_path)
.unwrap_or_else(|e| panic!("failed to read {rc_path:?}: {e}"));
assert!(
rc.contains("# zenops shell completions"),
"expected zenops completions comment in generated bash profile, got:\n{rc}"
);
assert!(
rc.contains("source <(zenops completions bash)"),
"expected source line for zenops completions in generated bash profile, got:\n{rc}"
);
}
fn init_single_symlink_env() -> (
test_env::TestEnv,
zenops_safe_relative_path::SafeRelativePathBuf,
zenops::output::ResolvedConfigFilePath,
zenops::output::ResolvedConfigFilePath,
) {
let env = test_env::TestEnv::load();
let symlink_full = paths::CONFIG_DIR.safe_join(srpath!("dummy-util/dummy-util.toml"));
let real = env.cfpath("configs/dummy-util/dummy-util.toml", ConfigFilePath::Zenops);
let symlink = env.cfpath("dummy-util/dummy-util.toml", ConfigFilePath::DotConfig);
env.init_config(
r#"
[pkg.dummy-util]
enable = "on"
[pkg.dummy-util.install_hint.brew]
packages = []
[[pkg.dummy-util.configs]]
type = ".config"
source = "configs/dummy-util"
symlinks = [
"dummy-util.toml"
]
"#,
);
env.write_zenops_file(
srpath!("configs/dummy-util/dummy-util.toml"),
"# hello",
Some("Added dummy-util.toml"),
);
(env, symlink_full, real, symlink)
}
#[test]
fn symlink_dst_is_regular_file() {
let (env, symlink_full, real, symlink) = init_single_symlink_env();
env.write_file(&symlink_full, "# pre-existing user file\n");
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![
env.git_repo_clean_entry(),
Entry::Status(Status::Symlink {
real: real.clone(),
symlink: symlink.clone(),
status: SymlinkStatus::IsFile,
}),
]
})
);
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
}),
Err(Error::RefusingToOverwriteFileWithSymlink {
real: real.clone(),
symlink: symlink.clone(),
})
);
}
#[test]
fn symlink_dst_is_directory() {
let (env, symlink_full, real, symlink) = init_single_symlink_env();
env.create_dir(&symlink_full);
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false
}),
Ok(Output {
entries: vec![
env.git_repo_clean_entry(),
Entry::Status(Status::Symlink {
real: real.clone(),
symlink: symlink.clone(),
status: SymlinkStatus::IsDir,
}),
]
})
);
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
}),
Err(Error::RefusingToOverwriteDirectoryWithSymlink {
real: real.clone(),
symlink: symlink.clone(),
})
);
}
#[test]
fn apply_dry_run_skips_all_changes() {
let (env, symlink_full, _real, _symlink) = init_single_symlink_env();
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: false,
dry_run: true,
allow_dirty: true,
}),
Ok(Output { entries: vec![] }),
);
let symlink_disk = env.resolve_path(&symlink_full);
assert!(
symlink_disk.symlink_metadata().is_err(),
"dry-run should not have created {symlink_disk:?}"
);
}
#[test]
fn ssh_allowed_signers_manual_entry_roundtrips_through_apply_and_status() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[[ssh.allowed_signers]]
type = "manual"
principal = "bob@example.com"
key_type = "ssh-ed25519"
key = "AAAAKEY"
"#,
);
let file_path = env.cfpath(".ssh/allowed_signers", ConfigFilePath::Home);
let dir_path = env.cfpath(".ssh", ConfigFilePath::Home);
let want_body = Arc::<str>::from(
"# Generated by zenops — do not edit.\nbob@example.com ssh-ed25519 AAAAKEY\n",
);
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false,
}),
Ok(Output {
entries: vec![
env.git_repo_clean_entry(),
Entry::Status(Status::Generated {
want_content: want_body.clone(),
cur_content: None,
path: file_path.clone(),
status: FileStatus::New,
}),
],
}),
);
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
}),
Ok(Output {
entries: vec![
Entry::AppliedAction(AppliedAction::CreatedDir(dir_path.clone())),
Entry::AppliedAction(AppliedAction::CreatedFile(file_path.clone())),
],
}),
);
let disk = std::fs::read_to_string(
env.resolve_path(paths::HOME_DIR.safe_join(srpath!(".ssh/allowed_signers"))),
)
.unwrap();
assert_eq!(disk, want_body.as_ref());
assert_eq!(
env.run(&Cmd::Status {
diff: false,
all: false,
}),
Ok(Output {
entries: vec![
env.git_repo_clean_entry(),
Entry::Status(Status::Generated {
want_content: want_body,
cur_content: Some(
"# Generated by zenops — do not edit.\nbob@example.com ssh-ed25519 AAAAKEY\n"
.to_string(),
),
path: file_path,
status: FileStatus::Ok,
}),
],
}),
);
}
#[test]
fn git_config_ssh_signing_with_allowed_signers_writes_full_gitconfig() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[user]
name = "Ada Lovelace"
email = "ada@example.com"
[git.signing]
type = "ssh"
key = "~/.ssh/id_ed25519-github.pub"
[[ssh.allowed_signers]]
type = "manual"
principal = "ada@example.com"
key_type = "ssh-ed25519"
key = "AAAAKEY"
"#,
);
let allowed_signers_path = env.cfpath(".ssh/allowed_signers", ConfigFilePath::Home);
let allowed_signers_dir = env.cfpath(".ssh", ConfigFilePath::Home);
let gitconfig_path = env.cfpath(".gitconfig", ConfigFilePath::Home);
let want_gitconfig = Arc::<str>::from(
"# Generated by zenops — do not edit.\n\
[user]\n\
\tname = Ada Lovelace\n\
\temail = ada@example.com\n\
\tsigningkey = ~/.ssh/id_ed25519-github.pub\n\
[gpg]\n\
\tformat = ssh\n\
[gpg \"ssh\"]\n\
\tallowedSignersFile = ~/.ssh/allowed_signers\n\
[commit]\n\
\tgpgsign = true\n",
);
let want_allowed_signers = Arc::<str>::from(
"# Generated by zenops — do not edit.\nada@example.com ssh-ed25519 AAAAKEY\n",
);
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
}),
Ok(Output {
entries: vec![
Entry::AppliedAction(AppliedAction::CreatedDir(allowed_signers_dir)),
Entry::AppliedAction(AppliedAction::CreatedFile(allowed_signers_path)),
Entry::AppliedAction(AppliedAction::CreatedFile(gitconfig_path.clone())),
],
}),
);
let disk =
std::fs::read_to_string(env.resolve_path(paths::HOME_DIR.safe_join(srpath!(".gitconfig"))))
.unwrap();
assert_eq!(disk, want_gitconfig.as_ref());
let disk = std::fs::read_to_string(
env.resolve_path(paths::HOME_DIR.safe_join(srpath!(".ssh/allowed_signers"))),
)
.unwrap();
assert_eq!(disk, want_allowed_signers.as_ref());
}
#[test]
fn git_config_ssh_signing_without_allowed_signers_omits_gpg_ssh_block() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[user]
name = "Ada Lovelace"
email = "ada@example.com"
[git.signing]
type = "ssh"
key = "~/.ssh/id_ed25519.pub"
"#,
);
let gitconfig_path = env.cfpath(".gitconfig", ConfigFilePath::Home);
assert_eq!(
env.run(&Cmd::Apply {
pull_config: false,
yes: true,
dry_run: false,
allow_dirty: true,
}),
Ok(Output {
entries: vec![Entry::AppliedAction(AppliedAction::CreatedFile(
gitconfig_path
))],
}),
);
let disk =
std::fs::read_to_string(env.resolve_path(paths::HOME_DIR.safe_join(srpath!(".gitconfig"))))
.unwrap();
assert!(
!disk.contains("allowedSignersFile"),
"gitconfig should not reference allowed_signers when none configured:\n{disk}"
);
assert!(disk.contains("\tname = Ada Lovelace\n"));
assert!(disk.contains("\tformat = ssh\n"));
assert!(disk.contains("\tgpgsign = true\n"));
}
fn status_entries_only(out: &Output) -> Vec<&Entry> {
out.entries
.iter()
.filter(|e| matches!(e, Entry::Status(_)))
.collect()
}
#[test]
fn doctor_runs_without_config() {
let env = test_env::TestEnv::load();
let out = env
.run(&Cmd::Doctor)
.expect("doctor must not fail when config.toml is missing");
assert!(
status_entries_only(&out).is_empty(),
"expected no status events without a config, got: {:?}",
status_entries_only(&out),
);
}
#[test]
fn doctor_runs_with_broken_config() {
let env = test_env::TestEnv::load();
env.write_zenops_file(srpath!("config.toml"), "[[[ not toml", None);
let out = env
.run(&Cmd::Doctor)
.expect("doctor must not fail on a malformed config.toml");
assert!(
status_entries_only(&out).is_empty(),
"expected no status events on a malformed config, got: {:?}",
status_entries_only(&out),
);
}
#[test]
fn doctor_runs_with_unknown_field_in_config() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[shell]
type = "bash"
totally_not_a_real_field = 1
"#,
);
let out = env
.run(&Cmd::Doctor)
.expect("doctor must not fail on an unknown-field ParseDb error");
assert!(
status_entries_only(&out).is_empty(),
"expected no status events when config fails to parse, got: {:?}",
status_entries_only(&out),
);
}
#[test]
fn doctor_emits_pkg_missing_for_enable_on_with_missing_detect() {
let env = test_env::TestEnv::load();
env.init_config(
r#"
[pkg.zenops-doctor-test]
enable = "on"
[pkg.zenops-doctor-test.install_hint.brew]
packages = ["zenops-doctor-fake"]
[pkg.zenops-doctor-test.detect]
type = "file"
path = "/definitely/does/not/exist/zenops-doctor-test"
"#,
);
let out = env
.run(&Cmd::Doctor)
.expect("doctor must succeed when config loads");
let status_only = status_entries_only(&out);
let has_expected = status_only.iter().any(|e| {
matches!(
e,
Entry::Status(Status::Pkg {
pkg,
status: PkgStatus::Missing { .. },
}) if pkg == "zenops-doctor-test"
)
});
assert!(
has_expected,
"expected a Status::Pkg::Missing for zenops-doctor-test, got: {status_only:?}",
);
for e in &status_only {
assert!(
matches!(e, Entry::Status(Status::Pkg { .. })),
"doctor emitted unexpected status event: {e:?}",
);
}
}
#[test]
fn doctor_emits_doctor_check_events_for_system_section() {
use zenops::output::{DoctorCheck, DoctorSection, DoctorSeverity};
let env = test_env::TestEnv::load();
env.init_config("");
let out = env.run(&Cmd::Doctor).expect("doctor must succeed");
let has_os_info = out.entries.iter().any(|e| {
matches!(
e,
Entry::Doctor(DoctorCheck::Check {
section: DoctorSection::System,
label,
severity: DoctorSeverity::Info,
..
}) if label == "os:"
)
});
assert!(
has_os_info,
"expected a system/os: info DoctorCheck, got: {:?}",
out.entries,
);
let has_system_header = out.entries.iter().any(|e| {
matches!(
e,
Entry::Doctor(DoctorCheck::SectionHeader {
section: DoctorSection::System
})
)
});
assert!(
has_system_header,
"expected a System SectionHeader event, got: {:?}",
out.entries,
);
}
#[test]
fn doctor_emits_bad_check_with_detail_for_parse_error() {
use zenops::output::{DoctorCheck, DoctorSection, DoctorSeverity};
let env = test_env::TestEnv::load();
env.write_zenops_file(srpath!("config.toml"), "[[[ not toml", None);
let out = env
.run(&Cmd::Doctor)
.expect("doctor must not fail on a malformed config.toml");
let parse_error = out
.entries
.iter()
.find_map(|e| match e {
Entry::Doctor(DoctorCheck::Check {
section: DoctorSection::Config,
label,
severity: DoctorSeverity::Bad,
value,
detail,
..
}) if label == "status:" && value == "parse error" => Some(detail),
_ => None,
})
.unwrap_or_else(|| {
panic!(
"expected a parse-error doctor check, got: {:?}",
out.entries
)
});
assert!(
!parse_error.is_empty(),
"parse-error check should carry multi-line detail body, got: {parse_error:?}",
);
}
#[test]
fn init_apply_false_emits_init_summary_event() {
use zenops::output::InitSummary;
let env = test_env::TestEnv::load();
let bare = env.seed_bare_repo(&[(
"config.toml",
"[shell]\ntype = \"bash\"\n[shell.environment]\n[shell.alias]\n",
)]);
let out = env
.run(&Cmd::Init {
url: Some(bare.to_str().unwrap().to_string()),
branch: None,
apply: false,
yes: false,
})
.expect("init should succeed");
let summaries: Vec<&InitSummary> = out
.entries
.iter()
.filter_map(|e| match e {
Entry::Init(s) => Some(s),
_ => None,
})
.collect();
assert_eq!(
summaries.len(),
1,
"expected exactly one init_summary, got: {:?}",
out.entries,
);
assert_eq!(summaries[0].shell.as_deref(), Some("bash"));
}
#[test]
fn init_apply_true_does_not_emit_init_summary() {
let env = test_env::TestEnv::load();
let bare = env.seed_bare_repo(&[(
"config.toml",
"[shell]\ntype = \"bash\"\n[shell.environment]\n[shell.alias]\n",
)]);
let out = env
.run(&Cmd::Init {
url: Some(bare.to_str().unwrap().to_string()),
branch: None,
apply: true,
yes: true,
})
.expect("init --apply --yes should succeed");
let has_summary = out.entries.iter().any(|e| matches!(e, Entry::Init(_)));
assert!(
!has_summary,
"init --apply should defer to apply's event stream and skip init_summary, got: {:?}",
out.entries,
);
}