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", skip_serializing_if = "Option::is_none")]
64 pub permission_decision: Option<String>,
65 #[serde(rename = "updatedInput")]
66 pub updated_input: UpdatedInput,
67}
68
69#[derive(Debug, Clone, Serialize)]
71pub struct UpdatedInput {
72 pub command: String,
74}
75
76#[derive(Debug, Clone, Serialize)]
78pub struct DenyOutput {
79 #[serde(rename = "hookSpecificOutput")]
80 pub hook_specific_output: HookSpecificOutput,
81}
82
83#[derive(Debug, Clone, Serialize)]
84pub struct HookSpecificOutput {
85 #[serde(rename = "hookEventName")]
86 pub hook_event_name: String,
87 #[serde(rename = "permissionDecision")]
88 pub permission_decision: String,
89 #[serde(rename = "permissionDecisionReason")]
90 pub permission_decision_reason: String,
91}
92
93impl HookOutput {
94 pub fn allow() -> Self {
96 Self::Allow(AllowOutput {})
97 }
98
99 pub fn allow_with_modified_command(replacement_command: impl Into<String>) -> Self {
108 Self::AllowWithModifiedCommand(AllowWithModifiedCommandOutput {
109 hook_specific_output: AllowWithModifiedHookSpecificOutput {
110 hook_event_name: "PreToolUse".to_string(),
111 permission_decision: Some("allow".to_string()),
112 updated_input: UpdatedInput {
113 command: replacement_command.into(),
114 },
115 },
116 })
117 }
118
119 pub fn rewrite_with_permission_check(replacement_command: impl Into<String>) -> Self {
126 Self::AllowWithModifiedCommand(AllowWithModifiedCommandOutput {
127 hook_specific_output: AllowWithModifiedHookSpecificOutput {
128 hook_event_name: "PreToolUse".to_string(),
129 permission_decision: None,
130 updated_input: UpdatedInput {
131 command: replacement_command.into(),
132 },
133 },
134 })
135 }
136
137 pub fn deny(reason: impl Into<String>) -> Self {
139 Self::Deny(DenyOutput {
140 hook_specific_output: HookSpecificOutput {
141 hook_event_name: "PreToolUse".to_string(),
142 permission_decision: "deny".to_string(),
143 permission_decision_reason: reason.into(),
144 },
145 })
146 }
147
148 pub fn is_allow(&self) -> bool {
150 matches!(self, Self::Allow(_) | Self::AllowWithModifiedCommand(_))
151 }
152
153 pub fn to_hook_json(
161 &self,
162 original_tool_input: Option<&serde_json::Value>,
163 ) -> serde_json::Result<String> {
164 let mut value = serde_json::to_value(self)?;
165 if let (Self::AllowWithModifiedCommand(_), Some(serde_json::Value::Object(original))) =
166 (self, original_tool_input)
167 && let Some(updated) = value
168 .pointer_mut("/hookSpecificOutput/updatedInput")
169 .and_then(serde_json::Value::as_object_mut)
170 {
171 for (key, field) in original {
172 updated.entry(key.clone()).or_insert_with(|| field.clone());
173 }
174 }
175 serde_json::to_string(&value)
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn test_parse_hook_input() {
185 let json = r#"{
186 "tool_name": "Bash",
187 "tool_input": {
188 "command": "cargo build --release",
189 "description": "Build the project"
190 },
191 "session_id": "abc123"
192 }"#;
193
194 let input: HookInput = serde_json::from_str(json).unwrap();
195 assert_eq!(input.tool_name, "Bash");
196 assert_eq!(input.tool_input.command, "cargo build --release");
197 assert_eq!(input.session_id, Some("abc123".to_string()));
198 }
199
200 #[test]
201 fn test_allow_output() {
202 let output = HookOutput::allow();
203 let json = serde_json::to_string(&output).unwrap();
204 assert_eq!(json, "{}");
205 }
206
207 #[test]
208 fn test_deny_output() {
209 let output = HookOutput::deny("Remote execution failed");
210 let json = serde_json::to_string(&output).unwrap();
211 assert!(json.contains("permissionDecision"));
212 assert!(json.contains("deny"));
213 }
214
215 #[test]
216 fn test_parse_hook_input_minimal() {
217 let json = r#"{
219 "tool_name": "Bash",
220 "tool_input": {
221 "command": "ls -la"
222 }
223 }"#;
224
225 let input: HookInput = serde_json::from_str(json).unwrap();
226 assert_eq!(input.tool_name, "Bash");
227 assert_eq!(input.tool_input.command, "ls -la");
228 assert!(input.tool_input.description.is_none());
229 assert!(input.session_id.is_none());
230 }
231
232 #[test]
233 fn test_parse_hook_input_with_empty_description() {
234 let json = r#"{
235 "tool_name": "Read",
236 "tool_input": {
237 "command": "cat file.txt",
238 "description": ""
239 }
240 }"#;
241
242 let input: HookInput = serde_json::from_str(json).unwrap();
243 assert_eq!(input.tool_name, "Read");
244 assert_eq!(input.tool_input.description, Some("".to_string()));
245 }
246
247 #[test]
248 fn test_hook_output_is_allow_true() {
249 let output = HookOutput::allow();
250 assert!(output.is_allow());
251 }
252
253 #[test]
254 fn test_hook_output_is_allow_false_for_deny() {
255 let output = HookOutput::deny("blocked");
256 assert!(!output.is_allow());
257 }
258
259 #[test]
260 fn test_deny_output_preserves_reason() {
261 let reason = "Command not allowed: security violation";
262 let output = HookOutput::deny(reason);
263
264 if let HookOutput::Deny(deny) = output {
265 assert_eq!(deny.hook_specific_output.permission_decision_reason, reason);
266 assert_eq!(deny.hook_specific_output.permission_decision, "deny");
267 assert_eq!(deny.hook_specific_output.hook_event_name, "PreToolUse");
268 } else {
269 panic!("Expected Deny variant");
270 }
271 }
272
273 #[test]
274 fn test_deny_output_with_empty_reason() {
275 let output = HookOutput::deny("");
276 if let HookOutput::Deny(deny) = output {
277 assert_eq!(deny.hook_specific_output.permission_decision_reason, "");
278 } else {
279 panic!("Expected Deny variant");
280 }
281 }
282
283 #[test]
284 fn test_allow_output_default() {
285 let output = AllowOutput::default();
286 let json = serde_json::to_string(&output).unwrap();
287 assert_eq!(json, "{}");
288 }
289
290 #[test]
291 fn test_deny_output_json_structure() {
292 let output = HookOutput::deny("test reason");
293 let json = serde_json::to_string(&output).unwrap();
294
295 assert!(json.contains("hookSpecificOutput"));
297 assert!(json.contains("hookEventName"));
298 assert!(json.contains("PreToolUse"));
299 assert!(json.contains("permissionDecision"));
300 assert!(json.contains("\"deny\""));
301 assert!(json.contains("permissionDecisionReason"));
302 assert!(json.contains("test reason"));
303 }
304
305 #[test]
306 fn test_hook_input_clone() {
307 let original = HookInput {
308 tool_name: "Bash".to_string(),
309 tool_input: ToolInput {
310 command: "cargo test".to_string(),
311 description: Some("Run tests".to_string()),
312 },
313 session_id: Some("session-123".to_string()),
314 };
315
316 let cloned = original.clone();
317 assert_eq!(original.tool_name, cloned.tool_name);
318 assert_eq!(original.tool_input.command, cloned.tool_input.command);
319 assert_eq!(original.session_id, cloned.session_id);
320 }
321
322 #[test]
323 fn test_tool_input_clone() {
324 let original = ToolInput {
325 command: "make build".to_string(),
326 description: None,
327 };
328
329 let cloned = original.clone();
330 assert_eq!(original.command, cloned.command);
331 assert_eq!(original.description, cloned.description);
332 }
333
334 #[test]
335 fn test_hook_output_clone_allow() {
336 let original = HookOutput::allow();
337 let cloned = original.clone();
338 assert!(cloned.is_allow());
339 }
340
341 #[test]
342 fn test_hook_output_clone_deny() {
343 let original = HookOutput::deny("cloned reason");
344 let cloned = original.clone();
345 assert!(!cloned.is_allow());
346 }
347
348 #[test]
349 fn test_deny_output_from_string() {
350 let output = HookOutput::deny(String::from("owned reason"));
352 if let HookOutput::Deny(deny) = output {
353 assert_eq!(
354 deny.hook_specific_output.permission_decision_reason,
355 "owned reason"
356 );
357 } else {
358 panic!("Expected Deny variant");
359 }
360 }
361
362 #[test]
363 fn test_parse_hook_input_different_tools() {
364 let tools = ["Bash", "Read", "Write", "Edit", "Glob", "Grep"];
365
366 for tool in tools {
367 let json = format!(
368 r#"{{"tool_name": "{}", "tool_input": {{"command": "test"}}}}"#,
369 tool
370 );
371 let input: HookInput = serde_json::from_str(&json).unwrap();
372 assert_eq!(input.tool_name, tool);
373 }
374 }
375
376 #[test]
377 fn test_parse_hook_input_unicode_command() {
378 let json = r#"{
379 "tool_name": "Bash",
380 "tool_input": {
381 "command": "echo '日本語 测试 émojis 🦀'"
382 }
383 }"#;
384
385 let input: HookInput = serde_json::from_str(json).unwrap();
386 assert!(input.tool_input.command.contains("日本語"));
387 assert!(input.tool_input.command.contains("🦀"));
388 }
389
390 #[test]
391 fn test_parse_hook_input_special_characters() {
392 let json = r#"{
393 "tool_name": "Bash",
394 "tool_input": {
395 "command": "echo \"hello\\nworld\" | grep 'pattern'"
396 }
397 }"#;
398
399 let input: HookInput = serde_json::from_str(json).unwrap();
400 assert!(input.tool_input.command.contains("echo"));
401 assert!(input.tool_input.command.contains("grep"));
402 }
403
404 #[test]
405 fn test_hook_specific_output_debug() {
406 let output = HookSpecificOutput {
407 hook_event_name: "PreToolUse".to_string(),
408 permission_decision: "deny".to_string(),
409 permission_decision_reason: "test".to_string(),
410 };
411
412 let debug_str = format!("{:?}", output);
414 assert!(debug_str.contains("HookSpecificOutput"));
415 assert!(debug_str.contains("PreToolUse"));
416 }
417
418 #[test]
419 fn test_deny_output_debug() {
420 let output = DenyOutput {
421 hook_specific_output: HookSpecificOutput {
422 hook_event_name: "PreToolUse".to_string(),
423 permission_decision: "deny".to_string(),
424 permission_decision_reason: "test".to_string(),
425 },
426 };
427
428 let debug_str = format!("{:?}", output);
429 assert!(debug_str.contains("DenyOutput"));
430 }
431
432 #[test]
433 fn test_allow_output_debug() {
434 let output = AllowOutput {};
435 let debug_str = format!("{:?}", output);
436 assert!(debug_str.contains("AllowOutput"));
437 }
438
439 #[test]
442 fn test_allow_with_modified_command_serializes() {
443 let output = HookOutput::allow_with_modified_command("true");
444 let json = serde_json::to_string(&output).unwrap();
445
446 assert!(json.contains("hookSpecificOutput"));
448 assert!(json.contains("hookEventName"));
449 assert!(json.contains("PreToolUse"));
450 assert!(json.contains("permissionDecision"));
451 assert!(json.contains("\"allow\""));
452 assert!(json.contains("updatedInput"));
453 assert!(json.contains("\"command\""));
454 assert!(json.contains("\"true\""));
455 }
456
457 #[test]
458 fn test_allow_with_modified_command_is_allow() {
459 let output = HookOutput::allow_with_modified_command("true");
460 assert!(output.is_allow());
461 }
462
463 #[test]
464 fn test_allow_with_modified_command_preserves_replacement() {
465 let output = HookOutput::allow_with_modified_command("exit 101");
466 if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
467 assert_eq!(
468 allow_mod.hook_specific_output.updated_input.command,
469 "exit 101"
470 );
471 assert_eq!(
472 allow_mod
473 .hook_specific_output
474 .permission_decision
475 .as_deref(),
476 Some("allow")
477 );
478 } else {
479 panic!("Expected AllowWithModifiedCommand variant");
480 }
481 }
482
483 #[test]
484 fn rewrite_with_permission_check_omits_the_decision() {
485 let json = HookOutput::rewrite_with_permission_check("cd x && rch exec -- cargo build")
486 .to_hook_json(None)
487 .unwrap();
488 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
489 let output = &value["hookSpecificOutput"];
490 assert!(output.get("permissionDecision").is_none(), "{json}");
491 assert_eq!(
492 output["updatedInput"]["command"],
493 "cd x && rch exec -- cargo build"
494 );
495 let allow = HookOutput::allow_with_modified_command("true")
496 .to_hook_json(None)
497 .unwrap();
498 assert!(
499 allow.contains("\"permissionDecision\":\"allow\""),
500 "{allow}"
501 );
502 }
503
504 #[test]
505 fn test_allow_with_modified_command_from_string() {
506 let output = HookOutput::allow_with_modified_command(String::from("echo done"));
507 if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
508 assert_eq!(
509 allow_mod.hook_specific_output.updated_input.command,
510 "echo done"
511 );
512 } else {
513 panic!("Expected AllowWithModifiedCommand variant");
514 }
515 }
516
517 #[test]
518 fn test_allow_with_modified_command_clone() {
519 let original = HookOutput::allow_with_modified_command("true");
520 let cloned = original.clone();
521 assert!(cloned.is_allow());
522 if let HookOutput::AllowWithModifiedCommand(allow_mod) = cloned {
523 assert_eq!(allow_mod.hook_specific_output.updated_input.command, "true");
524 } else {
525 panic!("Expected AllowWithModifiedCommand variant");
526 }
527 }
528
529 #[test]
530 fn test_allow_with_modified_command_json_structure() {
531 let output = HookOutput::allow_with_modified_command("true");
533 let json = serde_json::to_string(&output).unwrap();
534 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
535
536 assert!(parsed.get("hookSpecificOutput").is_some());
537 let hook_output = parsed.get("hookSpecificOutput").unwrap();
538 assert_eq!(hook_output.get("hookEventName").unwrap(), "PreToolUse");
539 assert_eq!(hook_output.get("permissionDecision").unwrap(), "allow");
540 assert!(hook_output.get("updatedInput").is_some());
541 let updated_input = hook_output.get("updatedInput").unwrap();
542 assert_eq!(updated_input.get("command").unwrap(), "true");
543 }
544
545 #[test]
546 fn test_to_hook_json_carries_original_tool_input_fields() {
547 let original = serde_json::json!({
548 "command": "cargo test",
549 "timeout": 600_000,
550 "run_in_background": true,
551 "description": "Run the suite",
552 });
553 let output = HookOutput::allow_with_modified_command("rch exec -- cargo test");
554 let json = output.to_hook_json(Some(&original)).unwrap();
555 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
556 let updated = &parsed["hookSpecificOutput"]["updatedInput"];
557 assert_eq!(updated["command"], "rch exec -- cargo test");
558 assert_eq!(updated["timeout"], 600_000);
559 assert_eq!(updated["run_in_background"], true);
560 assert_eq!(updated["description"], "Run the suite");
561 }
562
563 #[test]
564 fn test_to_hook_json_leaves_deny_and_missing_input_untouched() {
565 let original = serde_json::json!({"command": "x", "timeout": 5});
566 let deny = HookOutput::deny("no");
567 assert_eq!(
568 deny.to_hook_json(Some(&original)).unwrap(),
569 serde_json::to_string(&deny).unwrap()
570 );
571 let modified = HookOutput::allow_with_modified_command("true");
572 assert_eq!(
573 modified.to_hook_json(None).unwrap(),
574 serde_json::to_string(&modified).unwrap()
575 );
576 }
577}