1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Deserialize)]
9pub struct HookInput {
10 pub tool_name: String,
12 pub tool_input: ToolInput,
14 #[serde(default)]
16 pub session_id: Option<String>,
17}
18
19#[derive(Debug, Clone, Deserialize)]
21pub struct ToolInput {
22 pub command: String,
24 #[serde(default)]
26 pub description: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize)]
31#[serde(untagged)]
32pub enum HookOutput {
33 Allow(AllowOutput),
35 AllowWithModifiedCommand(AllowWithModifiedCommandOutput),
39 Deny(DenyOutput),
41}
42
43#[derive(Debug, Clone, Default, Serialize)]
45pub struct AllowOutput {}
46
47#[derive(Debug, Clone, Serialize)]
51pub struct AllowWithModifiedCommandOutput {
52 #[serde(rename = "hookSpecificOutput")]
53 pub hook_specific_output: AllowWithModifiedHookSpecificOutput,
54}
55
56#[derive(Debug, Clone, Serialize)]
58pub struct AllowWithModifiedHookSpecificOutput {
59 #[serde(rename = "hookEventName")]
60 pub hook_event_name: String,
61 #[serde(rename = "permissionDecision")]
62 pub permission_decision: String,
63 #[serde(rename = "updatedInput")]
64 pub updated_input: UpdatedInput,
65}
66
67#[derive(Debug, Clone, Serialize)]
69pub struct UpdatedInput {
70 pub command: String,
72}
73
74#[derive(Debug, Clone, Serialize)]
76pub struct DenyOutput {
77 #[serde(rename = "hookSpecificOutput")]
78 pub hook_specific_output: HookSpecificOutput,
79}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct HookSpecificOutput {
83 #[serde(rename = "hookEventName")]
84 pub hook_event_name: String,
85 #[serde(rename = "permissionDecision")]
86 pub permission_decision: String,
87 #[serde(rename = "permissionDecisionReason")]
88 pub permission_decision_reason: String,
89}
90
91impl HookOutput {
92 pub fn allow() -> Self {
94 Self::Allow(AllowOutput {})
95 }
96
97 pub fn allow_with_modified_command(replacement_command: impl Into<String>) -> Self {
106 Self::AllowWithModifiedCommand(AllowWithModifiedCommandOutput {
107 hook_specific_output: AllowWithModifiedHookSpecificOutput {
108 hook_event_name: "PreToolUse".to_string(),
109 permission_decision: "allow".to_string(),
110 updated_input: UpdatedInput {
111 command: replacement_command.into(),
112 },
113 },
114 })
115 }
116
117 pub fn deny(reason: impl Into<String>) -> Self {
119 Self::Deny(DenyOutput {
120 hook_specific_output: HookSpecificOutput {
121 hook_event_name: "PreToolUse".to_string(),
122 permission_decision: "deny".to_string(),
123 permission_decision_reason: reason.into(),
124 },
125 })
126 }
127
128 pub fn is_allow(&self) -> bool {
130 matches!(self, Self::Allow(_) | Self::AllowWithModifiedCommand(_))
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn test_parse_hook_input() {
140 let json = r#"{
141 "tool_name": "Bash",
142 "tool_input": {
143 "command": "cargo build --release",
144 "description": "Build the project"
145 },
146 "session_id": "abc123"
147 }"#;
148
149 let input: HookInput = serde_json::from_str(json).unwrap();
150 assert_eq!(input.tool_name, "Bash");
151 assert_eq!(input.tool_input.command, "cargo build --release");
152 assert_eq!(input.session_id, Some("abc123".to_string()));
153 }
154
155 #[test]
156 fn test_allow_output() {
157 let output = HookOutput::allow();
158 let json = serde_json::to_string(&output).unwrap();
159 assert_eq!(json, "{}");
160 }
161
162 #[test]
163 fn test_deny_output() {
164 let output = HookOutput::deny("Remote execution failed");
165 let json = serde_json::to_string(&output).unwrap();
166 assert!(json.contains("permissionDecision"));
167 assert!(json.contains("deny"));
168 }
169
170 #[test]
171 fn test_parse_hook_input_minimal() {
172 let json = r#"{
174 "tool_name": "Bash",
175 "tool_input": {
176 "command": "ls -la"
177 }
178 }"#;
179
180 let input: HookInput = serde_json::from_str(json).unwrap();
181 assert_eq!(input.tool_name, "Bash");
182 assert_eq!(input.tool_input.command, "ls -la");
183 assert!(input.tool_input.description.is_none());
184 assert!(input.session_id.is_none());
185 }
186
187 #[test]
188 fn test_parse_hook_input_with_empty_description() {
189 let json = r#"{
190 "tool_name": "Read",
191 "tool_input": {
192 "command": "cat file.txt",
193 "description": ""
194 }
195 }"#;
196
197 let input: HookInput = serde_json::from_str(json).unwrap();
198 assert_eq!(input.tool_name, "Read");
199 assert_eq!(input.tool_input.description, Some("".to_string()));
200 }
201
202 #[test]
203 fn test_hook_output_is_allow_true() {
204 let output = HookOutput::allow();
205 assert!(output.is_allow());
206 }
207
208 #[test]
209 fn test_hook_output_is_allow_false_for_deny() {
210 let output = HookOutput::deny("blocked");
211 assert!(!output.is_allow());
212 }
213
214 #[test]
215 fn test_deny_output_preserves_reason() {
216 let reason = "Command not allowed: security violation";
217 let output = HookOutput::deny(reason);
218
219 if let HookOutput::Deny(deny) = output {
220 assert_eq!(deny.hook_specific_output.permission_decision_reason, reason);
221 assert_eq!(deny.hook_specific_output.permission_decision, "deny");
222 assert_eq!(deny.hook_specific_output.hook_event_name, "PreToolUse");
223 } else {
224 panic!("Expected Deny variant");
225 }
226 }
227
228 #[test]
229 fn test_deny_output_with_empty_reason() {
230 let output = HookOutput::deny("");
231 if let HookOutput::Deny(deny) = output {
232 assert_eq!(deny.hook_specific_output.permission_decision_reason, "");
233 } else {
234 panic!("Expected Deny variant");
235 }
236 }
237
238 #[test]
239 fn test_allow_output_default() {
240 let output = AllowOutput::default();
241 let json = serde_json::to_string(&output).unwrap();
242 assert_eq!(json, "{}");
243 }
244
245 #[test]
246 fn test_deny_output_json_structure() {
247 let output = HookOutput::deny("test reason");
248 let json = serde_json::to_string(&output).unwrap();
249
250 assert!(json.contains("hookSpecificOutput"));
252 assert!(json.contains("hookEventName"));
253 assert!(json.contains("PreToolUse"));
254 assert!(json.contains("permissionDecision"));
255 assert!(json.contains("\"deny\""));
256 assert!(json.contains("permissionDecisionReason"));
257 assert!(json.contains("test reason"));
258 }
259
260 #[test]
261 fn test_hook_input_clone() {
262 let original = HookInput {
263 tool_name: "Bash".to_string(),
264 tool_input: ToolInput {
265 command: "cargo test".to_string(),
266 description: Some("Run tests".to_string()),
267 },
268 session_id: Some("session-123".to_string()),
269 };
270
271 let cloned = original.clone();
272 assert_eq!(original.tool_name, cloned.tool_name);
273 assert_eq!(original.tool_input.command, cloned.tool_input.command);
274 assert_eq!(original.session_id, cloned.session_id);
275 }
276
277 #[test]
278 fn test_tool_input_clone() {
279 let original = ToolInput {
280 command: "make build".to_string(),
281 description: None,
282 };
283
284 let cloned = original.clone();
285 assert_eq!(original.command, cloned.command);
286 assert_eq!(original.description, cloned.description);
287 }
288
289 #[test]
290 fn test_hook_output_clone_allow() {
291 let original = HookOutput::allow();
292 let cloned = original.clone();
293 assert!(cloned.is_allow());
294 }
295
296 #[test]
297 fn test_hook_output_clone_deny() {
298 let original = HookOutput::deny("cloned reason");
299 let cloned = original.clone();
300 assert!(!cloned.is_allow());
301 }
302
303 #[test]
304 fn test_deny_output_from_string() {
305 let output = HookOutput::deny(String::from("owned reason"));
307 if let HookOutput::Deny(deny) = output {
308 assert_eq!(
309 deny.hook_specific_output.permission_decision_reason,
310 "owned reason"
311 );
312 } else {
313 panic!("Expected Deny variant");
314 }
315 }
316
317 #[test]
318 fn test_parse_hook_input_different_tools() {
319 let tools = ["Bash", "Read", "Write", "Edit", "Glob", "Grep"];
320
321 for tool in tools {
322 let json = format!(
323 r#"{{"tool_name": "{}", "tool_input": {{"command": "test"}}}}"#,
324 tool
325 );
326 let input: HookInput = serde_json::from_str(&json).unwrap();
327 assert_eq!(input.tool_name, tool);
328 }
329 }
330
331 #[test]
332 fn test_parse_hook_input_unicode_command() {
333 let json = r#"{
334 "tool_name": "Bash",
335 "tool_input": {
336 "command": "echo '日本語 测试 émojis 🦀'"
337 }
338 }"#;
339
340 let input: HookInput = serde_json::from_str(json).unwrap();
341 assert!(input.tool_input.command.contains("日本語"));
342 assert!(input.tool_input.command.contains("🦀"));
343 }
344
345 #[test]
346 fn test_parse_hook_input_special_characters() {
347 let json = r#"{
348 "tool_name": "Bash",
349 "tool_input": {
350 "command": "echo \"hello\\nworld\" | grep 'pattern'"
351 }
352 }"#;
353
354 let input: HookInput = serde_json::from_str(json).unwrap();
355 assert!(input.tool_input.command.contains("echo"));
356 assert!(input.tool_input.command.contains("grep"));
357 }
358
359 #[test]
360 fn test_hook_specific_output_debug() {
361 let output = HookSpecificOutput {
362 hook_event_name: "PreToolUse".to_string(),
363 permission_decision: "deny".to_string(),
364 permission_decision_reason: "test".to_string(),
365 };
366
367 let debug_str = format!("{:?}", output);
369 assert!(debug_str.contains("HookSpecificOutput"));
370 assert!(debug_str.contains("PreToolUse"));
371 }
372
373 #[test]
374 fn test_deny_output_debug() {
375 let output = DenyOutput {
376 hook_specific_output: HookSpecificOutput {
377 hook_event_name: "PreToolUse".to_string(),
378 permission_decision: "deny".to_string(),
379 permission_decision_reason: "test".to_string(),
380 },
381 };
382
383 let debug_str = format!("{:?}", output);
384 assert!(debug_str.contains("DenyOutput"));
385 }
386
387 #[test]
388 fn test_allow_output_debug() {
389 let output = AllowOutput {};
390 let debug_str = format!("{:?}", output);
391 assert!(debug_str.contains("AllowOutput"));
392 }
393
394 #[test]
397 fn test_allow_with_modified_command_serializes() {
398 let output = HookOutput::allow_with_modified_command("true");
399 let json = serde_json::to_string(&output).unwrap();
400
401 assert!(json.contains("hookSpecificOutput"));
403 assert!(json.contains("hookEventName"));
404 assert!(json.contains("PreToolUse"));
405 assert!(json.contains("permissionDecision"));
406 assert!(json.contains("\"allow\""));
407 assert!(json.contains("updatedInput"));
408 assert!(json.contains("\"command\""));
409 assert!(json.contains("\"true\""));
410 }
411
412 #[test]
413 fn test_allow_with_modified_command_is_allow() {
414 let output = HookOutput::allow_with_modified_command("true");
415 assert!(output.is_allow());
416 }
417
418 #[test]
419 fn test_allow_with_modified_command_preserves_replacement() {
420 let output = HookOutput::allow_with_modified_command("exit 101");
421 if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
422 assert_eq!(
423 allow_mod.hook_specific_output.updated_input.command,
424 "exit 101"
425 );
426 assert_eq!(allow_mod.hook_specific_output.permission_decision, "allow");
427 } else {
428 panic!("Expected AllowWithModifiedCommand variant");
429 }
430 }
431
432 #[test]
433 fn test_allow_with_modified_command_from_string() {
434 let output = HookOutput::allow_with_modified_command(String::from("echo done"));
435 if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
436 assert_eq!(
437 allow_mod.hook_specific_output.updated_input.command,
438 "echo done"
439 );
440 } else {
441 panic!("Expected AllowWithModifiedCommand variant");
442 }
443 }
444
445 #[test]
446 fn test_allow_with_modified_command_clone() {
447 let original = HookOutput::allow_with_modified_command("true");
448 let cloned = original.clone();
449 assert!(cloned.is_allow());
450 if let HookOutput::AllowWithModifiedCommand(allow_mod) = cloned {
451 assert_eq!(allow_mod.hook_specific_output.updated_input.command, "true");
452 } else {
453 panic!("Expected AllowWithModifiedCommand variant");
454 }
455 }
456
457 #[test]
458 fn test_allow_with_modified_command_json_structure() {
459 let output = HookOutput::allow_with_modified_command("true");
461 let json = serde_json::to_string(&output).unwrap();
462 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
463
464 assert!(parsed.get("hookSpecificOutput").is_some());
465 let hook_output = parsed.get("hookSpecificOutput").unwrap();
466 assert_eq!(hook_output.get("hookEventName").unwrap(), "PreToolUse");
467 assert_eq!(hook_output.get("permissionDecision").unwrap(), "allow");
468 assert!(hook_output.get("updatedInput").is_some());
469 let updated_input = hook_output.get("updatedInput").unwrap();
470 assert_eq!(updated_input.get("command").unwrap(), "true");
471 }
472}