oxicode_sdk/ports/
hooks.rs1use std::future::Future;
6use std::path::PathBuf;
7use std::pin::Pin;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "PascalCase")]
15pub enum HookEvent {
16 #[default]
18 PreToolUse,
19 PostToolUse,
21 Stop,
23 SubagentStop,
25 SessionStart,
27 SessionEnd,
29 Notification,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct HookContext {
36 pub event: HookEvent,
38 pub tool_name: Option<String>,
40 pub tool_args: Option<serde_json::Value>,
43 pub tool_result: Option<String>,
45 pub is_error: Option<bool>,
47 pub session_id: Option<String>,
49 pub session_cwd: Option<PathBuf>,
51 #[serde(skip_serializing_if = "Option::is_none", default)]
53 pub extra: Option<serde_json::Value>,
54}
55
56#[derive(Debug, Clone, Default)]
64pub struct HookOutcome {
65 pub block: bool,
67 pub reason: Option<String>,
69 pub override_content: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct HookSpec {
76 pub event: HookEvent,
78 #[serde(default)]
80 pub matcher: Option<String>,
81 pub command: String,
83 #[serde(default)]
85 pub timeout_secs: Option<u64>,
86}
87
88pub trait HookRunner: Send + Sync + 'static {
91 fn run<'a>(
98 &'a self,
99 event: HookEvent,
100 ctx: &'a HookContext,
101 ) -> Pin<Box<dyn Future<Output = HookOutcome> + Send + 'a>>;
102}
103
104#[derive(Debug, Default, Clone, Copy)]
107pub struct NoopHookRunner;
108
109impl HookRunner for NoopHookRunner {
110 fn run<'a>(
111 &'a self,
112 _event: HookEvent,
113 _ctx: &'a HookContext,
114 ) -> Pin<Box<dyn Future<Output = HookOutcome> + Send + 'a>> {
115 Box::pin(async { HookOutcome::default() })
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[tokio::test]
124 async fn noop_runner_returns_default_outcome() {
125 let runner = NoopHookRunner;
126 let ctx = HookContext {
127 event: HookEvent::PreToolUse,
128 tool_name: Some("bash".into()),
129 ..Default::default()
130 };
131 let outcome = runner.run(HookEvent::PreToolUse, &ctx).await;
132 assert!(!outcome.block);
133 assert!(outcome.reason.is_none());
134 assert!(outcome.override_content.is_none());
135 }
136
137 #[test]
138 fn hook_event_serialises_pascalcase() {
139 let json = serde_json::to_string(&HookEvent::PreToolUse).unwrap();
140 assert_eq!(json, "\"PreToolUse\"");
141 let json = serde_json::to_string(&HookEvent::SessionStart).unwrap();
142 assert_eq!(json, "\"SessionStart\"");
143 let parsed: HookEvent = serde_json::from_str("\"SubagentStop\"").unwrap();
145 assert_eq!(parsed, HookEvent::SubagentStop);
146 }
147
148 #[test]
149 fn hook_context_serialises_with_extras() {
150 let ctx = HookContext {
151 event: HookEvent::PreToolUse,
152 tool_name: Some("bash".into()),
153 tool_args: Some(serde_json::json!({"command": "ls"})),
154 ..Default::default()
155 };
156 let json = serde_json::to_value(&ctx).unwrap();
157 assert_eq!(json["event"], "PreToolUse");
158 assert_eq!(json["tool_name"], "bash");
159 assert_eq!(json["tool_args"]["command"], "ls");
160 assert!(json.get("extra").is_none());
162 }
163
164 #[test]
165 fn hook_spec_minimal_parses() {
166 let toml = r#"
167 event = "PreToolUse"
168 command = "echo hi"
169 "#;
170 let spec: HookSpec = toml::from_str(toml).unwrap();
171 assert_eq!(spec.event, HookEvent::PreToolUse);
172 assert_eq!(spec.command, "echo hi");
173 assert!(spec.matcher.is_none());
174 assert!(spec.timeout_secs.is_none());
175 }
176}