use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "PascalCase")]
pub enum HookEvent {
#[default]
PreToolUse,
PostToolUse,
Stop,
SubagentStop,
SessionStart,
SessionEnd,
Notification,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HookContext {
pub event: HookEvent,
pub tool_name: Option<String>,
pub tool_args: Option<serde_json::Value>,
pub tool_result: Option<String>,
pub is_error: Option<bool>,
pub session_id: Option<String>,
pub session_cwd: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub extra: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default)]
pub struct HookOutcome {
pub block: bool,
pub reason: Option<String>,
pub override_content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookSpec {
pub event: HookEvent,
#[serde(default)]
pub matcher: Option<String>,
pub command: String,
#[serde(default)]
pub timeout_secs: Option<u64>,
}
pub trait HookRunner: Send + Sync + 'static {
fn run<'a>(
&'a self,
event: HookEvent,
ctx: &'a HookContext,
) -> Pin<Box<dyn Future<Output = HookOutcome> + Send + 'a>>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopHookRunner;
impl HookRunner for NoopHookRunner {
fn run<'a>(
&'a self,
_event: HookEvent,
_ctx: &'a HookContext,
) -> Pin<Box<dyn Future<Output = HookOutcome> + Send + 'a>> {
Box::pin(async { HookOutcome::default() })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn noop_runner_returns_default_outcome() {
let runner = NoopHookRunner;
let ctx = HookContext {
event: HookEvent::PreToolUse,
tool_name: Some("bash".into()),
..Default::default()
};
let outcome = runner.run(HookEvent::PreToolUse, &ctx).await;
assert!(!outcome.block);
assert!(outcome.reason.is_none());
assert!(outcome.override_content.is_none());
}
#[test]
fn hook_event_serialises_pascalcase() {
let json = serde_json::to_string(&HookEvent::PreToolUse).unwrap();
assert_eq!(json, "\"PreToolUse\"");
let json = serde_json::to_string(&HookEvent::SessionStart).unwrap();
assert_eq!(json, "\"SessionStart\"");
let parsed: HookEvent = serde_json::from_str("\"SubagentStop\"").unwrap();
assert_eq!(parsed, HookEvent::SubagentStop);
}
#[test]
fn hook_context_serialises_with_extras() {
let ctx = HookContext {
event: HookEvent::PreToolUse,
tool_name: Some("bash".into()),
tool_args: Some(serde_json::json!({"command": "ls"})),
..Default::default()
};
let json = serde_json::to_value(&ctx).unwrap();
assert_eq!(json["event"], "PreToolUse");
assert_eq!(json["tool_name"], "bash");
assert_eq!(json["tool_args"]["command"], "ls");
assert!(json.get("extra").is_none());
}
#[test]
fn hook_spec_minimal_parses() {
let toml = r#"
event = "PreToolUse"
command = "echo hi"
"#;
let spec: HookSpec = toml::from_str(toml).unwrap();
assert_eq!(spec.event, HookEvent::PreToolUse);
assert_eq!(spec.command, "echo hi");
assert!(spec.matcher.is_none());
assert!(spec.timeout_secs.is_none());
}
}