use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use klasp_core::GATE_SCHEMA_VERSION;
use tempfile::TempDir;
const FIXTURE_CODEX_SESSION: &str = include_str!("fixtures/codex/failing-commit-session.jsonl");
fn codex_commit_payload() -> String {
let cmd = extract_commit_command_from_session(FIXTURE_CODEX_SESSION);
serde_json::json!({
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": cmd,
"description": "Commit the staged changes."
},
"session_id": "codex-fixture-session",
"transcript_path": "/tmp/klasp-codex-fixture/transcript.jsonl"
})
.to_string()
}
const FAILING_KLASP_TOML: &str = r#"
version = 1
[gate]
agents = ["codex"]
policy = "any_fail"
[[checks]]
name = "always-fail"
triggers = [{ on = ["commit"] }]
timeout_secs = 5
[checks.source]
type = "shell"
command = "exit 7"
"#;
fn klasp_bin() -> &'static str {
env!("CARGO_BIN_EXE_klasp")
}
fn extract_commit_command_from_session(jsonl: &str) -> String {
for line in jsonl.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if v.get("type").and_then(|t| t.as_str()) != Some("tool_call") {
continue;
}
if let Some(cmd) = v
.get("input")
.and_then(|i| i.get("command"))
.and_then(|c| c.as_str())
{
if cmd.contains("git commit") {
return cmd.to_owned();
}
}
}
panic!(
"captured session fixture must contain a 'tool_call' line with a 'git commit' command; \
check tests/fixtures/codex/failing-commit-session.jsonl"
)
}
fn fresh_codex_repo_with_klasp() -> TempDir {
let dir = TempDir::new().expect("create tempdir");
std::fs::create_dir(dir.path().join(".git")).expect("create .git");
std::fs::create_dir(dir.path().join(".git").join("hooks")).expect("create .git/hooks");
std::fs::write(dir.path().join("AGENTS.md"), "# Project\n").expect("write AGENTS.md");
std::fs::write(dir.path().join("klasp.toml"), FAILING_KLASP_TOML).expect("write klasp.toml");
let out = Command::new(klasp_bin())
.current_dir(dir.path())
.args(["install", "--agent", "codex"])
.env_remove("CLAUDE_PROJECT_DIR")
.output()
.expect("spawn klasp install");
assert!(
out.status.success(),
"klasp install --agent codex must succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
dir
}
fn invoke_gate(payload: &str, repo: &Path) -> (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", repo)
.current_dir(repo)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn klasp gate");
child
.stdin
.as_mut()
.expect("piped stdin")
.write_all(payload.as_bytes())
.expect("write stdin payload");
let output = child.wait_with_output().expect("wait for klasp gate");
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if !stderr.is_empty() {
eprintln!("klasp gate stderr:\n{stderr}");
}
(output.status.code(), stderr)
}
#[test]
fn fixture_contains_git_commit_tool_call() {
let cmd = extract_commit_command_from_session(FIXTURE_CODEX_SESSION);
assert!(
cmd.contains("git commit"),
"expected a git commit command in the fixture, got: {cmd:?}",
);
}
#[test]
fn codex_failing_commit_is_blocked_by_gate() {
let repo = fresh_codex_repo_with_klasp();
let hook_body = std::fs::read_to_string(repo.path().join(".git/hooks/pre-commit")).unwrap();
assert!(
hook_body.contains("--agent codex"),
"pre-commit hook must dispatch to codex agent:\n{hook_body}",
);
let payload = codex_commit_payload();
let (code, _stderr) = invoke_gate(&payload, repo.path());
assert_eq!(
code,
Some(2),
"failing shell check on a commit payload must exit 2 (blocked)",
);
}
#[test]
fn codex_failing_commit_emits_structured_verdict() {
let repo = fresh_codex_repo_with_klasp();
let payload = codex_commit_payload();
let (code, stderr) = invoke_gate(&payload, repo.path());
assert_eq!(
code,
Some(2),
"gate must exit 2 for a failing commit; got {code:?}",
);
assert!(
stderr.contains("klasp-gate: blocked"),
"stderr must contain 'klasp-gate: blocked'; got:\n{stderr}",
);
assert!(
stderr.contains("errors"),
"stderr must report an error count; got:\n{stderr}",
);
assert!(
stderr.contains("policy="),
"stderr must carry the policy tag; got:\n{stderr}",
);
assert!(
stderr.contains("always-fail"),
"stderr must name the failing check 'always-fail'; got:\n{stderr}",
);
}
#[test]
fn codex_passing_check_allows_commit() {
let dir = TempDir::new().expect("create tempdir");
std::fs::create_dir(dir.path().join(".git")).expect("create .git");
std::fs::create_dir(dir.path().join(".git").join("hooks")).expect("create .git/hooks");
std::fs::write(dir.path().join("AGENTS.md"), "# Project\n").expect("write AGENTS.md");
std::fs::write(
dir.path().join("klasp.toml"),
r#"
version = 1
[gate]
agents = ["codex"]
policy = "any_fail"
[[checks]]
name = "always-pass"
triggers = [{ on = ["commit"] }]
timeout_secs = 5
[checks.source]
type = "shell"
command = "true"
"#,
)
.expect("write klasp.toml");
let payload = codex_commit_payload();
let (code, _stderr) = invoke_gate(&payload, dir.path());
assert_eq!(
code,
Some(0),
"passing check must allow the commit (exit 0)",
);
}
#[test]
fn codex_session_fixture_is_valid_jsonl() {
for (i, line) in FIXTURE_CODEX_SESSION.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let result = serde_json::from_str::<serde_json::Value>(line);
assert!(
result.is_ok(),
"fixture line {i} is not valid JSON: {line:?}",
);
}
}