use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use super::config::matching_hooks;
use super::{
HookConfig, HookContext, HookEvent, PreHookResult, UserPromptHookResult,
UserPromptSubmitOutput, UserPromptSubmitPayload,
};
fn push_context(acc: &mut String, extra: &str) {
if extra.is_empty() {
return;
}
if !acc.is_empty() {
acc.push_str("\n\n");
}
acc.push_str(extra);
}
pub struct HookExecutor {
hooks: Vec<HookConfig>,
}
impl HookExecutor {
pub fn new(hooks: Vec<HookConfig>) -> Self {
Self { hooks }
}
pub fn empty() -> Self {
Self { hooks: vec![] }
}
pub fn has_hooks(&self) -> bool {
!self.hooks.is_empty()
}
pub async fn run_pre_tool_use(
&self,
tool_name: &str,
ctx: &HookContext,
) -> PreHookResult {
let matched = matching_hooks(&self.hooks, HookEvent::PreToolUse, Some(tool_name));
if matched.is_empty() {
return PreHookResult::Allow;
}
let mut result = PreHookResult::Allow;
for hook in matched {
match self.execute_hook(hook, ctx).await {
Ok(stdout) => {
match serde_json::from_str::<PreHookResult>(&stdout) {
Ok(parsed) => match &parsed {
PreHookResult::Block { .. } => return parsed,
PreHookResult::Modify { .. } => result = parsed,
PreHookResult::Allow => {}
},
Err(_) => {}
}
}
Err(_) => {}
}
}
result
}
pub async fn run_post_tool_use(&self, tool_name: &str, ctx: &HookContext) {
let matched = matching_hooks(&self.hooks, HookEvent::PostToolUse, Some(tool_name));
for hook in matched {
let _ = self.execute_hook(hook, ctx).await;
}
}
pub async fn run_user_prompt_submit(
&self,
prompt: &str,
session_id: &str,
cwd: &str,
) -> UserPromptHookResult {
let matched = matching_hooks(&self.hooks, HookEvent::UserPromptSubmit, None);
if matched.is_empty() {
return UserPromptHookResult::Continue;
}
let payload = UserPromptSubmitPayload {
session_id: session_id.to_string(),
hook_event_name: "UserPromptSubmit".to_string(),
prompt: prompt.to_string(),
cwd: cwd.to_string(),
};
let payload_json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into());
let mut injected = String::new();
let mut warnings = Vec::new();
for hook in matched {
match self.execute_hook_with_stdin(hook, &payload_json).await {
Ok((exit_ok, stdout, stderr)) => {
if !exit_ok {
let last_line = stdout.lines().rev().find(|l| !l.trim().is_empty());
let json_action = last_line.and_then(|l| {
serde_json::from_str::<UserPromptSubmitOutput>(l.trim()).ok()
});
if let Some(parsed) = json_action {
if matches!(parsed.decision.as_deref(), Some("block")) {
let reason = parsed
.reason
.unwrap_or_else(|| "user prompt blocked by hook".into());
return UserPromptHookResult::Block(reason);
}
}
let reason = if !stderr.trim().is_empty() {
stderr.trim().to_string()
} else if !stdout.trim().is_empty() {
stdout.trim().to_string()
} else {
"hook exited with error".into()
};
warnings.push(reason);
continue;
}
let last_line = stdout.lines().rev().find(|l| !l.trim().is_empty());
let json_action = last_line.and_then(|l| {
serde_json::from_str::<UserPromptSubmitOutput>(l.trim()).ok()
});
if let Some(parsed) = json_action {
if matches!(parsed.decision.as_deref(), Some("block")) {
let reason = parsed
.reason
.unwrap_or_else(|| "user prompt blocked by hook".into());
return UserPromptHookResult::Block(reason);
}
if let Some(ctx) = parsed
.hook_specific_output
.and_then(|o| o.additional_context)
{
push_context(&mut injected, &ctx);
continue;
}
continue;
}
let trimmed = stdout.trim();
if !trimmed.is_empty() {
push_context(&mut injected, trimmed);
}
}
Err(_) => {
}
}
}
if !warnings.is_empty() {
return UserPromptHookResult::Warning(warnings.join("; "));
}
if injected.is_empty() {
UserPromptHookResult::Continue
} else {
UserPromptHookResult::Inject(injected)
}
}
async fn execute_hook_with_stdin(
&self,
hook: &HookConfig,
payload_json: &str,
) -> anyhow::Result<(bool, String, String)> {
use std::process::Stdio;
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(&hook.command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(ref root) = hook.plugin_root {
let s = root.as_os_str();
cmd.env("CLAUDE_PLUGIN_ROOT", s);
cmd.env("ATOMCODE_PLUGIN_ROOT", s);
}
crate::process_utils::suppress_console_window(&mut cmd);
let timeout = Duration::from_millis(hook.timeout_ms);
let fut = async {
let mut child = cmd.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(payload_json.as_bytes()).await?;
stdin.shutdown().await.ok();
drop(stdin);
}
let output = child.wait_with_output().await?;
anyhow::Ok((
output.status.success(),
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
))
};
Ok(tokio::time::timeout(timeout, fut).await??)
}
pub async fn run_session_event(&self, event: HookEvent, ctx: &HookContext) {
let matched = matching_hooks(&self.hooks, event, None);
for hook in matched {
let _ = self.execute_hook(hook, ctx).await;
}
}
pub async fn execute_hook(
&self,
hook: &HookConfig,
ctx: &HookContext,
) -> anyhow::Result<String> {
let ctx_json =
serde_json::to_string(ctx).unwrap_or_else(|_| "{}".to_string());
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(&hook.command)
.env("ATOMCODE_HOOK_EVENT", &ctx.event)
.env("ATOMCODE_HOOK_CONTEXT", &ctx_json)
.kill_on_drop(true);
if let Some(ref name) = ctx.tool_name {
cmd.env("ATOMCODE_TOOL_NAME", name);
}
if let Some(ref root) = hook.plugin_root {
let s = root.as_os_str();
cmd.env("CLAUDE_PLUGIN_ROOT", s);
cmd.env("ATOMCODE_PLUGIN_ROOT", s);
}
crate::process_utils::suppress_console_window(&mut cmd);
let timeout = Duration::from_millis(hook.timeout_ms);
let output = tokio::time::timeout(timeout, cmd.output()).await??;
if !output.status.success() {
anyhow::bail!(
"hook command exited with status {}",
output.status.code().unwrap_or(-1)
);
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn test_ctx() -> HookContext {
HookContext {
event: "pre_tool_use".into(),
tool_name: Some("bash".into()),
tool_args: Some(json!({"command": "ls"})),
tool_result: None,
tool_success: None,
session_id: "test-session".into(),
working_dir: "/tmp".into(),
}
}
fn make_hook(event: HookEvent, matcher: Option<&str>, cmd: &str) -> HookConfig {
HookConfig {
event,
matcher: matcher.map(String::from),
command: cmd.to_string(),
timeout_ms: 10_000,
plugin_root: None,
}
}
#[tokio::test]
async fn empty_executor_allows() {
let exec = HookExecutor::empty();
assert!(!exec.has_hooks());
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(result, PreHookResult::Allow);
}
#[tokio::test]
async fn hook_returning_allow_json() {
let hook = make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"echo '{"action":"allow"}'"#,
);
let exec = HookExecutor::new(vec![hook]);
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(result, PreHookResult::Allow);
}
#[tokio::test]
async fn hook_returning_block_json() {
let hook = make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"echo '{"action":"block","reason":"dangerous"}'"#,
);
let exec = HookExecutor::new(vec![hook]);
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(
result,
PreHookResult::Block {
reason: "dangerous".into()
}
);
}
#[tokio::test]
async fn hook_returning_modify_json() {
let hook = make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"echo '{"action":"modify","args":{"command":"ls -la"}}'"#,
);
let exec = HookExecutor::new(vec![hook]);
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(
result,
PreHookResult::Modify {
args: json!({"command": "ls -la"})
}
);
}
#[tokio::test]
async fn pre_tool_multiple_modify_last_wins() {
let hooks = vec![
make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"echo '{"action":"modify","args":{"command":"first"}}'"#,
),
make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"echo '{"action":"allow"}'"#,
),
];
let exec = HookExecutor::new(hooks);
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(
result,
PreHookResult::Modify {
args: json!({"command": "first"})
}
);
}
#[tokio::test]
async fn pre_tool_multiple_block_short_circuits() {
let hooks = vec![
make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"echo '{"action":"allow"}'"#,
),
make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"echo '{"action":"block","reason":"second blocks"}'"#,
),
make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"echo 'should-not-be-called'"#,
),
];
let exec = HookExecutor::new(hooks);
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(
result,
PreHookResult::Block {
reason: "second blocks".into()
}
);
}
#[tokio::test]
async fn hook_returning_non_json_allows() {
let hook = make_hook(
HookEvent::PreToolUse,
Some("bash"),
"echo 'not json at all'",
);
let exec = HookExecutor::new(vec![hook]);
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(result, PreHookResult::Allow);
}
#[tokio::test]
async fn hook_timeout_degrades_to_allow() {
let mut hook = make_hook(
HookEvent::PreToolUse,
Some("bash"),
"sleep 10",
);
hook.timeout_ms = 100;
let exec = HookExecutor::new(vec![hook]);
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(result, PreHookResult::Allow);
}
#[tokio::test]
async fn hook_returns_empty_stdout_allows() {
let hook = make_hook(HookEvent::PreToolUse, Some("bash"), "true");
let exec = HookExecutor::new(vec![hook]);
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(result, PreHookResult::Allow);
}
#[tokio::test]
async fn pre_tool_two_modifys_last_wins() {
let hooks = vec![
make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"echo '{"action":"modify","args":{"command":"first"}}'"#,
),
make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"echo '{"action":"modify","args":{"command":"second"}}'"#,
),
];
let exec = HookExecutor::new(hooks);
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(
result,
PreHookResult::Modify {
args: json!({"command": "second"})
}
);
}
#[tokio::test]
async fn user_prompt_no_hooks_returns_continue() {
let exec = HookExecutor::empty();
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Continue);
}
#[tokio::test]
async fn user_prompt_plain_stdout_injects_context() {
let hook = make_hook(
HookEvent::UserPromptSubmit,
None,
"echo extra-info",
);
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Inject("extra-info".into()));
}
#[tokio::test]
async fn user_prompt_decision_block_blocks() {
let hook = make_hook(
HookEvent::UserPromptSubmit,
None,
r#"echo '{"decision":"block","reason":"nope"}'"#,
);
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Block("nope".into()));
}
#[tokio::test]
async fn user_prompt_hook_specific_output_injects() {
let hook = make_hook(
HookEvent::UserPromptSubmit,
None,
r#"echo '{"hookSpecificOutput":{"additionalContext":"ctx-bag"}}'"#,
);
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Inject("ctx-bag".into()));
}
#[tokio::test]
async fn user_prompt_nonzero_exit_blocks_with_stderr() {
let hook = make_hook(
HookEvent::UserPromptSubmit,
None,
"echo bad >&2; exit 1",
);
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Warning("bad".into()));
}
#[tokio::test]
async fn user_prompt_block_after_debug_noise_still_blocks() {
let hook = make_hook(
HookEvent::UserPromptSubmit,
None,
r#"echo 'debug line 1'; echo 'debug line 2'; echo '{"decision":"block","reason":"final"}'"#,
);
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Block("final".into()));
}
#[tokio::test]
async fn user_prompt_plugin_root_with_spaces_via_env() {
let mut hook = make_hook(
HookEvent::UserPromptSubmit,
None,
r#"printf '%s' "$CLAUDE_PLUGIN_ROOT""#,
);
hook.plugin_root = Some(std::path::PathBuf::from("/opt/has space/x"));
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Inject("/opt/has space/x".into()));
}
#[tokio::test]
async fn user_prompt_payload_reaches_stdin() {
let hook = make_hook(
HookEvent::UserPromptSubmit,
None,
r#"python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["prompt"])'"#,
);
let exec = HookExecutor::new(vec![hook]);
let r = exec
.run_user_prompt_submit("ping-payload", "sess", "/tmp")
.await;
assert_eq!(r, UserPromptHookResult::Inject("ping-payload".into()));
}
#[tokio::test]
async fn hook_crash_degrades_to_allow() {
let hook = make_hook(
HookEvent::PreToolUse,
Some("bash"),
"exit 1",
);
let exec = HookExecutor::new(vec![hook]);
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(result, PreHookResult::Allow);
}
#[tokio::test]
async fn post_tool_use_fire_and_forget() {
let hook = make_hook(
HookEvent::PostToolUse,
Some("bash"),
"echo done",
);
let exec = HookExecutor::new(vec![hook]);
exec.run_post_tool_use("bash", &test_ctx()).await;
}
#[tokio::test]
async fn post_tool_multiple_all_fire() {
let dir = tempfile::tempdir().unwrap();
let m1 = dir.path().join("h1");
let m2 = dir.path().join("h2");
let (p1, p2) = (m1.to_string_lossy().to_string(), m2.to_string_lossy().to_string());
let hooks = vec![
make_hook(HookEvent::PostToolUse, Some("bash"), &format!("touch {}", p1)),
make_hook(HookEvent::PostToolUse, Some("bash"), &format!("touch {}", p2)),
];
let exec = HookExecutor::new(hooks);
exec.run_post_tool_use("bash", &test_ctx()).await;
assert!(m1.exists(), "first post-tool hook should have run");
assert!(m2.exists(), "second post-tool hook should have run");
}
#[tokio::test]
async fn post_tool_crash_does_not_affect_others() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("survivor");
let p = marker.to_string_lossy().to_string();
let hooks = vec![
make_hook(HookEvent::PostToolUse, Some("bash"), "exit 1"),
make_hook(HookEvent::PostToolUse, Some("bash"), &format!("touch {}", p)),
];
let exec = HookExecutor::new(hooks);
exec.run_post_tool_use("bash", &test_ctx()).await;
assert!(marker.exists(), "subsequent hook should fire after crash");
}
#[tokio::test]
async fn post_tool_matcher_filters() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("post_matched");
let p = marker.to_string_lossy().to_string();
let hooks = vec![
make_hook(HookEvent::PostToolUse, Some("grep"), &format!("touch {}", p)),
];
let exec = HookExecutor::new(hooks);
exec.run_post_tool_use("bash", &test_ctx()).await;
assert!(!marker.exists(), "non-matching hook should not fire");
exec.run_post_tool_use("grep", &test_ctx()).await;
assert!(marker.exists(), "matching hook should fire");
}
#[tokio::test]
async fn post_tool_no_matching_noop() {
let hook = make_hook(HookEvent::PostToolUse, Some("grep"), "echo should-not-run");
let exec = HookExecutor::new(vec![hook]);
exec.run_post_tool_use("bash", &test_ctx()).await;
}
#[tokio::test]
async fn matcher_filters_correctly() {
let hook = make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"echo '{"action":"block","reason":"bash only"}'"#,
);
let exec = HookExecutor::new(vec![hook]);
let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
assert_eq!(
result,
PreHookResult::Block {
reason: "bash only".into()
}
);
let result = exec.run_pre_tool_use("grep", &test_ctx()).await;
assert_eq!(result, PreHookResult::Allow);
}
#[tokio::test]
async fn hook_receives_env_vars() {
let hook = make_hook(
HookEvent::PreToolUse,
Some("bash"),
r#"printf '{"event":"%s","tool":"%s","has_ctx":"%s"}' "$ATOMCODE_HOOK_EVENT" "$ATOMCODE_TOOL_NAME" "$(test -n "$ATOMCODE_HOOK_CONTEXT" && echo yes || echo no)""#,
);
let exec = HookExecutor::new(vec![hook]);
let ctx = test_ctx();
let stdout = exec.execute_hook(&exec.hooks[0], &ctx).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(parsed["event"], "pre_tool_use");
assert_eq!(parsed["tool"], "bash");
assert_eq!(parsed["has_ctx"], "yes");
}
#[tokio::test]
async fn user_prompt_multiple_hooks_first_blocks() {
let hooks = vec![
make_hook(HookEvent::UserPromptSubmit, None,
r#"echo '{"decision":"block","reason":"blocked by first"}'"#),
make_hook(HookEvent::UserPromptSubmit, None,
r#"echo 'plain inject from second'"#),
];
let exec = HookExecutor::new(hooks);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Block("blocked by first".into()));
}
#[tokio::test]
async fn user_prompt_all_inject_combined() {
let hooks = vec![
make_hook(HookEvent::UserPromptSubmit, None, r#"echo 'context from first'"#),
make_hook(HookEvent::UserPromptSubmit, None, r#"echo 'context from second'"#),
];
let exec = HookExecutor::new(hooks);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Inject("context from first
context from second".into()));
}
#[tokio::test]
async fn user_prompt_nonzero_exit_no_stderr() {
let hook = make_hook(HookEvent::UserPromptSubmit, None, "exit 1");
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Warning("hook exited with error".into()));
}
#[tokio::test]
async fn user_prompt_json_empty_object_continues() {
let hook = make_hook(HookEvent::UserPromptSubmit, None, r#"echo '{}'"#);
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Continue);
}
#[tokio::test]
async fn user_prompt_decision_allow_continues() {
let hook = make_hook(HookEvent::UserPromptSubmit, None,
r#"echo '{"decision":"allow"}'"#);
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Continue);
}
#[tokio::test]
async fn user_prompt_json_block_with_trailing_debug_falls_through() {
let hook = make_hook(HookEvent::UserPromptSubmit, None,
r#"echo '{"decision":"block","reason":"missed"}'; echo 'some trailing log'"#);
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert!(matches!(r, UserPromptHookResult::Inject(_)),
"expected Inject, got {:?}", r);
if let UserPromptHookResult::Inject(ctx) = r {
assert!(ctx.contains("some trailing log"), "ctx: {ctx}");
assert!(ctx.contains("missed"), "ctx: {ctx}");
}
}
#[tokio::test]
async fn user_prompt_json_decision_allow_with_context_injects() {
let hook = make_hook(HookEvent::UserPromptSubmit, None,
r#"echo '{"decision":"allow","hookSpecificOutput":{"additionalContext":"extra"}}'"#);
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Inject("extra".into()));
}
#[tokio::test]
async fn user_prompt_whitespace_only_continues() {
let hook = make_hook(HookEvent::UserPromptSubmit, None, "echo ' '");
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Continue);
}
#[tokio::test]
async fn user_prompt_timeout_degrades_to_continue() {
let mut hook = make_hook(HookEvent::UserPromptSubmit, None, "sleep 3600");
hook.timeout_ms = 50;
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Continue);
}
#[tokio::test]
async fn user_prompt_block_after_inject_still_blocks() {
let hooks = vec![
make_hook(HookEvent::UserPromptSubmit, None, r#"echo 'injected'"#),
make_hook(HookEvent::UserPromptSubmit, None,
r#"echo '{"decision":"block","reason":"second blocks"}'"#),
];
let exec = HookExecutor::new(hooks);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Block("second blocks".into()));
}
#[tokio::test]
async fn user_prompt_stderr_preferred_over_stdout_for_block() {
let hook = make_hook(HookEvent::UserPromptSubmit, None,
r#"echo 'stdout msg' && echo 'stderr reason' >&2 && exit 1"#);
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Warning("stderr reason".into()));
}
#[tokio::test]
async fn user_prompt_payload_fields_echoed_by_python() {
let hook = make_hook(HookEvent::UserPromptSubmit, None,
r#"python3 -c 'import json,sys;d=json.load(sys.stdin);print("sid="+d["session_id"]+" prompt_len="+str(len(d["prompt"])))'"#);
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hello-world", "s-42", "/tmp").await;
match r {
UserPromptHookResult::Inject(ctx) => {
assert!(ctx.contains("s-42"), "ctx: {ctx}");
assert!(ctx.contains("prompt_len=11"), "ctx: {ctx}");
}
_ => panic!("expected Inject, got {:?}", r),
}
}
#[tokio::test]
async fn user_prompt_spawn_failure_continues() {
let mut hook = make_hook(HookEvent::UserPromptSubmit, None, "sleep 10");
hook.timeout_ms = 100;
let exec = HookExecutor::new(vec![hook]);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Continue);
}
#[tokio::test]
async fn user_prompt_three_injects_combined() {
let hooks = vec![
make_hook(HookEvent::UserPromptSubmit, None, "echo first"),
make_hook(HookEvent::UserPromptSubmit, None, "echo second"),
make_hook(HookEvent::UserPromptSubmit, None, "echo third"),
];
let exec = HookExecutor::new(hooks);
let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
assert_eq!(r, UserPromptHookResult::Inject("first\n\nsecond\n\nthird".into()));
}
fn session_ctx(event: &str) -> HookContext {
HookContext {
event: event.into(),
tool_name: None,
tool_args: None,
tool_result: None,
tool_success: None,
session_id: "sess-1".into(),
working_dir: "/tmp".into(),
}
}
#[tokio::test]
async fn session_start_runs_matching_hooks() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("session_started");
let p = marker.to_string_lossy().to_string();
let hook = make_hook(HookEvent::SessionStart, None, &format!("touch {}", p));
let exec = HookExecutor::new(vec![hook]);
exec.run_session_event(HookEvent::SessionStart, &session_ctx("session_start")).await;
assert!(marker.exists(), "SessionStart hook should have run");
}
#[tokio::test]
async fn session_end_runs_matching_hooks() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("session_ended");
let p = marker.to_string_lossy().to_string();
let hook = make_hook(HookEvent::SessionEnd, None, &format!("touch {}", p));
let exec = HookExecutor::new(vec![hook]);
exec.run_session_event(HookEvent::SessionEnd, &session_ctx("session_end")).await;
assert!(marker.exists(), "SessionEnd hook should have run");
}
#[tokio::test]
async fn session_event_no_matching_hooks_noop() {
let hook = make_hook(HookEvent::PostToolUse, None, "echo should-not-run");
let exec = HookExecutor::new(vec![hook]);
exec.run_session_event(HookEvent::SessionStart, &session_ctx("session_start")).await;
}
#[tokio::test]
async fn session_event_tool_matcher_ignored() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("session_matcher_ignored");
let p = marker.to_string_lossy().to_string();
let hook = make_hook(HookEvent::SessionStart, Some("grep"),
&format!("touch {}", p));
let exec = HookExecutor::new(vec![hook]);
exec.run_session_event(HookEvent::SessionStart, &session_ctx("session_start")).await;
assert!(marker.exists(), "SessionStart hook ignores tool matcher");
}
#[tokio::test]
async fn session_event_crash_does_not_panic() {
let hook = make_hook(HookEvent::SessionStart, None, "exit 1");
let exec = HookExecutor::new(vec![hook]);
exec.run_session_event(HookEvent::SessionStart, &session_ctx("session_start")).await;
}
#[tokio::test]
async fn session_event_multiple_hooks_all_fire() {
let dir = tempfile::tempdir().unwrap();
let f1 = dir.path().join("ev1");
let f2 = dir.path().join("ev2");
let p1 = f1.to_string_lossy().to_string();
let p2 = f2.to_string_lossy().to_string();
let hooks = vec![
make_hook(HookEvent::SessionEnd, None, &format!("touch {}", p1)),
make_hook(HookEvent::SessionEnd, None, &format!("touch {}", p2)),
];
let exec = HookExecutor::new(hooks);
exec.run_session_event(HookEvent::SessionEnd, &session_ctx("session_end")).await;
assert!(f1.exists(), "first SessionEnd hook should run");
assert!(f2.exists(), "second SessionEnd hook should run");
}
#[tokio::test]
async fn session_notification_runs() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("notified");
let p = marker.to_string_lossy().to_string();
let hook = make_hook(HookEvent::Notification, None, &format!("touch {}", p));
let exec = HookExecutor::new(vec![hook]);
exec.run_session_event(HookEvent::Notification, &session_ctx("notification")).await;
assert!(marker.exists(), "Notification hook should run");
}
#[tokio::test]
async fn session_event_timeout_does_not_panic() {
let mut hook = make_hook(HookEvent::SessionStart, None, "sleep 10");
hook.timeout_ms = 100;
let exec = HookExecutor::new(vec![hook]);
exec.run_session_event(HookEvent::SessionStart, &session_ctx("session_start")).await;
}
#[tokio::test]
async fn execute_hook_without_tool_name_does_not_set_env() {
let hook = make_hook(HookEvent::SessionStart, None,
r#"printf '%s' "${ATOMCODE_TOOL_NAME-unset}""#);
let exec = HookExecutor::new(vec![hook]);
let ctx = HookContext {
event: "session_start".into(),
tool_name: None,
tool_args: None,
tool_result: None,
tool_success: None,
session_id: "s".into(),
working_dir: "/tmp".into(),
};
let stdout = exec.execute_hook(&exec.hooks[0], &ctx).await.unwrap();
assert_eq!(stdout, "unset");
}
#[tokio::test]
async fn execute_hook_with_plugin_root_sets_both_env_vars() {
let mut hook = make_hook(HookEvent::PreToolUse, Some("bash"),
r#"printf '%s:%s' "$CLAUDE_PLUGIN_ROOT" "$ATOMCODE_PLUGIN_ROOT""#);
hook.plugin_root = Some(std::path::PathBuf::from("/opt/p"));
let exec = HookExecutor::new(vec![hook]);
let stdout = exec.execute_hook(&exec.hooks[0], &test_ctx()).await.unwrap();
assert_eq!(stdout, "/opt/p:/opt/p");
}
#[tokio::test]
async fn execute_hook_with_positive_timeout_runs_successfully() {
let mut hook = make_hook(HookEvent::PreToolUse, None, "echo ok");
hook.timeout_ms = 5000;
let exec = HookExecutor::new(vec![hook]);
let stdout = exec.execute_hook(&exec.hooks[0], &test_ctx()).await.unwrap();
assert_eq!(stdout.trim(), "ok");
}
#[tokio::test]
async fn execute_hook_nonzero_exit_returns_error() {
let hook = make_hook(HookEvent::PreToolUse, None, "exit 2");
let exec = HookExecutor::new(vec![hook]);
let result = exec.execute_hook(&exec.hooks[0], &test_ctx()).await;
assert!(result.is_err(), "expected Err for non-zero exit, got {:?}", result);
let err = result.unwrap_err().to_string();
assert!(err.contains("exited with status"), "unexpected error: {}", err);
}
#[tokio::test]
async fn execute_hook_with_stdin_echoes_payload() {
let hook = make_hook(HookEvent::UserPromptSubmit, None, r#"cat"#);
let exec = HookExecutor::new(vec![hook]);
let (ok, stdout, _) = exec.execute_hook_with_stdin(
&exec.hooks[0], r#"{"hello":"world"}"#).await.unwrap();
assert!(ok);
assert!(stdout.contains("hello"));
}
#[tokio::test]
async fn execute_hook_with_stdin_nonzero_exit() {
let hook = make_hook(HookEvent::UserPromptSubmit, None, r#"echo 'reason on stderr' >&2; exit 1"#);
let exec = HookExecutor::new(vec![hook]);
let (ok, _stdout, stderr) = exec.execute_hook_with_stdin(
&exec.hooks[0], r#"{"k":"v"}"#).await.unwrap();
assert!(!ok);
assert!(stderr.trim() == "reason on stderr",
"stderr should contain the error message, got: '{stderr}'");
}
#[test]
fn push_context_empty_acc() {
let mut acc = String::new();
push_context(&mut acc, "hello");
assert_eq!(acc, "hello");
}
#[test]
fn push_context_non_empty_acc() {
let mut acc = "first".to_string();
push_context(&mut acc, "second");
assert_eq!(acc, "first\n\nsecond");
}
#[test]
fn push_context_empty_extra() {
let mut acc = "first".to_string();
push_context(&mut acc, "");
assert_eq!(acc, "first");
}
#[test]
fn push_context_multiple_appends() {
let mut acc = String::new();
push_context(&mut acc, "a");
push_context(&mut acc, "b");
push_context(&mut acc, "c");
assert_eq!(acc, "a\n\nb\n\nc");
}
}