use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
fn scratch(tag: &str) -> PathBuf {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target/test-tmp/steer")
.join(tag);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn steer() -> Command {
Command::new(env!("CARGO_BIN_EXE_ct-steer"))
}
fn temp_scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join("ct-steer-tests").join(tag);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn run_hook_in(dir: &Path, envelope: &str, extra: &[&str]) -> Output {
let mut child = steer()
.arg("hook")
.args(extra)
.current_dir(dir)
.env_remove("CT_STEER_LOG")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(envelope.as_bytes())
.unwrap();
child.wait_with_output().unwrap()
}
fn only_daily_log(dir: &Path) -> PathBuf {
let files: Vec<PathBuf> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
assert_eq!(files.len(), 1, "exactly one daily log file in {dir:?}");
let name = files[0].file_name().unwrap().to_string_lossy().into_owned();
assert_eq!(name.len(), "yyyy-mm-dd.jsonl".len(), "name was {name}");
assert!(
name.ends_with(".jsonl") && name.as_bytes()[4] == b'-',
"name was {name}"
);
files[0].clone()
}
fn code(out: &Output) -> i32 {
out.status.code().expect("child exited via a signal")
}
fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn run_hook(envelope: &str, extra: &[&str]) -> Output {
let mut child = steer()
.arg("hook")
.arg("--no-log")
.args(extra)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(envelope.as_bytes())
.unwrap();
child.wait_with_output().unwrap()
}
fn bash_envelope(command: &str) -> String {
serde_json::json!({
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": command },
})
.to_string()
}
#[test]
fn hook_denies_a_find_grep_pipeline() {
let out = run_hook(&bash_envelope("find . -name '*.rs' | xargs grep TODO"), &[]);
assert_eq!(code(&out), 0, "the hook always exits 0");
let v: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("decision JSON");
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(
v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap()
.contains("ct search"),
"reason names the ct tool: {}",
stdout(&out)
);
}
#[test]
fn hook_denies_a_harness_grep_call() {
let envelope = serde_json::json!({
"hook_event_name": "PreToolUse",
"tool_name": "Grep",
"tool_input": { "pattern": "TODO", "path": "src", "glob": "*.rs" },
})
.to_string();
let out = run_hook(&envelope, &[]);
assert_eq!(code(&out), 0);
let v: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("decision JSON");
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(
v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap()
.contains("ct search"),
"reason names ct search: {}",
stdout(&out)
);
}
#[test]
fn hook_allows_a_read_of_an_image() {
let envelope = serde_json::json!({
"tool_name": "Read",
"tool_input": { "file_path": "docs/diagram.png" },
})
.to_string();
let out = run_hook(&envelope, &[]);
assert_eq!(code(&out), 0);
assert!(
stdout(&out).trim().is_empty(),
"image Read is allowed silently"
);
}
#[test]
fn hook_modes_change_the_decision() {
let env = bash_envelope("grep -r TODO src");
let ask: serde_json::Value =
serde_json::from_str(&stdout(&run_hook(&env, &["--mode", "ask"]))).unwrap();
assert_eq!(ask["hookSpecificOutput"]["permissionDecision"], "ask");
let warn: serde_json::Value =
serde_json::from_str(&stdout(&run_hook(&env, &["--mode", "warn"]))).unwrap();
assert!(warn["hookSpecificOutput"]["additionalContext"].is_string());
assert!(
warn["hookSpecificOutput"]
.get("permissionDecision")
.is_none()
);
}
#[test]
fn hook_is_silent_and_fails_open_on_misses() {
let allow = run_hook(&bash_envelope("git status"), &[]);
assert_eq!(code(&allow), 0);
assert!(stdout(&allow).trim().is_empty(), "allow is silent");
let other = run_hook(r#"{"tool_name":"Read","tool_input":{}}"#, &[]);
assert_eq!(code(&other), 0);
assert!(stdout(&other).trim().is_empty());
let bad = run_hook("not json at all", &[]);
assert_eq!(code(&bad), 0);
assert!(stdout(&bad).trim().is_empty());
}
#[test]
fn check_exit_codes_mirror_the_decision() {
let steered = steer()
.args(["check", "grep -r TODO src"])
.output()
.unwrap();
assert_eq!(code(&steered), 1, "a steered command exits 1");
assert!(stdout(&steered).contains("ct search"));
let allowed = steer().args(["check", "git status"]).output().unwrap();
assert_eq!(code(&allowed), 0, "an allowed command exits 0");
}
#[test]
fn install_creates_idempotent_settings_then_uninstalls() {
let dir = scratch("install");
let settings = dir.join(".claude").join("settings.json");
let first = steer()
.args(["install", "--scope", "project"])
.current_dir(&dir)
.output()
.unwrap();
assert_eq!(code(&first), 0);
let written = std::fs::read_to_string(&settings).expect("settings.json created");
assert!(written.contains("PreToolUse"));
assert!(written.contains("ct steer hook"));
assert!(written.contains("\"matcher\": \"Bash\""));
let again = steer()
.args(["install", "--scope", "project"])
.current_dir(&dir)
.output()
.unwrap();
assert_eq!(code(&again), 0);
assert_eq!(std::fs::read_to_string(&settings).unwrap(), written);
let removed = steer()
.args(["uninstall", "--scope", "project"])
.current_dir(&dir)
.output()
.unwrap();
assert_eq!(code(&removed), 0);
assert!(
!std::fs::read_to_string(&settings)
.unwrap()
.contains("steer hook")
);
}
#[test]
fn install_with_multiple_tools_writes_a_matcher_each() {
let dir = scratch("multitool");
let settings = dir.join(".claude").join("settings.json");
let out = steer()
.args(["install", "--tools", "Bash,Grep,Glob,Read"])
.current_dir(&dir)
.output()
.unwrap();
assert_eq!(code(&out), 0);
let written = std::fs::read_to_string(&settings).expect("settings.json created");
for matcher in ["\"Bash\"", "\"Grep\"", "\"Glob\"", "\"Read\""] {
assert!(written.contains(matcher), "missing {matcher} in {written}");
}
let removed = steer()
.args(["uninstall", "--scope", "project"])
.current_dir(&dir)
.output()
.unwrap();
assert_eq!(code(&removed), 0);
assert!(
!std::fs::read_to_string(&settings)
.unwrap()
.contains("steer hook")
);
}
#[test]
fn install_print_writes_nothing() {
let dir = scratch("print");
let out = steer()
.args(["install", "--print"])
.current_dir(&dir)
.output()
.unwrap();
assert_eq!(code(&out), 0);
assert!(stdout(&out).contains("ct steer hook"));
assert!(
!dir.join(".claude").exists(),
"--print must not touch the filesystem"
);
}
#[test]
fn hook_logs_every_call_by_default_under_ct_tclog() {
let dir = temp_scratch("default-log");
std::fs::create_dir_all(dir.join(".ct")).unwrap();
let out = run_hook_in(&dir, &bash_envelope("git status"), &[]);
assert_eq!(code(&out), 0);
assert!(stdout(&out).trim().is_empty(), "an allow is still silent");
let file = only_daily_log(&dir.join(".ct").join("tclog"));
let content = std::fs::read_to_string(&file).unwrap();
assert!(
content.contains("git status"),
"logged the command: {content}"
);
assert!(content.contains("\"decision\":\"allow\""), "{content}");
assert!(content.contains("\"tool\":\"Bash\""), "{content}");
let gitignore = std::fs::read_to_string(dir.join(".ct").join(".gitignore")).unwrap();
assert!(gitignore.contains("*log"), "gitignore: {gitignore}");
run_hook_in(&dir, &bash_envelope("ls"), &[]);
let after = std::fs::read_to_string(&file).unwrap();
assert_eq!(after.lines().count(), 2, "second call appends: {after}");
}
#[test]
fn hook_log_dir_override_writes_there_and_leaves_ct_alone() {
let dir = temp_scratch("override-log");
let logs = dir.join("mylogs");
let out = run_hook_in(
&dir,
&bash_envelope("git status"),
&["--log-dir", logs.to_str().unwrap()],
);
assert_eq!(code(&out), 0);
only_daily_log(&logs); assert!(
!dir.join(".ct").exists(),
"an explicit --log-dir must not create or manage .ct"
);
}
#[test]
fn hook_no_log_writes_nothing() {
let dir = temp_scratch("no-log");
std::fs::create_dir_all(dir.join(".ct")).unwrap();
let out = run_hook_in(&dir, &bash_envelope("git status"), &["--no-log"]);
assert_eq!(code(&out), 0);
assert!(
!dir.join(".ct").join("tclog").exists(),
"--no-log must write no log files"
);
}