1use crate::permissions::PermissionUpdate;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct BaseHookInput {
9 pub session_id: String,
10 pub transcript_path: String,
11 pub cwd: String,
12 #[serde(skip_serializing_if = "Option::is_none")]
13 pub permission_mode: Option<String>,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub agent_id: Option<String>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub agent_type: Option<String>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(tag = "hook_event_name")]
45pub enum HookInput {
46 PreToolUse {
47 #[serde(flatten)]
48 base: BaseHookInput,
49 tool_name: String,
50 tool_input: serde_json::Value,
51 tool_use_id: String,
52 },
53
54 PostToolUse {
55 #[serde(flatten)]
56 base: BaseHookInput,
57 tool_name: String,
58 tool_input: serde_json::Value,
59 tool_response: serde_json::Value,
60 tool_use_id: String,
61 },
62
63 PostToolUseFailure {
64 #[serde(flatten)]
65 base: BaseHookInput,
66 tool_name: String,
67 tool_input: serde_json::Value,
68 tool_use_id: String,
69 error: String,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 is_interrupt: Option<bool>,
72 },
73
74 Notification {
75 #[serde(flatten)]
76 base: BaseHookInput,
77 message: String,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 title: Option<String>,
80 notification_type: String,
81 },
82
83 UserPromptSubmit {
84 #[serde(flatten)]
85 base: BaseHookInput,
86 prompt: String,
87 },
88
89 SessionStart {
90 #[serde(flatten)]
91 base: BaseHookInput,
92 source: SessionStartSource,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 model: Option<String>,
95 },
96
97 SessionEnd {
98 #[serde(flatten)]
99 base: BaseHookInput,
100 reason: String,
101 },
102
103 Stop {
104 #[serde(flatten)]
105 base: BaseHookInput,
106 stop_hook_active: bool,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 last_assistant_message: Option<String>,
109 },
110
111 SubagentStart {
112 #[serde(flatten)]
113 base: BaseHookInput,
114 agent_id: String,
115 agent_type: String,
116 },
117
118 SubagentStop {
119 #[serde(flatten)]
120 base: BaseHookInput,
121 stop_hook_active: bool,
122 agent_id: String,
123 agent_transcript_path: String,
124 agent_type: String,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 last_assistant_message: Option<String>,
127 },
128
129 PreCompact {
130 #[serde(flatten)]
131 base: BaseHookInput,
132 trigger: CompactTriggerType,
133 custom_instructions: Option<String>,
134 },
135
136 PermissionRequest {
137 #[serde(flatten)]
138 base: BaseHookInput,
139 tool_name: String,
140 tool_input: serde_json::Value,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 permission_suggestions: Option<Vec<PermissionUpdate>>,
143 },
144
145 Setup {
146 #[serde(flatten)]
147 base: BaseHookInput,
148 trigger: SetupTrigger,
149 },
150
151 TeammateIdle {
152 #[serde(flatten)]
153 base: BaseHookInput,
154 teammate_name: String,
155 team_name: String,
156 },
157
158 TaskCompleted {
159 #[serde(flatten)]
160 base: BaseHookInput,
161 task_id: String,
162 task_subject: String,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 task_description: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 teammate_name: Option<String>,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 team_name: Option<String>,
169 },
170
171 ConfigChange {
172 #[serde(flatten)]
173 base: BaseHookInput,
174 source: ConfigChangeSource,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 file_path: Option<String>,
177 },
178
179 WorktreeCreate {
180 #[serde(flatten)]
181 base: BaseHookInput,
182 name: String,
183 },
184
185 WorktreeRemove {
186 #[serde(flatten)]
187 base: BaseHookInput,
188 worktree_path: String,
189 },
190}
191
192impl HookInput {
193 pub fn tool_name(&self) -> Option<&str> {
195 match self {
196 HookInput::PreToolUse { tool_name, .. }
197 | HookInput::PostToolUse { tool_name, .. }
198 | HookInput::PostToolUseFailure { tool_name, .. }
199 | HookInput::PermissionRequest { tool_name, .. } => Some(tool_name),
200 _ => None,
201 }
202 }
203
204 pub fn event_name(&self) -> &str {
206 match self {
207 HookInput::PreToolUse { .. } => "PreToolUse",
208 HookInput::PostToolUse { .. } => "PostToolUse",
209 HookInput::PostToolUseFailure { .. } => "PostToolUseFailure",
210 HookInput::Notification { .. } => "Notification",
211 HookInput::UserPromptSubmit { .. } => "UserPromptSubmit",
212 HookInput::SessionStart { .. } => "SessionStart",
213 HookInput::SessionEnd { .. } => "SessionEnd",
214 HookInput::Stop { .. } => "Stop",
215 HookInput::SubagentStart { .. } => "SubagentStart",
216 HookInput::SubagentStop { .. } => "SubagentStop",
217 HookInput::PreCompact { .. } => "PreCompact",
218 HookInput::PermissionRequest { .. } => "PermissionRequest",
219 HookInput::Setup { .. } => "Setup",
220 HookInput::TeammateIdle { .. } => "TeammateIdle",
221 HookInput::TaskCompleted { .. } => "TaskCompleted",
222 HookInput::ConfigChange { .. } => "ConfigChange",
223 HookInput::WorktreeCreate { .. } => "WorktreeCreate",
224 HookInput::WorktreeRemove { .. } => "WorktreeRemove",
225 }
226 }
227
228 pub fn base(&self) -> &BaseHookInput {
230 match self {
231 HookInput::PreToolUse { base, .. }
232 | HookInput::PostToolUse { base, .. }
233 | HookInput::PostToolUseFailure { base, .. }
234 | HookInput::Notification { base, .. }
235 | HookInput::UserPromptSubmit { base, .. }
236 | HookInput::SessionStart { base, .. }
237 | HookInput::SessionEnd { base, .. }
238 | HookInput::Stop { base, .. }
239 | HookInput::SubagentStart { base, .. }
240 | HookInput::SubagentStop { base, .. }
241 | HookInput::PreCompact { base, .. }
242 | HookInput::PermissionRequest { base, .. }
243 | HookInput::Setup { base, .. }
244 | HookInput::TeammateIdle { base, .. }
245 | HookInput::TaskCompleted { base, .. }
246 | HookInput::ConfigChange { base, .. }
247 | HookInput::WorktreeCreate { base, .. }
248 | HookInput::WorktreeRemove { base, .. } => base,
249 }
250 }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254#[serde(rename_all = "lowercase")]
255pub enum SessionStartSource {
256 Startup,
257 Resume,
258 Clear,
259 Compact,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
263#[serde(rename_all = "lowercase")]
264pub enum CompactTriggerType {
265 Manual,
266 Auto,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270#[serde(rename_all = "lowercase")]
271pub enum SetupTrigger {
272 Init,
274 Maintenance,
276 Boot,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
281#[serde(rename_all = "snake_case")]
282pub enum ConfigChangeSource {
283 UserSettings,
284 ProjectSettings,
285 LocalSettings,
286 PolicySettings,
287 Skills,
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 fn base() -> BaseHookInput {
295 BaseHookInput {
296 session_id: "sess-1".into(),
297 transcript_path: "/tmp/transcript.json".into(),
298 cwd: "/projects/test".into(),
299 permission_mode: Some("default".into()),
300 agent_id: None,
301 agent_type: None,
302 }
303 }
304
305 #[test]
306 fn tool_name_returns_some_for_tool_events() {
307 let input = HookInput::PreToolUse {
308 base: base(),
309 tool_name: "Bash".into(),
310 tool_input: serde_json::json!({"command": "ls"}),
311 tool_use_id: "tu-1".into(),
312 };
313 assert_eq!(input.tool_name(), Some("Bash"));
314 }
315
316 #[test]
317 fn tool_name_returns_none_for_non_tool_events() {
318 let input = HookInput::SessionStart {
319 base: base(),
320 source: SessionStartSource::Startup,
321 model: Some("claude-haiku-4-5".into()),
322 };
323 assert_eq!(input.tool_name(), None);
324 }
325
326 #[test]
327 fn event_name_matches_variant() {
328 let input = HookInput::PostToolUseFailure {
329 base: base(),
330 tool_name: "Write".into(),
331 tool_input: serde_json::json!({}),
332 tool_use_id: "tu-2".into(),
333 error: "permission denied".into(),
334 is_interrupt: Some(false),
335 };
336 assert_eq!(input.event_name(), "PostToolUseFailure");
337 }
338
339 #[test]
340 fn base_accessor_returns_correct_fields() {
341 let b = base();
342 let input = HookInput::Stop {
343 base: b.clone(),
344 stop_hook_active: true,
345 last_assistant_message: None,
346 };
347 assert_eq!(input.base().session_id, "sess-1");
348 assert_eq!(input.base().cwd, "/projects/test");
349 }
350
351 #[test]
352 fn serde_roundtrip_tagged() {
353 let input = HookInput::UserPromptSubmit {
354 base: base(),
355 prompt: "hello world".into(),
356 };
357 let json = serde_json::to_string(&input).unwrap();
358 assert!(json.contains("\"hook_event_name\":\"UserPromptSubmit\""));
359 let back: HookInput = serde_json::from_str(&json).unwrap();
360 assert_eq!(back.event_name(), "UserPromptSubmit");
361 }
362
363 #[test]
364 fn session_start_source_serde() {
365 let src = SessionStartSource::Resume;
366 let json = serde_json::to_string(&src).unwrap();
367 assert_eq!(json, "\"resume\"");
368 let back: SessionStartSource = serde_json::from_str(&json).unwrap();
369 assert_eq!(back, SessionStartSource::Resume);
370 }
371
372 #[test]
373 fn config_change_source_serde() {
374 let src = ConfigChangeSource::ProjectSettings;
375 let json = serde_json::to_string(&src).unwrap();
376 assert_eq!(json, "\"project_settings\"");
377 }
378
379 #[test]
380 fn all_event_names_covered() {
381 let inputs = vec![
383 HookInput::PreToolUse {
384 base: base(),
385 tool_name: "t".into(),
386 tool_input: serde_json::json!(null),
387 tool_use_id: "id".into(),
388 },
389 HookInput::PostToolUse {
390 base: base(),
391 tool_name: "t".into(),
392 tool_input: serde_json::json!(null),
393 tool_response: serde_json::json!(null),
394 tool_use_id: "id".into(),
395 },
396 HookInput::PostToolUseFailure {
397 base: base(),
398 tool_name: "t".into(),
399 tool_input: serde_json::json!(null),
400 tool_use_id: "id".into(),
401 error: "e".into(),
402 is_interrupt: None,
403 },
404 HookInput::Notification {
405 base: base(),
406 message: "m".into(),
407 title: None,
408 notification_type: "info".into(),
409 },
410 HookInput::UserPromptSubmit {
411 base: base(),
412 prompt: "p".into(),
413 },
414 HookInput::SessionStart {
415 base: base(),
416 source: SessionStartSource::Startup,
417 model: None,
418 },
419 HookInput::SessionEnd {
420 base: base(),
421 reason: "done".into(),
422 },
423 HookInput::Stop {
424 base: base(),
425 stop_hook_active: false,
426 last_assistant_message: None,
427 },
428 HookInput::SubagentStart {
429 base: base(),
430 agent_id: "a".into(),
431 agent_type: "general".into(),
432 },
433 HookInput::SubagentStop {
434 base: base(),
435 stop_hook_active: false,
436 agent_id: "a".into(),
437 agent_transcript_path: "/t".into(),
438 agent_type: "general".into(),
439 last_assistant_message: None,
440 },
441 HookInput::PreCompact {
442 base: base(),
443 trigger: CompactTriggerType::Auto,
444 custom_instructions: None,
445 },
446 HookInput::PermissionRequest {
447 base: base(),
448 tool_name: "t".into(),
449 tool_input: serde_json::json!(null),
450 permission_suggestions: None,
451 },
452 HookInput::Setup {
453 base: base(),
454 trigger: SetupTrigger::Init,
455 },
456 HookInput::TeammateIdle {
457 base: base(),
458 teammate_name: "n".into(),
459 team_name: "t".into(),
460 },
461 HookInput::TaskCompleted {
462 base: base(),
463 task_id: "1".into(),
464 task_subject: "s".into(),
465 task_description: None,
466 teammate_name: None,
467 team_name: None,
468 },
469 HookInput::ConfigChange {
470 base: base(),
471 source: ConfigChangeSource::Skills,
472 file_path: None,
473 },
474 HookInput::WorktreeCreate {
475 base: base(),
476 name: "wt".into(),
477 },
478 HookInput::WorktreeRemove {
479 base: base(),
480 worktree_path: "/wt".into(),
481 },
482 ];
483
484 for input in &inputs {
485 assert!(!input.event_name().is_empty());
486 assert!(!input.base().session_id.is_empty());
488 }
489 assert_eq!(inputs.len(), 18, "should cover all 18 HookInput variants");
490 }
491}