use std::io::{Read, Write};
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const PATIENCE: Duration = Duration::from_secs(5);
const NO_OP: &str = r#"{"continue": true}"#;
struct Fired {
code: Option<i32>,
stdout: String,
took: Duration,
}
fn sandbox(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("cctop-hook-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for sub in ["run", "home", "cache", "config", "data"] {
std::fs::create_dir_all(dir.join(sub)).unwrap();
}
dir
}
fn command(dir: &Path) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_cctop"));
c.env("XDG_RUNTIME_DIR", dir.join("run"))
.env("HOME", dir.join("home"))
.env("XDG_CACHE_HOME", dir.join("cache"))
.env("XDG_CONFIG_HOME", dir.join("config"))
.env("XDG_DATA_HOME", dir.join("data"))
.env_remove("CLAUDE_CONFIG_DIR")
.env_remove("CCTOP_LOG");
c
}
fn hooks(dir: &Path) -> PathBuf {
dir.join("run").join("cctop").join("hooks.d")
}
fn fire(dir: &Path, event: &str, stdin: Option<&str>) -> Fired {
let started = Instant::now();
let mut child = command(dir)
.arg("hook")
.arg(event)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("the hook binary runs");
let mut pipe = child.stdin.take().expect("stdin was piped");
if let Some(text) = stdin {
let _ = pipe.write_all(text.as_bytes());
}
drop(pipe);
let mut out = child.stdout.take().expect("stdout was piped");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut text = String::new();
let _ = out.read_to_string(&mut text);
let _ = tx.send(text);
});
let stdout = rx.recv_timeout(PATIENCE).unwrap_or_else(|_| {
let _ = child.kill();
panic!("`cctop hook {event}` had not returned after {PATIENCE:?}");
});
let status = child.wait().expect("the hook exits");
Fired {
code: status.code(),
stdout,
took: started.elapsed(),
}
}
#[test]
fn a_hook_with_nothing_listening_exits_zero_and_says_nothing() {
let dir = sandbox("quiet");
let fired = fire(
&dir,
"PreToolUse",
Some(r#"{"session_id":"abc","cwd":"/tmp","hook_event_name":"PreToolUse"}"#),
);
assert_eq!(fired.code, Some(0), "the hook failed");
assert_eq!(fired.stdout, "", "stdout carried content for the model");
assert!(fired.took < PATIENCE, "took {:?}", fired.took);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_hook_whose_event_will_not_parse_exits_zero_and_says_nothing() {
let dir = sandbox("garbage");
let fired = fire(&dir, "PostToolUse", Some("{ this is not json at all"));
assert_eq!(fired.code, Some(0), "the hook failed on a payload");
assert_eq!(fired.stdout, "", "stdout carried content for the model");
assert!(fired.took < PATIENCE, "took {:?}", fired.took);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_hook_whose_stdin_is_closed_exits_zero() {
let dir = sandbox("closed");
let fired = fire(&dir, "SessionStart", None);
assert_eq!(fired.code, Some(0), "the hook failed on an empty stdin");
assert!(fired.took < PATIENCE, "took {:?}", fired.took);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_events_that_demand_an_answer_get_the_no_op_and_nothing_else() {
let dir = sandbox("answer");
for event in ["Stop", "SubagentStop"] {
let fired = fire(
&dir,
event,
Some(r#"{"session_id":"abc","hook_event_name":"Stop"}"#),
);
assert_eq!(fired.code, Some(0), "{event} failed");
assert_eq!(
fired.stdout.trim(),
NO_OP,
"{event} answered something else"
);
assert!(fired.took < PATIENCE, "{event} took {:?}", fired.took);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_hook_whose_cctop_has_stopped_accepting_still_returns() {
let dir = sandbox("wedged");
let hooks = hooks(&dir);
std::fs::create_dir_all(&hooks).unwrap();
let path = hooks.join("1-0000000000000000001.sock");
let listener = UnixListener::bind(&path).expect("bind");
assert_eq!(
unsafe { libc::listen(listener.as_raw_fd(), 1) },
0,
"listen"
);
let held = cctop_core::test_wait::fill_queue(&path);
assert!(!held.is_empty(), "the fixture never reached the listener");
let fired = fire(
&dir,
"Stop",
Some(r#"{"session_id":"abc","hook_event_name":"Stop"}"#),
);
assert_eq!(
fired.code,
Some(0),
"the hook failed against a wedged cctop"
);
assert!(fired.took < PATIENCE, "took {:?}", fired.took);
assert!(
path.exists(),
"the hook deleted a live cctop's socket because it would not answer"
);
drop(held);
drop(listener);
let _ = std::fs::remove_dir_all(&dir);
}