use std::fs;
use std::path::Path;
use std::process::{Command, Output};
const VALID_TOML_BOTH: &str = r#"version = 1
[gate]
agents = ["claude_code", "codex"]
policy = "any_fail"
"#;
const VALID_TOML_EMPTY_AGENTS: &str = r#"version = 1
[gate]
agents = []
policy = "any_fail"
"#;
fn run_install(dir: &Path, extra_args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_klasp"))
.current_dir(dir)
.arg("install")
.args(extra_args)
.env_remove("CLAUDE_PROJECT_DIR")
.output()
.expect("spawn klasp")
}
fn run_uninstall(dir: &Path, extra_args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_klasp"))
.current_dir(dir)
.arg("uninstall")
.args(extra_args)
.env_remove("CLAUDE_PROJECT_DIR")
.output()
.expect("spawn klasp")
}
fn fresh_codex_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
fs::create_dir(dir.path().join(".git")).unwrap();
fs::create_dir(dir.path().join(".git").join("hooks")).unwrap();
fs::write(dir.path().join("AGENTS.md"), "# Project\n").unwrap();
dir
}
fn fresh_dual_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
fs::create_dir(dir.path().join(".git")).unwrap();
fs::create_dir(dir.path().join(".git").join("hooks")).unwrap();
fs::create_dir(dir.path().join(".claude")).unwrap();
fs::write(dir.path().join("AGENTS.md"), "# Project\n").unwrap();
dir
}
fn write_toml(repo_root: &Path, body: &str) {
fs::write(repo_root.join("klasp.toml"), body).unwrap();
}
fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
#[test]
fn install_agent_codex_writes_agents_md_and_hooks_only() {
let repo = fresh_codex_repo();
let out = run_install(repo.path(), &["--agent", "codex"]);
assert!(
out.status.success(),
"expected exit 0\nstdout:\n{}\nstderr:\n{}",
stdout(&out),
stderr(&out)
);
let agents_md = fs::read_to_string(repo.path().join("AGENTS.md")).unwrap();
assert!(
agents_md.contains("klasp:managed:start"),
"AGENTS.md must contain managed block:\n{agents_md}"
);
let pre_commit = repo.path().join(".git/hooks/pre-commit");
assert!(
pre_commit.exists(),
"pre-commit hook must be written for codex"
);
let pre_commit_body = fs::read_to_string(&pre_commit).unwrap();
assert!(
pre_commit_body.contains("--agent codex"),
"pre-commit must dispatch to codex agent:\n{pre_commit_body}"
);
let pre_push = repo.path().join(".git/hooks/pre-push");
assert!(pre_push.exists(), "pre-push hook must be written for codex");
assert!(
!repo.path().join(".claude/hooks/klasp-gate.sh").exists(),
"claude hook must NOT be written when --agent codex"
);
assert!(
!repo.path().join(".claude/settings.json").exists(),
"claude settings.json must NOT be written when --agent codex"
);
}
#[test]
fn install_agent_all_writes_every_listed_surface() {
let repo = fresh_dual_repo();
write_toml(repo.path(), VALID_TOML_BOTH);
let out = run_install(repo.path(), &["--agent", "all"]);
assert!(
out.status.success(),
"expected exit 0\nstdout:\n{}\nstderr:\n{}",
stdout(&out),
stderr(&out)
);
assert!(
repo.path().join(".claude/hooks/klasp-gate.sh").exists(),
"claude hook must be written"
);
assert!(
repo.path().join(".claude/settings.json").exists(),
"claude settings.json must be written"
);
let agents_md = fs::read_to_string(repo.path().join("AGENTS.md")).unwrap();
assert!(agents_md.contains("klasp:managed:start"));
assert!(
repo.path().join(".git/hooks/pre-commit").exists(),
"pre-commit must be written"
);
assert!(
repo.path().join(".git/hooks/pre-push").exists(),
"pre-push must be written"
);
let so = stdout(&out);
assert!(so.contains("claude_code: installed"), "stdout:\n{so}");
assert!(so.contains("codex: installed"), "stdout:\n{so}");
}
#[test]
fn install_unknown_agent_exits_nonzero_with_supported_list() {
let repo = fresh_codex_repo();
let out = run_install(repo.path(), &["--agent", "foo"]);
assert!(!out.status.success(), "expected non-zero exit");
let se = stderr(&out);
assert!(
se.contains("unknown agent \"foo\""),
"stderr should name the unknown agent: {se}"
);
assert!(
se.contains("claude_code") && se.contains("codex"),
"stderr should list supported agents: {se}"
);
assert!(!repo.path().join(".git/hooks/pre-commit").exists());
}
#[test]
fn install_agent_all_with_empty_list_warns_and_exits_0() {
let repo = fresh_dual_repo();
write_toml(repo.path(), VALID_TOML_EMPTY_AGENTS);
let out = run_install(repo.path(), &["--agent", "all"]);
assert!(
out.status.success(),
"expected exit 0 on empty agents list, got {:?}\nstderr:\n{}",
out.status,
stderr(&out)
);
let se = stderr(&out);
assert!(
se.contains("warning:"),
"stderr should carry a `warning:` line: {se}"
);
assert!(
se.contains("[gate].agents = []") || se.contains("`[gate].agents = []`"),
"warning should explain the empty agents array: {se}"
);
assert!(!repo.path().join(".claude/hooks/klasp-gate.sh").exists());
assert!(!repo.path().join(".git/hooks/pre-commit").exists());
let agents_md = fs::read_to_string(repo.path().join("AGENTS.md")).unwrap();
assert!(
!agents_md.contains("klasp:managed:start"),
"AGENTS.md must remain untouched on no-op"
);
}
#[test]
fn install_agent_codex_skips_husky_pre_commit_with_warning() {
let repo = fresh_codex_repo();
let husky_hook = "#!/usr/bin/env sh\n\
. \"$(dirname -- \"$0\")/_/husky.sh\"\n\
npm test\n";
fs::write(repo.path().join(".git/hooks/pre-commit"), husky_hook).unwrap();
let out = run_install(repo.path(), &["--agent", "codex"]);
assert!(
out.status.success(),
"husky conflict must NOT fail install\nstdout:\n{}\nstderr:\n{}",
stdout(&out),
stderr(&out)
);
let se = stderr(&out);
assert!(
se.contains("warning:"),
"stderr must carry a `warning:` line: {se}"
);
assert!(
se.contains("pre-commit"),
"warning must name the pre-commit hook: {se}"
);
assert!(
se.contains("husky"),
"warning must name husky as the foreign tool: {se}"
);
assert!(
se.contains("klasp gate") && se.contains("--trigger commit"),
"warning must include the manual-install incantation: {se}"
);
let pre_commit_after = fs::read_to_string(repo.path().join(".git/hooks/pre-commit")).unwrap();
assert_eq!(
pre_commit_after, husky_hook,
"husky pre-commit must not be modified"
);
let pre_push = fs::read_to_string(repo.path().join(".git/hooks/pre-push")).unwrap();
assert!(pre_push.contains("# >>> klasp managed start <<<"));
let agents_md = fs::read_to_string(repo.path().join("AGENTS.md")).unwrap();
assert!(agents_md.contains("klasp:managed:start"));
}
#[test]
fn uninstall_agent_codex_removes_only_codex_artefacts() {
let repo = fresh_dual_repo();
write_toml(repo.path(), VALID_TOML_BOTH);
let out = run_install(repo.path(), &["--agent", "all"]);
assert!(out.status.success(), "{}", stderr(&out));
let out = run_uninstall(repo.path(), &["--agent", "codex"]);
assert!(
out.status.success(),
"uninstall failed:\nstderr:\n{}",
stderr(&out)
);
let agents_md = fs::read_to_string(repo.path().join("AGENTS.md")).unwrap();
assert!(
!agents_md.contains("klasp:managed:start"),
"AGENTS.md block must be stripped"
);
assert!(
!repo.path().join(".git/hooks/pre-commit").exists(),
"pre-commit deleted after uninstall"
);
assert!(
!repo.path().join(".git/hooks/pre-push").exists(),
"pre-push deleted after uninstall"
);
assert!(
repo.path().join(".claude/hooks/klasp-gate.sh").exists(),
"claude hook must survive `uninstall --agent codex`"
);
assert!(repo.path().join(".claude/settings.json").exists());
}
#[test]
fn uninstall_agent_all_removes_every_listed_surface() {
let repo = fresh_dual_repo();
write_toml(repo.path(), VALID_TOML_BOTH);
let out = run_install(repo.path(), &["--agent", "all"]);
assert!(out.status.success(), "{}", stderr(&out));
let out = run_uninstall(repo.path(), &["--agent", "all"]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(!repo.path().join(".claude/hooks/klasp-gate.sh").exists());
assert!(!repo.path().join(".git/hooks/pre-commit").exists());
assert!(!repo.path().join(".git/hooks/pre-push").exists());
let agents_md = fs::read_to_string(repo.path().join("AGENTS.md")).unwrap();
assert!(!agents_md.contains("klasp:managed:start"));
}
#[test]
fn uninstall_agent_all_walks_registry_regardless_of_config() {
let repo = fresh_dual_repo();
write_toml(repo.path(), VALID_TOML_EMPTY_AGENTS);
let out = run_uninstall(repo.path(), &["--agent", "all"]);
assert!(
out.status.success(),
"expected exit 0\nstderr:\n{}",
stderr(&out)
);
let so = stdout(&out);
assert!(
so.contains("claude_code: nothing to remove") && so.contains("codex: nothing to remove"),
"expected uninstall to walk every registered surface; got stdout:\n{so}"
);
}
#[test]
fn uninstall_unknown_agent_exits_nonzero() {
let repo = fresh_codex_repo();
let out = run_uninstall(repo.path(), &["--agent", "foo"]);
assert!(!out.status.success(), "expected non-zero exit");
assert!(stderr(&out).contains("unknown agent \"foo\""));
}
#[test]
fn install_agent_all_is_idempotent() {
let repo = fresh_dual_repo();
write_toml(repo.path(), VALID_TOML_BOTH);
let first = run_install(repo.path(), &["--agent", "all"]);
assert!(first.status.success());
let second = run_install(repo.path(), &["--agent", "all"]);
assert!(second.status.success());
let so = stdout(&second);
assert!(
so.contains("claude_code: already installed") && so.contains("codex: already installed"),
"second run must be a no-op:\n{so}"
);
}