use similar_asserts::assert_eq;
use zenops::{
Cmd, ColorChoice,
config_files::ConfigFilePath,
error::Error,
git::GitFileStatus,
output::{AppliedAction, Status, SymlinkStatus},
};
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 }),
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 }),
Ok(Output { entries: vec![] }),
);
env.init_config("");
assert_eq!(
env.run(&Cmd::Status { diff: false }),
Ok(Output { entries: vec![] }),
);
}
#[test]
fn config_dir_git_status() {
let env = test_env::TestEnv::load();
env.init_config("");
assert_eq!(
env.run(&Cmd::Status { diff: false }),
Ok(Output { entries: vec![] })
);
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 }),
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 pkg_list_shows_defaults_as_missing() {
let env = test_env::TestEnv::load();
env.init_config("");
let out = env
.run_pkg_list(false, false, false, ColorChoice::Never)
.expect("pkg list should succeed");
assert!(
out.contains("cargo"),
"expected cargo in output, got: {out}"
);
assert!(out.contains("sk"), "expected sk in output, got: {out}");
assert!(
out.contains("starship"),
"expected starship in output, got: {out}"
);
assert!(
out.contains("cross-shell prompt"),
"expected starship description, got: {out}"
);
assert!(
!out.contains('\x1b'),
"ColorChoice::Never output should not contain ANSI escapes, got: {out:?}"
);
let colored = env
.run_pkg_list(false, false, false, ColorChoice::Always)
.expect("pkg list --color always should succeed");
assert!(
colored.contains('\x1b'),
"ColorChoice::Always output should contain ANSI escapes, got: {colored:?}"
);
}
#[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 out = env
.run_pkg_list(false, false, false, ColorChoice::Never)
.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());
if brew_available {
let footer_line = out
.lines()
.find(|l| l.contains("To install all missing via brew: brew install"))
.unwrap_or_else(|| panic!("expected aggregate footer, got: {out}"));
assert!(
footer_line.contains("alpha-formula") && footer_line.contains("bravo-formula"),
"footer should list both missing pkgs, got: {footer_line}"
);
assert_eq!(
footer_line.matches("alpha-formula").count(),
1,
"alpha-formula should appear once in footer, got: {footer_line}"
);
} else {
assert!(
!out.contains("via brew:"),
"expected no install guidance without brew on PATH, got: {out}"
);
assert!(
!out.contains("To install all missing"),
"expected no aggregate footer without brew on PATH, got: {out}"
);
}
}
#[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_out = env
.run_pkg_list(false, false, false, ColorChoice::Never)
.expect("pkg list should succeed");
assert!(
!default_out.contains("ghost"),
"disabled pkg should be hidden by default, got: {default_out}"
);
let all_out = env
.run_pkg_list(true, false, false, ColorChoice::Never)
.expect("pkg list --all should succeed");
let ghost_line = all_out
.lines()
.find(|l| l.contains("ghost"))
.unwrap_or_else(|| panic!("disabled pkg should appear with --all, got: {all_out}"));
assert!(
ghost_line.starts_with("- "),
"disabled pkg row should begin with the `-` marker, got: {ghost_line:?}"
);
}
#[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#"
[[configs]]
type = ".config"
name = "dummy-util"
source = "configs/dummy-util"
symlinks = [
"dummy-util.toml"
]
[[configs]]
type = "home"
dir = ".dummy2"
source = "configs/dummy2"
symlinks = [
"dummy2.toml"
]
"#,
);
assert_eq!(
env.run(&Cmd::Status { diff: false }),
Ok(Output {
entries: vec![
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 }),
Ok(Output {
entries: vec![
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 }),
Ok(Output {
entries: vec![
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 }),
Ok(Output {
entries: vec![
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
}),
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
}),
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 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_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,
})
.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#"
[[configs]]
type = ".config"
name = "dummy-util"
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 }),
Ok(Output {
entries: vec![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
}),
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 }),
Ok(Output {
entries: vec![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
}),
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,
}),
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:?}"
);
}