1pub mod callback;
14pub mod registry;
15
16pub use callback::{ErasedHookCallback, HookCallback, HookDecision};
17pub use forge_primitives::hooks::{
18 BaseHookInput, HookContext, HookKind, NotificationInput, PermissionRequestInput,
19 PostToolUseFailureInput, PostToolUseInput, PreCompactInput, PreToolUseHookSpecificOutput,
20 PreToolUseInput, StopInput, SubagentContext, SubagentStartInput, SubagentStopInput,
21 UserPromptSubmitHookSpecificOutput, UserPromptSubmitInput,
22};
23pub use registry::{Hooks, HooksBuilder};
24
25#[cfg(test)]
26mod tests_hooks_types {
27 #[allow(unused_imports)]
29 use super::*;
30
31 use crate::{
32 BaseHookInput, HookDecision, HookKind, NotificationInput, PermissionRequestInput,
33 PostToolUseFailureInput, PostToolUseInput, PreCompactInput, PreToolUseInput, StopInput,
34 SubagentContext, SubagentStartInput, SubagentStopInput, UserPromptSubmitInput,
35 };
36 use serde_json::json;
37
38 #[test]
40 fn base_hook_input_parses_required_fields() {
41 let raw = json!({
42 "session_id": "sess-123",
43 "transcript_path": "/tmp/t.jsonl",
44 "cwd": "/home/u",
45 "permission_mode": "default"
46 });
47 let base: BaseHookInput = serde_json::from_value(raw).expect("parse");
48 assert_eq!(base.session_id, "sess-123");
49 assert_eq!(base.transcript_path, "/tmp/t.jsonl");
50 assert_eq!(base.cwd, "/home/u");
51 assert_eq!(base.permission_mode.as_deref(), Some("default"));
52 }
53
54 #[test]
56 fn base_hook_input_permission_mode_optional() {
57 let raw = json!({
58 "session_id": "s",
59 "transcript_path": "/t",
60 "cwd": "/c"
61 });
62 let base: BaseHookInput = serde_json::from_value(raw).expect("parse");
63 assert!(base.permission_mode.is_none());
64 }
65
66 #[test]
67 fn pre_tool_use_input_carries_base_and_subagent_fields() {
68 let raw = json!({
69 "hook_event_name": "PreToolUse",
70 "session_id": "sess",
71 "transcript_path": "/t",
72 "cwd": "/c",
73 "permission_mode": "default",
74 "agent_id": "agent-1",
75 "agent_type": "general-purpose",
76 "tool_name": "Bash",
77 "tool_input": {"command": "echo hi"},
78 "tool_use_id": "toolu_1"
79 });
80 let input: PreToolUseInput = serde_json::from_value(raw).expect("parse");
81 assert_eq!(input.base.session_id, "sess");
82 assert_eq!(input.base.transcript_path, "/t");
83 assert_eq!(input.base.cwd, "/c");
84 assert_eq!(input.subagent.agent_id.as_deref(), Some("agent-1"));
85 assert_eq!(input.subagent.agent_type.as_deref(), Some("general-purpose"));
86 assert_eq!(input.tool_name, "Bash");
87 assert_eq!(input.tool_use_id, "toolu_1");
88 }
89
90 #[test]
91 fn pre_tool_use_input_subagent_fields_optional() {
92 let raw = json!({
94 "hook_event_name": "PreToolUse",
95 "session_id": "s",
96 "transcript_path": "/t",
97 "cwd": "/c",
98 "tool_name": "Bash",
99 "tool_input": {"command": "ls"},
100 "tool_use_id": "toolu_1"
101 });
102 let input: PreToolUseInput = serde_json::from_value(raw).expect("parse");
103 assert!(input.subagent.agent_id.is_none());
104 assert!(input.subagent.agent_type.is_none());
105 }
106
107 #[test]
108 fn post_tool_use_input_carries_tool_response_and_tool_use_id() {
109 let raw = json!({
110 "hook_event_name": "PostToolUse",
111 "session_id": "s",
112 "transcript_path": "/t",
113 "cwd": "/c",
114 "tool_name": "Bash",
115 "tool_input": {"command": "ls"},
116 "tool_response": {"stdout": "a\nb\n"},
117 "tool_use_id": "toolu_9"
118 });
119 let input: PostToolUseInput = serde_json::from_value(raw).expect("parse");
120 assert_eq!(input.tool_name, "Bash");
121 assert_eq!(input.tool_use_id, "toolu_9");
122 assert!(input.tool_response.get("stdout").is_some());
123 }
124
125 #[test]
126 fn user_prompt_submit_input_carries_base_and_prompt() {
127 let raw = json!({
128 "hook_event_name": "UserPromptSubmit",
129 "session_id": "s",
130 "transcript_path": "/t",
131 "cwd": "/c",
132 "prompt": "hi there"
133 });
134 let input: UserPromptSubmitInput = serde_json::from_value(raw).expect("parse");
135 assert_eq!(input.prompt, "hi there");
136 assert_eq!(input.base.session_id, "s");
137 }
138
139 #[test]
140 fn stop_input_has_stop_hook_active_not_num_turns() {
141 let raw = json!({
144 "hook_event_name": "Stop",
145 "session_id": "s",
146 "transcript_path": "/t",
147 "cwd": "/c",
148 "stop_hook_active": true
149 });
150 let input: StopInput = serde_json::from_value(raw).expect("parse");
151 assert!(input.stop_hook_active);
152 }
153
154 #[test]
155 fn subagent_stop_input_carries_agent_metadata() {
156 let raw = json!({
157 "hook_event_name": "SubagentStop",
158 "session_id": "s",
159 "transcript_path": "/t",
160 "cwd": "/c",
161 "stop_hook_active": false,
162 "agent_id": "agent-1",
163 "agent_transcript_path": "/tmp/agent-1.jsonl",
164 "agent_type": "code-reviewer"
165 });
166 let input: SubagentStopInput = serde_json::from_value(raw).expect("parse");
167 assert!(!input.stop_hook_active);
168 assert_eq!(input.agent_id, "agent-1");
169 assert_eq!(input.agent_transcript_path, "/tmp/agent-1.jsonl");
170 assert_eq!(input.agent_type, "code-reviewer");
171 }
172
173 #[test]
174 fn pre_compact_input_has_trigger_and_custom_instructions() {
175 let raw = json!({
176 "hook_event_name": "PreCompact",
177 "session_id": "s",
178 "transcript_path": "/t",
179 "cwd": "/c",
180 "trigger": "auto",
181 "custom_instructions": null
182 });
183 let input: PreCompactInput = serde_json::from_value(raw).expect("parse");
184 assert_eq!(input.trigger, "auto");
185 assert!(input.custom_instructions.is_none());
186 }
187
188 #[test]
189 fn post_tool_use_failure_input_carries_error_field() {
190 let raw = json!({
191 "hook_event_name": "PostToolUseFailure",
192 "session_id": "s",
193 "transcript_path": "/t",
194 "cwd": "/c",
195 "tool_name": "Bash",
196 "tool_input": {"command": "false"},
197 "tool_use_id": "toolu_x",
198 "error": "exit 1",
199 "is_interrupt": false
200 });
201 let input: PostToolUseFailureInput = serde_json::from_value(raw).expect("parse");
202 assert_eq!(input.error, "exit 1");
203 assert_eq!(input.is_interrupt, Some(false));
204 assert_eq!(input.tool_use_id, "toolu_x");
205 }
206
207 #[test]
208 fn post_tool_use_failure_input_is_interrupt_optional() {
209 let raw = json!({
211 "hook_event_name": "PostToolUseFailure",
212 "session_id": "s",
213 "transcript_path": "/t",
214 "cwd": "/c",
215 "tool_name": "Bash",
216 "tool_input": {"command": "false"},
217 "tool_use_id": "toolu_x",
218 "error": "boom"
219 });
220 let input: PostToolUseFailureInput = serde_json::from_value(raw).expect("parse");
221 assert!(input.is_interrupt.is_none());
222 }
223
224 #[test]
225 fn notification_input_parses() {
226 let raw = json!({
227 "hook_event_name": "Notification",
228 "session_id": "s",
229 "transcript_path": "/t",
230 "cwd": "/c",
231 "message": "Tool needs approval",
232 "title": "Permission",
233 "notification_type": "permission_request"
234 });
235 let input: NotificationInput = serde_json::from_value(raw).expect("parse");
236 assert_eq!(input.message, "Tool needs approval");
237 assert_eq!(input.title.as_deref(), Some("Permission"));
238 assert_eq!(input.notification_type, "permission_request");
239 }
240
241 #[test]
242 fn subagent_start_input_parses() {
243 let raw = json!({
244 "hook_event_name": "SubagentStart",
245 "session_id": "s",
246 "transcript_path": "/t",
247 "cwd": "/c",
248 "agent_id": "agent-1",
249 "agent_type": "code-reviewer"
250 });
251 let input: SubagentStartInput = serde_json::from_value(raw).expect("parse");
252 assert_eq!(input.agent_id, "agent-1");
253 assert_eq!(input.agent_type, "code-reviewer");
254 }
255
256 #[test]
257 fn permission_request_input_parses() {
258 let raw = json!({
259 "hook_event_name": "PermissionRequest",
260 "session_id": "s",
261 "transcript_path": "/t",
262 "cwd": "/c",
263 "tool_name": "Bash",
264 "tool_input": {"command": "rm -rf /"},
265 "permission_suggestions": [{"behavior":"ask"}]
266 });
267 let input: PermissionRequestInput = serde_json::from_value(raw).expect("parse");
268 assert_eq!(input.tool_name, "Bash");
269 assert_eq!(input.permission_suggestions.as_ref().map(Vec::len), Some(1));
270 }
271
272 #[test]
273 fn subagent_context_round_trips() {
274 let ctx = SubagentContext { agent_id: Some("a".into()), agent_type: Some("t".into()) };
275 let v = serde_json::to_value(&ctx).expect("serialise");
276 assert_eq!(v, json!({"agent_id": "a", "agent_type": "t"}));
277 let empty = SubagentContext::default();
279 let v2 = serde_json::to_value(empty).expect("serialise");
280 assert_eq!(v2, json!({}));
281 }
282
283 #[test]
284 fn hook_decision_allow() {
285 let d = HookDecision::allow();
286 assert!(d.is_allow());
287 }
288
289 #[test]
290 fn hook_decision_deny() {
291 let d = HookDecision::deny("no go");
292 assert!(!d.is_allow());
293 assert_eq!(d.reason(), Some("no go"));
294 }
295
296 #[test]
297 fn hook_decision_replace_input() {
298 let d = HookDecision::replace_input(json!({"command": "echo safe"}));
299 assert!(d.is_allow());
300 assert!(d.updated_input().is_some());
301 }
302
303 #[test]
304 fn hook_kind_discriminants_cover_all_variants_plus_unknown() {
305 assert_eq!(HookKind::PreToolUse.as_str(), "PreToolUse");
306 assert_eq!(HookKind::PostToolUse.as_str(), "PostToolUse");
307 assert_eq!(HookKind::PostToolUseFailure.as_str(), "PostToolUseFailure");
308 assert_eq!(HookKind::UserPromptSubmit.as_str(), "UserPromptSubmit");
309 assert_eq!(HookKind::Stop.as_str(), "Stop");
310 assert_eq!(HookKind::SubagentStop.as_str(), "SubagentStop");
311 assert_eq!(HookKind::SubagentStart.as_str(), "SubagentStart");
312 assert_eq!(HookKind::PreCompact.as_str(), "PreCompact");
313 assert_eq!(HookKind::Notification.as_str(), "Notification");
314 assert_eq!(HookKind::PermissionRequest.as_str(), "PermissionRequest");
315 assert_eq!(HookKind::WorktreeCreate.as_str(), "WorktreeCreate");
316 assert_eq!(HookKind::WorktreeRemove.as_str(), "WorktreeRemove");
317 assert_eq!(HookKind::CwdChanged.as_str(), "CwdChanged");
318 assert_eq!(HookKind::FileChanged.as_str(), "FileChanged");
319 assert_eq!(HookKind::SessionStart.as_str(), "SessionStart");
320 assert_eq!(HookKind::SessionEnd.as_str(), "SessionEnd");
321 assert_eq!(HookKind::Unknown.as_str(), "Unknown");
322 }
323
324 #[test]
325 fn hook_kind_from_wire_round_trips() {
326 for kind in [
327 HookKind::PreToolUse,
328 HookKind::PostToolUse,
329 HookKind::PostToolUseFailure,
330 HookKind::UserPromptSubmit,
331 HookKind::Stop,
332 HookKind::SubagentStop,
333 HookKind::SubagentStart,
334 HookKind::PreCompact,
335 HookKind::Notification,
336 HookKind::PermissionRequest,
337 HookKind::WorktreeCreate,
338 HookKind::WorktreeRemove,
339 HookKind::CwdChanged,
340 HookKind::FileChanged,
341 HookKind::SessionStart,
342 HookKind::SessionEnd,
343 ] {
344 assert_eq!(HookKind::from_wire(kind.as_str()), kind);
345 }
346 assert_eq!(HookKind::from_wire("BrandNewKind"), HookKind::Unknown);
347 }
348
349 #[test]
350 fn hook_decision_defer_carries_timeout() {
351 let d = HookDecision::defer(Some(5000));
352 assert!(d.is_deferred());
353 assert_eq!(d.defer_timeout_ms(), Some(5000));
354 assert!(d.is_allow());
358 assert!(d.updated_input().is_none());
359 assert!(d.reason().is_none());
360 }
361
362 #[test]
363 fn hook_decision_defer_without_timeout_leaves_field_none() {
364 let d = HookDecision::defer(None);
365 assert!(d.is_deferred());
366 assert!(d.defer_timeout_ms().is_none());
367 }
368}