use std::fs;
use std::path::Path;
use std::process::{Command, Output, Stdio};
fn run_setup(dir: &Path, home_dir: &Path, extra_args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_klasp"))
.current_dir(dir)
.arg("setup")
.args(extra_args)
.env("HOME", home_dir)
.env_remove("CLAUDE_PROJECT_DIR")
.output()
.expect("spawn klasp setup")
}
fn run_setup_interactive(
dir: &Path,
home_dir: &Path,
stdin_input: &str,
extra_args: &[&str],
) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_klasp"))
.current_dir(dir)
.arg("setup")
.arg("--interactive")
.args(extra_args)
.env("HOME", home_dir)
.env_remove("CLAUDE_PROJECT_DIR")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn klasp setup --interactive");
use std::io::Write;
if let Some(stdin) = child.stdin.take() {
let mut stdin = stdin;
stdin.write_all(stdin_input.as_bytes()).ok();
}
child
.wait_with_output()
.expect("wait klasp setup --interactive")
}
fn fixture_repo() -> Option<tempfile::TempDir> {
if which::which("git").is_err() {
eprintln!("git not on PATH — skipping test");
return None;
}
let dir = tempfile::tempdir().expect("tempdir");
let status = Command::new("git")
.args(["init", "-q"])
.current_dir(dir.path())
.status()
.expect("spawn git");
if !status.success() {
eprintln!("git init failed — skipping test");
return None;
}
Some(dir)
}
fn fake_home(agents: &[FakeAgent]) -> tempfile::TempDir {
let home = tempfile::tempdir().expect("tempdir for fake home");
for agent in agents {
match agent {
FakeAgent::Claude => {
fs::create_dir(home.path().join(".claude")).unwrap();
}
FakeAgent::Codex => {
fs::create_dir(home.path().join(".codex")).unwrap();
}
FakeAgent::Aider => {
fs::write(home.path().join(".aider.conf.yml"), "commit: true\n").unwrap();
}
}
}
home
}
enum FakeAgent {
Claude,
Codex,
Aider,
}
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 setup_help_exits_successfully() {
let out = Command::new(env!("CARGO_BIN_EXE_klasp"))
.args(["setup", "--help"])
.output()
.expect("spawn klasp");
assert!(
out.status.success(),
"`klasp setup --help` must exit 0\nstdout: {}\nstderr: {}",
stdout(&out),
stderr(&out)
);
let so = stdout(&out);
assert!(
so.contains("setup") || so.to_lowercase().contains("first-run"),
"help output should describe setup:\n{so}"
);
}
#[test]
fn dry_run_prints_plan_writes_nothing() {
let Some(repo) = fixture_repo() else { return };
let home = fake_home(&[FakeAgent::Claude]);
let out = run_setup(repo.path(), home.path(), &["--dry-run"]);
assert!(
out.status.success(),
"dry-run must exit 0\nstdout: {}\nstderr: {}",
stdout(&out),
stderr(&out)
);
assert!(
!repo.path().join("klasp.toml").exists(),
"--dry-run must NOT write klasp.toml"
);
let so = stdout(&out);
assert!(
so.to_lowercase().contains("dry-run") || so.contains("writing nothing"),
"stdout should indicate dry-run mode:\n{so}"
);
}
#[test]
fn claude_only_home_narrows_agents_to_claude_code() {
let Some(repo) = fixture_repo() else { return };
let home = fake_home(&[FakeAgent::Claude]);
fs::write(repo.path().join(".pre-commit-config.yaml"), "repos: []\n").unwrap();
let out = run_setup(repo.path(), home.path(), &[]);
let toml_path = repo.path().join("klasp.toml");
if !toml_path.exists() {
eprintln!("stdout: {}", stdout(&out));
eprintln!("stderr: {}", stderr(&out));
}
assert!(toml_path.exists(), "klasp.toml must be written by setup");
let config =
klasp_core::ConfigV1::from_file(&toml_path).expect("written klasp.toml must parse");
assert_eq!(
config.gate.agents,
vec!["claude_code"],
"with ~/.claude only, agents must be [\"claude_code\"], got: {:?}",
config.gate.agents
);
}
#[test]
fn all_three_home_dirs_produces_three_agent_list() {
let Some(repo) = fixture_repo() else { return };
let home = fake_home(&[FakeAgent::Claude, FakeAgent::Codex, FakeAgent::Aider]);
let out = run_setup(repo.path(), home.path(), &[]);
let toml_path = repo.path().join("klasp.toml");
if !toml_path.exists() {
eprintln!("stdout: {}", stdout(&out));
eprintln!("stderr: {}", stderr(&out));
}
assert!(toml_path.exists(), "klasp.toml must be written by setup");
let config =
klasp_core::ConfigV1::from_file(&toml_path).expect("written klasp.toml must parse");
assert_eq!(
config.gate.agents,
vec!["claude_code", "codex", "aider"],
"with all three agent dirs, agents must be all three, got: {:?}",
config.gate.agents
);
}
#[test]
fn setup_with_lefthook_gate_writes_valid_config() {
let Some(repo) = fixture_repo() else { return };
let home = fake_home(&[FakeAgent::Claude]);
fs::write(
repo.path().join("lefthook.yml"),
"pre-commit:\n commands:\n lint:\n run: pnpm lint\n",
)
.unwrap();
let out = run_setup(repo.path(), home.path(), &[]);
let _ = (stdout(&out), stderr(&out));
let toml_path = repo.path().join("klasp.toml");
assert!(toml_path.exists(), "klasp.toml must be written by setup");
let config =
klasp_core::ConfigV1::from_file(&toml_path).expect("written klasp.toml must parse");
assert_eq!(config.gate.agents, vec!["claude_code"]);
assert!(
!config.checks.is_empty(),
"expected at least one check from lefthook fixture"
);
}
#[test]
fn setup_with_husky_gate_writes_valid_config() {
let Some(repo) = fixture_repo() else { return };
let home = fake_home(&[FakeAgent::Claude]);
fs::create_dir_all(repo.path().join(".husky")).unwrap();
fs::write(
repo.path().join(".husky/pre-commit"),
"#!/bin/sh\npnpm lint\n",
)
.unwrap();
let out = run_setup(repo.path(), home.path(), &[]);
let _ = (stdout(&out), stderr(&out));
let toml_path = repo.path().join("klasp.toml");
assert!(toml_path.exists(), "klasp.toml must be written by setup");
let config =
klasp_core::ConfigV1::from_file(&toml_path).expect("written klasp.toml must parse");
assert_eq!(config.gate.agents, vec!["claude_code"]);
}
#[test]
fn interactive_n_to_mirror_skips_write() {
let Some(repo) = fixture_repo() else { return };
let home = fake_home(&[FakeAgent::Claude]);
fs::write(repo.path().join(".pre-commit-config.yaml"), "repos: []\n").unwrap();
let out = run_setup_interactive(repo.path(), home.path(), "n\n", &[]);
assert!(
out.status.success(),
"interactive n must exit 0\nstdout: {}\nstderr: {}",
stdout(&out),
stderr(&out)
);
let so = stdout(&out);
assert!(
so.contains("Skipping") || so.contains("Aborted") || so.contains("klasp setup"),
"interactive n must print a graceful message:\n{so}"
);
}
#[test]
fn interactive_y_y_writes_file() {
let Some(repo) = fixture_repo() else { return };
let home = fake_home(&[FakeAgent::Claude]);
let out = run_setup_interactive(repo.path(), home.path(), "y\ny\n", &[]);
let so = stdout(&out);
let se = stderr(&out);
let toml_path = repo.path().join("klasp.toml");
if out.status.success() || so.contains("wrote klasp.toml") || so.contains("wrote ") {
assert!(
toml_path.exists(),
"interactive y/y must write klasp.toml\nstdout: {so}\nstderr: {se}"
);
}
let _ = (so, se);
}
#[test]
fn duplicate_gate_check_names_get_suffix() {
let Some(repo) = fixture_repo() else { return };
let home = fake_home(&[FakeAgent::Claude]);
fs::create_dir_all(repo.path().join(".husky")).unwrap();
fs::write(
repo.path().join(".husky/pre-commit"),
"#!/bin/sh\npnpm lint\n",
)
.unwrap();
fs::write(
repo.path().join("lefthook.yml"),
"pre-commit:\n commands:\n lint:\n run: pnpm lint\n",
)
.unwrap();
let out = run_setup(repo.path(), home.path(), &[]);
let toml_path = repo.path().join("klasp.toml");
if !toml_path.exists() {
eprintln!("stdout: {}", stdout(&out));
eprintln!("stderr: {}", stderr(&out));
return;
}
let content = fs::read_to_string(&toml_path).unwrap();
let config =
klasp_core::ConfigV1::from_file(&toml_path).expect("written klasp.toml must parse");
if config.checks.len() > 1 {
let names: Vec<&str> = config.checks.iter().map(|c| c.name.as_str()).collect();
let lint_count = names.iter().filter(|&&n| n == "lint").count();
assert_eq!(
lint_count, 1,
"bare 'lint' should appear exactly once; got: {names:?}\n{content}"
);
let has_suffixed = names.iter().any(|n| n.starts_with("lint-"));
assert!(
has_suffixed,
"second 'lint' should be suffixed; got: {names:?}\n{content}"
);
}
}
#[test]
fn install_single_agent_warns_about_uncovered() {
let Some(repo) = fixture_repo() else { return };
let toml = r#"version = 1
[gate]
agents = ["claude_code", "codex", "aider"]
policy = "any_fail"
"#;
fs::write(repo.path().join("klasp.toml"), toml).unwrap();
fs::create_dir(repo.path().join(".claude")).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_klasp"))
.current_dir(repo.path())
.args(["install", "--agent", "claude_code"])
.env_remove("CLAUDE_PROJECT_DIR")
.output()
.expect("spawn klasp install");
assert!(
out.status.success(),
"install must exit 0 even with uncovered agents\nstdout: {}\nstderr: {}",
stdout(&out),
stderr(&out)
);
let se = stderr(&out);
assert!(
se.to_lowercase().contains("warn") || se.contains("NOT cover"),
"stderr must warn about uncovered agents:\n{se}"
);
assert!(
se.contains("codex") || se.contains("aider"),
"warning must mention uncovered agent names:\n{se}"
);
}
#[test]
fn install_all_does_not_warn_narrower() {
let Some(repo) = fixture_repo() else { return };
let toml = r#"version = 1
[gate]
agents = ["claude_code"]
policy = "any_fail"
"#;
fs::write(repo.path().join("klasp.toml"), toml).unwrap();
fs::create_dir(repo.path().join(".claude")).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_klasp"))
.current_dir(repo.path())
.args(["install", "--agent", "all"])
.env_remove("CLAUDE_PROJECT_DIR")
.output()
.expect("spawn klasp install");
let se = stderr(&out);
assert!(
!se.contains("NOT cover"),
"`klasp install --agent all` should not warn about uncovered agents:\n{se}"
);
}