use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use klasp_core::GATE_SCHEMA_VERSION;
use tempfile::TempDir;
const FIXTURE_GIT_COMMIT: &str = include_str!("fixtures/claude_commit_hook.json");
fn klasp_bin() -> &'static str {
env!("CARGO_BIN_EXE_klasp")
}
fn spawn_gate(
stdin_payload: &str,
project_dir: &Path,
extra_env: &[(&str, &str)],
) -> (Option<i32>, String) {
let mut cmd = Command::new(klasp_bin());
cmd.arg("gate")
.env("KLASP_GATE_SCHEMA", GATE_SCHEMA_VERSION.to_string())
.env("CLAUDE_PROJECT_DIR", project_dir)
.current_dir(project_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in extra_env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn klasp binary");
child
.stdin
.as_mut()
.expect("piped stdin")
.write_all(stdin_payload.as_bytes())
.expect("write stdin");
let output = child.wait_with_output().expect("wait for klasp");
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if !stderr.is_empty() {
eprintln!("klasp gate stderr:\n{stderr}");
}
(output.status.code(), stderr)
}
fn write_klasp_toml(project_dir: &Path, body: &str) {
std::fs::write(project_dir.join("klasp.toml"), body).expect("write klasp.toml");
}
fn init_repo_with_commits(dir: &Path, commits: usize) {
run_git(dir, &["init", "--initial-branch=main"]);
run_git(dir, &["config", "user.email", "klasp-test@example.com"]);
run_git(dir, &["config", "user.name", "klasp-test"]);
run_git(dir, &["config", "commit.gpgsign", "false"]);
for i in 0..commits {
std::fs::write(dir.join(format!("f{i}.txt")), format!("commit {i}"))
.expect("write fixture file");
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", &format!("c{i}")]);
}
}
fn run_git(cwd: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
#[test]
fn fail_check_blocks_with_exit_2() {
let project = TempDir::new().expect("tempdir");
write_klasp_toml(
project.path(),
r#"
version = 1
[gate]
agents = ["claude_code"]
policy = "any_fail"
[[checks]]
name = "always-fail"
triggers = [{ on = ["commit"] }]
timeout_secs = 5
[checks.source]
type = "shell"
command = "exit 7"
"#,
);
let (code, _stderr) = spawn_gate(FIXTURE_GIT_COMMIT, project.path(), &[]);
assert_eq!(
code,
Some(2),
"failing check on a `git commit` payload must exit 2",
);
}
#[test]
fn pass_check_returns_exit_0() {
let project = TempDir::new().expect("tempdir");
write_klasp_toml(
project.path(),
r#"
version = 1
[gate]
agents = ["claude_code"]
policy = "any_fail"
[[checks]]
name = "always-pass"
triggers = [{ on = ["commit"] }]
timeout_secs = 5
[checks.source]
type = "shell"
command = "true"
"#,
);
let (code, _stderr) = spawn_gate(FIXTURE_GIT_COMMIT, project.path(), &[]);
assert_eq!(code, Some(0), "passing check must exit 0");
}
#[test]
fn non_git_command_skips_checks_and_returns_0() {
let project = TempDir::new().expect("tempdir");
write_klasp_toml(
project.path(),
r#"
version = 1
[gate]
agents = ["claude_code"]
policy = "any_fail"
[[checks]]
name = "always-fail"
triggers = [{ on = ["commit"] }]
timeout_secs = 5
[checks.source]
type = "shell"
command = "exit 7"
"#,
);
let payload = r#"{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "ls -la" }
}"#;
let (code, _stderr) = spawn_gate(payload, project.path(), &[]);
assert_eq!(
code,
Some(0),
"a non-git command must short-circuit without running checks",
);
}
#[test]
fn missing_klasp_toml_fails_open() {
let project = TempDir::new().expect("tempdir");
let (code, stderr) = spawn_gate(FIXTURE_GIT_COMMIT, project.path(), &[]);
assert_eq!(
code,
Some(0),
"missing klasp.toml must fail open with exit 0, never block",
);
assert!(
stderr.contains("klasp-gate:"),
"expected fail-open notice on stderr, got: {stderr:?}",
);
}
#[test]
fn klasp_base_ref_is_exposed_to_shell_checks() {
let project = TempDir::new().expect("tempdir");
init_repo_with_commits(project.path(), 2);
let sentinel = project.path().join("base_ref.txt");
write_klasp_toml(
project.path(),
&format!(
r#"
version = 1
[gate]
agents = ["claude_code"]
policy = "any_fail"
[[checks]]
name = "echo-base-ref"
triggers = [{{ on = ["commit"] }}]
timeout_secs = 5
[checks.source]
type = "shell"
command = 'printf "$KLASP_BASE_REF" > {sentinel}; test -n "$KLASP_BASE_REF"'
"#,
sentinel = sentinel.display(),
),
);
let (code, stderr) = spawn_gate(FIXTURE_GIT_COMMIT, project.path(), &[]);
assert_eq!(
code,
Some(0),
"check passes only if KLASP_BASE_REF is non-empty in the child env\nstderr:\n{stderr}",
);
let captured = std::fs::read_to_string(&sentinel).expect("read sentinel file");
assert!(
!captured.is_empty(),
"KLASP_BASE_REF must be exported with a non-empty value, got: {captured:?}",
);
assert_eq!(
captured, "HEAD~1",
"no-remote fallback must be HEAD~1, got: {captured:?}",
);
}
#[test]
fn source_runtime_error_fails_open() {
let project = TempDir::new().expect("tempdir");
write_klasp_toml(
project.path(),
r#"
version = 1
[gate]
agents = ["claude_code"]
policy = "any_fail"
[[checks]]
name = "always-times-out"
triggers = [{ on = ["commit"] }]
timeout_secs = 0
[checks.source]
type = "shell"
command = "sleep 1"
"#,
);
let (code, stderr) = spawn_gate(FIXTURE_GIT_COMMIT, project.path(), &[]);
assert_eq!(
code,
Some(0),
"source runtime error must fail open (exit 0), got code = {code:?}\nstderr:\n{stderr}",
);
assert!(
stderr.contains("klasp-gate:"),
"expected fail-open notice on stderr, got: {stderr:?}",
);
assert!(
stderr.contains("always-times-out"),
"notice should mention the check name, got: {stderr:?}",
);
}