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 pub fn to_hook_json(
141 &self,
142 original_tool_input: Option<&serde_json::Value>,
143 ) -> serde_json::Result<String> {
144 let mut value = serde_json::to_value(self)?;
145 if let (Self::AllowWithModifiedCommand(_), Some(serde_json::Value::Object(original))) =
146 (self, original_tool_input)
147 && let Some(updated) = value
148 .pointer_mut("/hookSpecificOutput/updatedInput")
149 .and_then(serde_json::Value::as_object_mut)
150 {
151 for (key, field) in original {
152 updated.entry(key.clone()).or_insert_with(|| field.clone());
153 }
154 }
155 serde_json::to_string(&value)
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn test_parse_hook_input() {
165 let json = r#"{
166 "tool_name": "Bash",
167 "tool_input": {
168 "command": "cargo build --release",
169 "description": "Build the project"
170 },
171 "session_id": "abc123"
172 }"#;
173
174 let input: HookInput = serde_json::from_str(json).unwrap();
175 assert_eq!(input.tool_name, "Bash");
176 assert_eq!(input.tool_input.command, "cargo build --release");
177 assert_eq!(input.session_id, Some("abc123".to_string()));
178 }
179
180 #[test]
181 fn test_allow_output() {
182 let output = HookOutput::allow();
183 let json = serde_json::to_string(&output).unwrap();
184 assert_eq!(json, "{}");
185 }
186
187 #[test]
188 fn test_deny_output() {
189 let output = HookOutput::deny("Remote execution failed");
190 let json = serde_json::to_string(&output).unwrap();
191 assert!(json.contains("permissionDecision"));
192 assert!(json.contains("deny"));
193 }
194
195 #[test]
196 fn test_parse_hook_input_minimal() {
197 let json = r#"{
199 "tool_name": "Bash",
200 "tool_input": {
201 "command": "ls -la"
202 }
203 }"#;
204
205 let input: HookInput = serde_json::from_str(json).unwrap();
206 assert_eq!(input.tool_name, "Bash");
207 assert_eq!(input.tool_input.command, "ls -la");
208 assert!(input.tool_input.description.is_none());
209 assert!(input.session_id.is_none());
210 }
211
212 #[test]
213 fn test_parse_hook_input_with_empty_description() {
214 let json = r#"{
215 "tool_name": "Read",
216 "tool_input": {
217 "command": "cat file.txt",
218 "description": ""
219 }
220 }"#;
221
222 let input: HookInput = serde_json::from_str(json).unwrap();
223 assert_eq!(input.tool_name, "Read");
224 assert_eq!(input.tool_input.description, Some("".to_string()));
225 }
226
227 #[test]
228 fn test_hook_output_is_allow_true() {
229 let output = HookOutput::allow();
230 assert!(output.is_allow());
231 }
232
233 #[test]
234 fn test_hook_output_is_allow_false_for_deny() {
235 let output = HookOutput::deny("blocked");
236 assert!(!output.is_allow());
237 }
238
239 #[test]
240 fn test_deny_output_preserves_reason() {
241 let reason = "Command not allowed: security violation";
242 let output = HookOutput::deny(reason);
243
244 if let HookOutput::Deny(deny) = output {
245 assert_eq!(deny.hook_specific_output.permission_decision_reason, reason);
246 assert_eq!(deny.hook_specific_output.permission_decision, "deny");
247 assert_eq!(deny.hook_specific_output.hook_event_name, "PreToolUse");
248 } else {
249 panic!("Expected Deny variant");
250 }
251 }
252
253 #[test]
254 fn test_deny_output_with_empty_reason() {
255 let output = HookOutput::deny("");
256 if let HookOutput::Deny(deny) = output {
257 assert_eq!(deny.hook_specific_output.permission_decision_reason, "");
258 } else {
259 panic!("Expected Deny variant");
260 }
261 }
262
263 #[test]
264 fn test_allow_output_default() {
265 let output = AllowOutput::default();
266 let json = serde_json::to_string(&output).unwrap();
267 assert_eq!(json, "{}");
268 }
269
270 #[test]
271 fn test_deny_output_json_structure() {
272 let output = HookOutput::deny("test reason");
273 let json = serde_json::to_string(&output).unwrap();
274
275 assert!(json.contains("hookSpecificOutput"));
277 assert!(json.contains("hookEventName"));
278 assert!(json.contains("PreToolUse"));
279 assert!(json.contains("permissionDecision"));
280 assert!(json.contains("\"deny\""));
281 assert!(json.contains("permissionDecisionReason"));
282 assert!(json.contains("test reason"));
283 }
284
285 #[test]
286 fn test_hook_input_clone() {
287 let original = HookInput {
288 tool_name: "Bash".to_string(),
289 tool_input: ToolInput {
290 command: "cargo test".to_string(),
291 description: Some("Run tests".to_string()),
292 },
293 session_id: Some("session-123".to_string()),
294 };
295
296 let cloned = original.clone();
297 assert_eq!(original.tool_name, cloned.tool_name);
298 assert_eq!(original.tool_input.command, cloned.tool_input.command);
299 assert_eq!(original.session_id, cloned.session_id);
300 }
301
302 #[test]
303 fn test_tool_input_clone() {
304 let original = ToolInput {
305 command: "make build".to_string(),
306 description: None,
307 };
308
309 let cloned = original.clone();
310 assert_eq!(original.command, cloned.command);
311 assert_eq!(original.description, cloned.description);
312 }
313
314 #[test]
315 fn test_hook_output_clone_allow() {
316 let original = HookOutput::allow();
317 let cloned = original.clone();
318 assert!(cloned.is_allow());
319 }
320
321 #[test]
322 fn test_hook_output_clone_deny() {
323 let original = HookOutput::deny("cloned reason");
324 let cloned = original.clone();
325 assert!(!cloned.is_allow());
326 }
327
328 #[test]
329 fn test_deny_output_from_string() {
330 let output = HookOutput::deny(String::from("owned reason"));
332 if let HookOutput::Deny(deny) = output {
333 assert_eq!(
334 deny.hook_specific_output.permission_decision_reason,
335 "owned reason"
336 );
337 } else {
338 panic!("Expected Deny variant");
339 }
340 }
341
342 #[test]
343 fn test_parse_hook_input_different_tools() {
344 let tools = ["Bash", "Read", "Write", "Edit", "Glob", "Grep"];
345
346 for tool in tools {
347 let json = format!(
348 r#"{{"tool_name": "{}", "tool_input": {{"command": "test"}}}}"#,
349 tool
350 );
351 let input: HookInput = serde_json::from_str(&json).unwrap();
352 assert_eq!(input.tool_name, tool);
353 }
354 }
355
356 #[test]
357 fn test_parse_hook_input_unicode_command() {
358 let json = r#"{
359 "tool_name": "Bash",
360 "tool_input": {
361 "command": "echo '日本語 测试 émojis 🦀'"
362 }
363 }"#;
364
365 let input: HookInput = serde_json::from_str(json).unwrap();
366 assert!(input.tool_input.command.contains("日本語"));
367 assert!(input.tool_input.command.contains("🦀"));
368 }
369
370 #[test]
371 fn test_parse_hook_input_special_characters() {
372 let json = r#"{
373 "tool_name": "Bash",
374 "tool_input": {
375 "command": "echo \"hello\\nworld\" | grep 'pattern'"
376 }
377 }"#;
378
379 let input: HookInput = serde_json::from_str(json).unwrap();
380 assert!(input.tool_input.command.contains("echo"));
381 assert!(input.tool_input.command.contains("grep"));
382 }
383
384 #[test]
385 fn test_hook_specific_output_debug() {
386 let output = HookSpecificOutput {
387 hook_event_name: "PreToolUse".to_string(),
388 permission_decision: "deny".to_string(),
389 permission_decision_reason: "test".to_string(),
390 };
391
392 let debug_str = format!("{:?}", output);
394 assert!(debug_str.contains("HookSpecificOutput"));
395 assert!(debug_str.contains("PreToolUse"));
396 }
397
398 #[test]
399 fn test_deny_output_debug() {
400 let output = DenyOutput {
401 hook_specific_output: HookSpecificOutput {
402 hook_event_name: "PreToolUse".to_string(),
403 permission_decision: "deny".to_string(),
404 permission_decision_reason: "test".to_string(),
405 },
406 };
407
408 let debug_str = format!("{:?}", output);
409 assert!(debug_str.contains("DenyOutput"));
410 }
411
412 #[test]
413 fn test_allow_output_debug() {
414 let output = AllowOutput {};
415 let debug_str = format!("{:?}", output);
416 assert!(debug_str.contains("AllowOutput"));
417 }
418
419 #[test]
422 fn test_allow_with_modified_command_serializes() {
423 let output = HookOutput::allow_with_modified_command("true");
424 let json = serde_json::to_string(&output).unwrap();
425
426 assert!(json.contains("hookSpecificOutput"));
428 assert!(json.contains("hookEventName"));
429 assert!(json.contains("PreToolUse"));
430 assert!(json.contains("permissionDecision"));
431 assert!(json.contains("\"allow\""));
432 assert!(json.contains("updatedInput"));
433 assert!(json.contains("\"command\""));
434 assert!(json.contains("\"true\""));
435 }
436
437 #[test]
438 fn test_allow_with_modified_command_is_allow() {
439 let output = HookOutput::allow_with_modified_command("true");
440 assert!(output.is_allow());
441 }
442
443 #[test]
444 fn test_allow_with_modified_command_preserves_replacement() {
445 let output = HookOutput::allow_with_modified_command("exit 101");
446 if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
447 assert_eq!(
448 allow_mod.hook_specific_output.updated_input.command,
449 "exit 101"
450 );
451 assert_eq!(allow_mod.hook_specific_output.permission_decision, "allow");
452 } else {
453 panic!("Expected AllowWithModifiedCommand variant");
454 }
455 }
456
457 #[test]
458 fn test_allow_with_modified_command_from_string() {
459 let output = HookOutput::allow_with_modified_command(String::from("echo done"));
460 if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
461 assert_eq!(
462 allow_mod.hook_specific_output.updated_input.command,
463 "echo done"
464 );
465 } else {
466 panic!("Expected AllowWithModifiedCommand variant");
467 }
468 }
469
470 #[test]
471 fn test_allow_with_modified_command_clone() {
472 let original = HookOutput::allow_with_modified_command("true");
473 let cloned = original.clone();
474 assert!(cloned.is_allow());
475 if let HookOutput::AllowWithModifiedCommand(allow_mod) = cloned {
476 assert_eq!(allow_mod.hook_specific_output.updated_input.command, "true");
477 } else {
478 panic!("Expected AllowWithModifiedCommand variant");
479 }
480 }
481
482 #[test]
483 fn test_allow_with_modified_command_json_structure() {
484 let output = HookOutput::allow_with_modified_command("true");
486 let json = serde_json::to_string(&output).unwrap();
487 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
488
489 assert!(parsed.get("hookSpecificOutput").is_some());
490 let hook_output = parsed.get("hookSpecificOutput").unwrap();
491 assert_eq!(hook_output.get("hookEventName").unwrap(), "PreToolUse");
492 assert_eq!(hook_output.get("permissionDecision").unwrap(), "allow");
493 assert!(hook_output.get("updatedInput").is_some());
494 let updated_input = hook_output.get("updatedInput").unwrap();
495 assert_eq!(updated_input.get("command").unwrap(), "true");
496 }
497
498 #[test]
499 fn test_to_hook_json_carries_original_tool_input_fields() {
500 let original = serde_json::json!({
501 "command": "cargo test",
502 "timeout": 600_000,
503 "run_in_background": true,
504 "description": "Run the suite",
505 });
506 let output = HookOutput::allow_with_modified_command("rch exec -- cargo test");
507 let json = output.to_hook_json(Some(&original)).unwrap();
508 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
509 let updated = &parsed["hookSpecificOutput"]["updatedInput"];
510 assert_eq!(updated["command"], "rch exec -- cargo test");
511 assert_eq!(updated["timeout"], 600_000);
512 assert_eq!(updated["run_in_background"], true);
513 assert_eq!(updated["description"], "Run the suite");
514 }
515
516 #[test]
517 fn test_to_hook_json_leaves_deny_and_missing_input_untouched() {
518 let original = serde_json::json!({"command": "x", "timeout": 5});
519 let deny = HookOutput::deny("no");
520 assert_eq!(
521 deny.to_hook_json(Some(&original)).unwrap(),
522 serde_json::to_string(&deny).unwrap()
523 );
524 let modified = HookOutput::allow_with_modified_command("true");
525 assert_eq!(
526 modified.to_hook_json(None).unwrap(),
527 serde_json::to_string(&modified).unwrap()
528 );
529 }
530}