1pub mod config;
2pub mod executor;
3pub mod json_config;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum HookEvent {
12 PreToolUse,
13 PostToolUse,
14 SessionStart,
15 SessionEnd,
16 Notification,
17 UserPromptSubmit,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct HookConfig {
27 pub event: HookEvent,
28 pub matcher: Option<String>,
31 pub command: String,
33 #[serde(default = "default_timeout_ms")]
35 pub timeout_ms: u64,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub plugin_root: Option<std::path::PathBuf>,
44}
45
46fn default_timeout_ms() -> u64 {
47 10_000
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct UserPromptSubmitPayload {
55 pub session_id: String,
56 pub hook_event_name: String,
57 pub prompt: String,
58 pub cwd: String,
59}
60
61#[derive(Debug, Clone, PartialEq)]
63pub enum UserPromptHookResult {
64 Continue,
66 Inject(String),
69 Block(String),
71}
72
73#[derive(Debug, Deserialize, Default)]
77#[serde(default)]
78pub(crate) struct UserPromptSubmitOutput {
79 pub decision: Option<String>,
82 pub reason: Option<String>,
84 #[serde(rename = "hookSpecificOutput")]
86 pub hook_specific_output: Option<UserPromptHookSpecific>,
87}
88
89#[derive(Debug, Deserialize, Default)]
90#[serde(default)]
91pub(crate) struct UserPromptHookSpecific {
92 #[serde(rename = "additionalContext")]
94 pub additional_context: Option<String>,
95}
96
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99#[serde(tag = "action", rename_all = "snake_case")]
100pub enum PreHookResult {
101 Allow,
103 Block { reason: String },
105 Modify { args: Value },
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct HookContext {
112 pub event: String,
114 pub tool_name: Option<String>,
116 pub tool_args: Option<Value>,
118 pub tool_result: Option<String>,
120 pub tool_success: Option<bool>,
122 pub session_id: String,
124 pub working_dir: String,
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use serde_json::json;
132
133 #[test]
136 fn hook_event_serializes_to_snake_case() {
137 assert_eq!(
138 serde_json::to_string(&HookEvent::PreToolUse).unwrap(),
139 r#""pre_tool_use""#
140 );
141 assert_eq!(
142 serde_json::to_string(&HookEvent::PostToolUse).unwrap(),
143 r#""post_tool_use""#
144 );
145 assert_eq!(
146 serde_json::to_string(&HookEvent::SessionStart).unwrap(),
147 r#""session_start""#
148 );
149 assert_eq!(
150 serde_json::to_string(&HookEvent::SessionEnd).unwrap(),
151 r#""session_end""#
152 );
153 assert_eq!(
154 serde_json::to_string(&HookEvent::Notification).unwrap(),
155 r#""notification""#
156 );
157 }
158
159 #[test]
160 fn hook_event_deserializes_from_snake_case() {
161 let event: HookEvent = serde_json::from_str(r#""pre_tool_use""#).unwrap();
162 assert_eq!(event, HookEvent::PreToolUse);
163
164 let event: HookEvent = serde_json::from_str(r#""post_tool_use""#).unwrap();
165 assert_eq!(event, HookEvent::PostToolUse);
166 }
167
168 #[test]
171 fn hook_config_roundtrip_json() {
172 let cfg = HookConfig {
173 event: HookEvent::PreToolUse,
174 matcher: Some("bash".into()),
175 command: "echo ok".into(),
176 timeout_ms: 5000,
177 plugin_root: None,
178 };
179 let json = serde_json::to_string(&cfg).unwrap();
180 let back: HookConfig = serde_json::from_str(&json).unwrap();
181 assert_eq!(back.event, HookEvent::PreToolUse);
182 assert_eq!(back.matcher.as_deref(), Some("bash"));
183 assert_eq!(back.command, "echo ok");
184 assert_eq!(back.timeout_ms, 5000);
185 }
186
187 #[test]
188 fn hook_config_timeout_defaults_to_10000() {
189 let json = r#"{
190 "event": "session_start",
191 "command": "notify-send hello"
192 }"#;
193 let cfg: HookConfig = serde_json::from_str(json).unwrap();
194 assert_eq!(cfg.timeout_ms, 10_000);
195 assert!(cfg.matcher.is_none());
196 }
197
198 #[test]
199 fn hook_config_roundtrip_toml() {
200 let toml_str = r#"
201event = "pre_tool_use"
202matcher = "write"
203command = "check-write.sh"
204timeout_ms = 3000
205"#;
206 let cfg: HookConfig = toml::from_str(toml_str).unwrap();
207 assert_eq!(cfg.event, HookEvent::PreToolUse);
208 assert_eq!(cfg.matcher.as_deref(), Some("write"));
209 assert_eq!(cfg.timeout_ms, 3000);
210 }
211
212 #[test]
215 fn pre_hook_result_allow_roundtrip() {
216 let r = PreHookResult::Allow;
217 let json = serde_json::to_value(&r).unwrap();
218 assert_eq!(json, json!({"action": "allow"}));
219
220 let back: PreHookResult = serde_json::from_value(json).unwrap();
221 assert_eq!(back, PreHookResult::Allow);
222 }
223
224 #[test]
225 fn pre_hook_result_block_roundtrip() {
226 let r = PreHookResult::Block {
227 reason: "unsafe".into(),
228 };
229 let json = serde_json::to_value(&r).unwrap();
230 assert_eq!(json, json!({"action": "block", "reason": "unsafe"}));
231
232 let back: PreHookResult = serde_json::from_value(json).unwrap();
233 assert_eq!(back, r);
234 }
235
236 #[test]
237 fn pre_hook_result_modify_roundtrip() {
238 let new_args = json!({"path": "/safe/dir", "content": "ok"});
239 let r = PreHookResult::Modify {
240 args: new_args.clone(),
241 };
242 let json = serde_json::to_value(&r).unwrap();
243 assert_eq!(
244 json,
245 json!({"action": "modify", "args": {"path": "/safe/dir", "content": "ok"}})
246 );
247
248 let back: PreHookResult = serde_json::from_value(json).unwrap();
249 assert_eq!(back, r);
250 }
251
252 #[test]
255 fn hook_context_full_roundtrip() {
256 let ctx = HookContext {
257 event: "pre_tool_use".into(),
258 tool_name: Some("bash".into()),
259 tool_args: Some(json!({"command": "ls"})),
260 tool_result: None,
261 tool_success: None,
262 session_id: "abc-123".into(),
263 working_dir: "/home/user/project".into(),
264 };
265 let json = serde_json::to_string(&ctx).unwrap();
266 let back: HookContext = serde_json::from_str(&json).unwrap();
267 assert_eq!(back.event, "pre_tool_use");
268 assert_eq!(back.tool_name.as_deref(), Some("bash"));
269 assert!(back.tool_result.is_none());
270 assert!(back.tool_success.is_none());
271 assert_eq!(back.session_id, "abc-123");
272 }
273
274 #[test]
275 fn hook_context_post_tool_use() {
276 let ctx = HookContext {
277 event: "post_tool_use".into(),
278 tool_name: Some("write".into()),
279 tool_args: None,
280 tool_result: Some("file written".into()),
281 tool_success: Some(true),
282 session_id: "xyz-789".into(),
283 working_dir: "/tmp".into(),
284 };
285 let v = serde_json::to_value(&ctx).unwrap();
286 assert_eq!(v["tool_success"], json!(true));
287 assert_eq!(v["tool_result"], json!("file written"));
288 }
289
290 #[test]
291 fn hook_context_minimal_session_event() {
292 let json_str = r#"{
293 "event": "session_start",
294 "tool_name": null,
295 "tool_args": null,
296 "tool_result": null,
297 "tool_success": null,
298 "session_id": "s1",
299 "working_dir": "/home"
300 }"#;
301 let ctx: HookContext = serde_json::from_str(json_str).unwrap();
302 assert_eq!(ctx.event, "session_start");
303 assert!(ctx.tool_name.is_none());
304 }
305}