1use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub enum HookEvent {
16 #[serde(rename = "PreToolUse")]
18 PreToolUse,
19 #[serde(rename = "PostToolUse")]
21 PostToolUse,
22 #[serde(rename = "Notification")]
24 Notification,
25 #[serde(rename = "UserPromptSubmit")]
27 UserPromptSubmit,
28 #[serde(rename = "Stop")]
30 Stop,
31 #[serde(rename = "SubagentStop")]
33 SubagentStop,
34 #[serde(rename = "PreCompact")]
36 PreCompact,
37 #[serde(rename = "SessionStart")]
39 SessionStart,
40 #[serde(rename = "SessionEnd")]
42 SessionEnd,
43 #[serde(untagged)]
45 Other(String),
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct HookConfig {
51 pub events: Vec<HookEvent>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub matcher: Option<String>,
56 #[serde(rename = "type")]
58 pub hook_type: String,
59 pub command: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub timeout: Option<u32>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub description: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct HookCommand {
72 #[serde(rename = "type")]
74 pub hook_type: String,
75 pub command: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub timeout: Option<u32>,
80 #[serde(rename = "_agpm", skip_serializing_if = "Option::is_none")]
82 pub agpm_metadata: Option<AgpmHookMetadata>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct AgpmHookMetadata {
88 pub managed: bool,
90 pub dependency_name: String,
92 pub source: String,
94 pub version: String,
96 pub installed_at: String,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct MatcherGroup {
106 pub matcher: String,
108 pub hooks: Vec<HookCommand>,
110}
111
112pub fn load_hook_configs(hooks_dir: &Path) -> Result<HashMap<String, HookConfig>> {
151 let mut configs = HashMap::new();
152
153 if !hooks_dir.exists() {
154 return Ok(configs);
155 }
156
157 for entry in std::fs::read_dir(hooks_dir)? {
158 let entry = entry?;
159 let path = entry.path();
160
161 if path.extension().and_then(|s| s.to_str()) == Some("json") {
162 let name = path
163 .file_stem()
164 .and_then(|s| s.to_str())
165 .ok_or_else(|| anyhow::anyhow!("Invalid hook filename"))?
166 .to_string();
167
168 let content = std::fs::read_to_string(&path)
169 .with_context(|| format!("Failed to read hook file: {}", path.display()))?;
170
171 let config: HookConfig = serde_json::from_str(&content)
172 .with_context(|| format!("Failed to parse hook config: {}", path.display()))?;
173
174 configs.insert(name, config);
175 }
176 }
177
178 Ok(configs)
179}
180
181fn convert_to_claude_format(
186 hook_configs: HashMap<String, HookConfig>,
187) -> Result<serde_json::Value> {
188 use serde_json::{Map, Value, json};
189
190 let mut events_map: Map<String, Value> = Map::new();
191
192 for (_name, config) in hook_configs {
193 for event in &config.events {
194 let event_name = event_to_string(event);
195
196 let hook_obj = json!({
198 "type": config.hook_type,
199 "command": config.command,
200 "timeout": config.timeout
201 });
202
203 let event_array = events_map.entry(event_name).or_insert_with(|| json!([]));
205 let event_vec = event_array.as_array_mut().unwrap();
206
207 if let Some(ref matcher) = config.matcher {
208 let mut found_group = false;
211 for group in event_vec.iter_mut() {
212 if let Some(group_matcher) = group.get("matcher").and_then(|m| m.as_str())
213 && group_matcher == matcher
214 {
215 if let Some(hooks_array) =
217 group.get_mut("hooks").and_then(|h| h.as_array_mut())
218 {
219 hooks_array.push(hook_obj.clone());
220 found_group = true;
221 break;
222 }
223 }
224 }
225
226 if !found_group {
227 event_vec.push(json!({
229 "matcher": matcher,
230 "hooks": [hook_obj]
231 }));
232 }
233 } else {
234 if let Some(first_group) = event_vec.first_mut() {
236 if first_group.as_object().unwrap().contains_key("matcher") {
238 event_vec.push(json!({
240 "hooks": [hook_obj]
241 }));
242 } else if let Some(hooks_array) =
243 first_group.get_mut("hooks").and_then(|h| h.as_array_mut())
244 {
245 let hook_exists = hooks_array.iter().any(|existing_hook| {
247 existing_hook.get("command") == hook_obj.get("command")
248 && existing_hook.get("type") == hook_obj.get("type")
249 });
250 if !hook_exists {
251 hooks_array.push(hook_obj);
252 }
253 }
254 } else {
255 event_vec.push(json!({
257 "hooks": [hook_obj]
258 }));
259 }
260 }
261 }
262 }
263
264 Ok(Value::Object(events_map))
265}
266
267fn event_to_string(event: &HookEvent) -> String {
269 match event {
270 HookEvent::PreToolUse => "PreToolUse".to_string(),
271 HookEvent::PostToolUse => "PostToolUse".to_string(),
272 HookEvent::Notification => "Notification".to_string(),
273 HookEvent::UserPromptSubmit => "UserPromptSubmit".to_string(),
274 HookEvent::Stop => "Stop".to_string(),
275 HookEvent::SubagentStop => "SubagentStop".to_string(),
276 HookEvent::PreCompact => "PreCompact".to_string(),
277 HookEvent::SessionStart => "SessionStart".to_string(),
278 HookEvent::SessionEnd => "SessionEnd".to_string(),
279 HookEvent::Other(event_name) => event_name.clone(),
280 }
281}
282
283pub async fn install_hooks(
291 lockfile: &crate::lockfile::LockFile,
292 project_root: &Path,
293 cache: &crate::cache::Cache,
294) -> Result<()> {
295 if lockfile.hooks.is_empty() {
296 return Ok(());
297 }
298
299 let claude_dir = project_root.join(".claude");
300 let settings_path = claude_dir.join("settings.local.json");
301
302 crate::utils::fs::ensure_dir(&claude_dir)?;
304
305 let mut hook_configs = HashMap::new();
307
308 for entry in &lockfile.hooks {
309 let source_path = if let Some(source_name) = &entry.source {
311 let url = entry
312 .url
313 .as_ref()
314 .ok_or_else(|| anyhow::anyhow!("Hook {} has no URL", entry.name))?;
315
316 let is_local_source = entry.resolved_commit.as_deref().is_none_or(str::is_empty);
318
319 if is_local_source {
320 std::path::PathBuf::from(url).join(&entry.path)
322 } else {
323 let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
325 anyhow::anyhow!("Hook {} missing resolved commit SHA", entry.name)
326 })?;
327
328 let worktree = cache
329 .get_or_create_worktree_for_sha(source_name, url, sha, Some(&entry.name))
330 .await?;
331 worktree.join(&entry.path)
332 }
333 } else {
334 let candidate = Path::new(&entry.path);
336 if candidate.is_absolute() {
337 candidate.to_path_buf()
338 } else {
339 project_root.join(candidate)
340 }
341 };
342
343 let content = tokio::fs::read_to_string(&source_path)
345 .await
346 .with_context(|| format!("Failed to read hook file: {}", source_path.display()))?;
347
348 let config: HookConfig = serde_json::from_str(&content)
349 .with_context(|| format!("Failed to parse hook config: {}", source_path.display()))?;
350
351 hook_configs.insert(entry.name.clone(), config);
352 }
353
354 let mut settings = crate::mcp::ClaudeSettings::load_or_default(&settings_path)?;
356
357 let claude_hooks = convert_to_claude_format(hook_configs)?;
359
360 let hooks_changed = match &settings.hooks {
362 Some(existing_hooks) => existing_hooks != &claude_hooks,
363 None => claude_hooks.as_object().is_none_or(|obj| !obj.is_empty()),
364 };
365
366 if hooks_changed {
367 let configured_count = claude_hooks.as_object().map_or(0, |events| {
369 events
370 .values()
371 .filter_map(|event_groups| event_groups.as_array())
372 .map(|groups| {
373 groups
374 .iter()
375 .filter_map(|group| group.get("hooks")?.as_array())
376 .map(std::vec::Vec::len)
377 .sum::<usize>()
378 })
379 .sum::<usize>()
380 });
381
382 settings.hooks = Some(claude_hooks);
384
385 settings.save(&settings_path)?;
387
388 if configured_count > 0 {
389 println!("✓ Configured {configured_count} hook(s) in .claude/settings.local.json");
390 }
391 }
392
393 Ok(())
394}
395
396pub fn validate_hook_config(config: &HookConfig, script_path: &Path) -> Result<()> {
444 if config.events.is_empty() {
446 return Err(anyhow::anyhow!("Hook must specify at least one event"));
447 }
448
449 if let Some(ref matcher) = config.matcher {
451 regex::Regex::new(matcher)
452 .with_context(|| format!("Invalid regex pattern in matcher: {matcher}"))?;
453 }
454
455 if config.hook_type != "command" {
457 return Err(anyhow::anyhow!("Only 'command' hook type is currently supported"));
458 }
459
460 let script_full_path = if config.command.starts_with(".claude/scripts/") {
462 script_path
466 .parent() .and_then(|p| p.parent()) .map(|p| p.join(&config.command))
469 } else {
470 None
471 };
472
473 if let Some(path) = script_full_path
474 && !path.exists()
475 {
476 return Err(anyhow::anyhow!("Hook references non-existent script: {}", config.command));
477 }
478
479 Ok(())
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use std::fs;
486 use tempfile::tempdir;
487
488 #[test]
489 fn test_hook_event_serialization() {
490 let events = vec![
492 (HookEvent::PreToolUse, r#""PreToolUse""#),
493 (HookEvent::PostToolUse, r#""PostToolUse""#),
494 (HookEvent::Notification, r#""Notification""#),
495 (HookEvent::UserPromptSubmit, r#""UserPromptSubmit""#),
496 (HookEvent::Stop, r#""Stop""#),
497 (HookEvent::SubagentStop, r#""SubagentStop""#),
498 (HookEvent::PreCompact, r#""PreCompact""#),
499 (HookEvent::SessionStart, r#""SessionStart""#),
500 (HookEvent::SessionEnd, r#""SessionEnd""#),
501 (HookEvent::Other("CustomEvent".to_string()), r#""CustomEvent""#),
502 ];
503
504 for (event, expected) in events {
505 let json = serde_json::to_string(&event).unwrap();
506 assert_eq!(json, expected);
507 let parsed: HookEvent = serde_json::from_str(&json).unwrap();
508 assert_eq!(parsed, event);
509 }
510 }
511
512 #[test]
513 fn test_hook_config_serialization() {
514 let config = HookConfig {
515 events: vec![HookEvent::PreToolUse, HookEvent::PostToolUse],
516 matcher: Some("Bash|Write".to_string()),
517 hook_type: "command".to_string(),
518 command: ".claude/scripts/security-check.sh".to_string(),
519 timeout: Some(5000),
520 description: Some("Security validation".to_string()),
521 };
522
523 let json = serde_json::to_string_pretty(&config).unwrap();
524 let parsed: HookConfig = serde_json::from_str(&json).unwrap();
525
526 assert_eq!(parsed.events.len(), 2);
527 assert_eq!(parsed.matcher, Some("Bash|Write".to_string()));
528 assert_eq!(parsed.timeout, Some(5000));
529 assert_eq!(parsed.description, Some("Security validation".to_string()));
530 }
531
532 #[test]
533 fn test_hook_config_minimal() {
534 let config = HookConfig {
536 events: vec![HookEvent::UserPromptSubmit],
537 matcher: Some(".*".to_string()),
538 hook_type: "command".to_string(),
539 command: "echo 'test'".to_string(),
540 timeout: None,
541 description: None,
542 };
543
544 let json = serde_json::to_string(&config).unwrap();
545 assert!(!json.contains("timeout"));
546 assert!(!json.contains("description"));
547 }
548
549 #[test]
550 fn test_hook_command_serialization() {
551 let metadata = AgpmHookMetadata {
552 managed: true,
553 dependency_name: "test-hook".to_string(),
554 source: "community".to_string(),
555 version: "v1.0.0".to_string(),
556 installed_at: "2024-01-01T00:00:00Z".to_string(),
557 };
558
559 let command = HookCommand {
560 hook_type: "command".to_string(),
561 command: "test.sh".to_string(),
562 timeout: Some(3000),
563 agpm_metadata: Some(metadata.clone()),
564 };
565
566 let json = serde_json::to_string(&command).unwrap();
567 let parsed: HookCommand = serde_json::from_str(&json).unwrap();
568
569 assert_eq!(parsed.hook_type, "command");
570 assert_eq!(parsed.command, "test.sh");
571 assert_eq!(parsed.timeout, Some(3000));
572 assert!(parsed.agpm_metadata.is_some());
573 let meta = parsed.agpm_metadata.unwrap();
574 assert!(meta.managed);
575 assert_eq!(meta.dependency_name, "test-hook");
576 }
577
578 #[test]
579 fn test_matcher_group_serialization() {
580 let command = HookCommand {
581 hook_type: "command".to_string(),
582 command: "test.sh".to_string(),
583 timeout: None,
584 agpm_metadata: None,
585 };
586
587 let group = MatcherGroup {
588 matcher: "Bash.*".to_string(),
589 hooks: vec![command.clone(), command.clone()],
590 };
591
592 let json = serde_json::to_string(&group).unwrap();
593 let parsed: MatcherGroup = serde_json::from_str(&json).unwrap();
594
595 assert_eq!(parsed.matcher, "Bash.*");
596 assert_eq!(parsed.hooks.len(), 2);
597 }
598
599 #[test]
600 fn test_load_hook_configs() {
601 let temp = tempdir().unwrap();
602 let hooks_dir = temp.path().join("hooks");
603 std::fs::create_dir(&hooks_dir).unwrap();
604
605 let config1 = HookConfig {
607 events: vec![HookEvent::PreToolUse],
608 matcher: Some(".*".to_string()),
609 hook_type: "command".to_string(),
610 command: "test1.sh".to_string(),
611 timeout: None,
612 description: None,
613 };
614
615 let config2 = HookConfig {
616 events: vec![HookEvent::PostToolUse],
617 matcher: Some("Write".to_string()),
618 hook_type: "command".to_string(),
619 command: "test2.sh".to_string(),
620 timeout: Some(1000),
621 description: Some("Test hook 2".to_string()),
622 };
623
624 fs::write(hooks_dir.join("test-hook1.json"), serde_json::to_string(&config1).unwrap())
625 .unwrap();
626 fs::write(hooks_dir.join("test-hook2.json"), serde_json::to_string(&config2).unwrap())
627 .unwrap();
628
629 fs::write(hooks_dir.join("readme.txt"), "This is not a hook").unwrap();
631
632 let configs = load_hook_configs(&hooks_dir).unwrap();
633 assert_eq!(configs.len(), 2);
634 assert!(configs.contains_key("test-hook1"));
635 assert!(configs.contains_key("test-hook2"));
636
637 let hook1 = &configs["test-hook1"];
638 assert_eq!(hook1.events.len(), 1);
639 assert_eq!(hook1.command, "test1.sh");
640
641 let hook2 = &configs["test-hook2"];
642 assert_eq!(hook2.timeout, Some(1000));
643 }
644
645 #[test]
646 fn test_load_hook_configs_empty_dir() {
647 let temp = tempdir().unwrap();
648 let hooks_dir = temp.path().join("empty_hooks");
649 let configs = load_hook_configs(&hooks_dir).unwrap();
652 assert_eq!(configs.len(), 0);
653 }
654
655 #[test]
656 fn test_load_hook_configs_invalid_json() {
657 let temp = tempdir().unwrap();
658 let hooks_dir = temp.path().join("hooks");
659 fs::create_dir(&hooks_dir).unwrap();
660
661 fs::write(hooks_dir.join("invalid.json"), "{ not valid json").unwrap();
663
664 let result = load_hook_configs(&hooks_dir);
665 assert!(result.is_err());
666 assert!(result.unwrap_err().to_string().contains("Failed to parse hook config"));
667 }
668
669 #[test]
670 fn test_validate_hook_config_empty_events() {
671 let temp = tempdir().unwrap();
672
673 let config = HookConfig {
674 events: vec![], matcher: Some(".*".to_string()),
676 hook_type: "command".to_string(),
677 command: "test.sh".to_string(),
678 timeout: None,
679 description: None,
680 };
681
682 let result = validate_hook_config(&config, temp.path());
683 assert!(result.is_err());
684 assert!(result.unwrap_err().to_string().contains("at least one event"));
685 }
686
687 #[test]
688 fn test_validate_hook_config_invalid_regex() {
689 let temp = tempdir().unwrap();
690
691 let config = HookConfig {
692 events: vec![HookEvent::PreToolUse],
693 matcher: Some("[invalid regex".to_string()), hook_type: "command".to_string(),
695 command: "test.sh".to_string(),
696 timeout: None,
697 description: None,
698 };
699
700 let result = validate_hook_config(&config, temp.path());
701 assert!(result.is_err());
702 assert!(result.unwrap_err().to_string().contains("Invalid regex pattern"));
703 }
704
705 #[test]
706 fn test_validate_hook_config_unsupported_type() {
707 let temp = tempdir().unwrap();
708
709 let config = HookConfig {
710 events: vec![HookEvent::PreToolUse],
711 matcher: Some(".*".to_string()),
712 hook_type: "webhook".to_string(), command: "test.sh".to_string(),
714 timeout: None,
715 description: None,
716 };
717
718 let result = validate_hook_config(&config, temp.path());
719 assert!(result.is_err());
720 assert!(result.unwrap_err().to_string().contains("Only 'command' hook type"));
721 }
722
723 #[test]
724 fn test_validate_hook_config_script_exists() {
725 let temp = tempdir().unwrap();
726
727 let claude_dir = temp.path().join(".claude");
730 let scripts_dir = claude_dir.join("scripts");
731 fs::create_dir_all(&scripts_dir).unwrap();
732
733 let script_path = scripts_dir.join("test.sh");
734 fs::write(&script_path, "#!/bin/bash\necho test").unwrap();
735
736 let config = HookConfig {
737 events: vec![HookEvent::PreToolUse],
738 matcher: Some(".*".to_string()),
739 hook_type: "command".to_string(),
740 command: ".claude/scripts/test.sh".to_string(),
741 timeout: None,
742 description: None,
743 };
744
745 let settings_path = temp.path().join(".claude").join("settings.local.json");
748 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
749 let result = validate_hook_config(&config, &settings_path);
750
751 assert!(result.is_ok(), "Expected validation to succeed, but got: {:?}", result);
753 }
754
755 #[test]
756 fn test_validate_hook_config_script_not_exists() {
757 let temp = tempdir().unwrap();
758
759 let config = HookConfig {
760 events: vec![HookEvent::PreToolUse],
761 matcher: Some(".*".to_string()),
762 hook_type: "command".to_string(),
763 command: ".claude/scripts/nonexistent.sh".to_string(),
764 timeout: None,
765 description: None,
766 };
767
768 let hook_path = temp.path().join(".claude").join("agpm").join("hooks").join("test.json");
770 let result = validate_hook_config(&config, &hook_path);
771 assert!(result.is_err());
772 assert!(result.unwrap_err().to_string().contains("non-existent script"));
773 }
774
775 #[test]
776 fn test_validate_hook_config_non_claude_path() {
777 let temp = tempdir().unwrap();
778
779 let config = HookConfig {
781 events: vec![HookEvent::PreToolUse],
782 matcher: Some(".*".to_string()),
783 hook_type: "command".to_string(),
784 command: "/usr/bin/echo".to_string(), timeout: None,
786 description: None,
787 };
788
789 let result = validate_hook_config(&config, temp.path());
790 assert!(result.is_ok());
792 }
793
794 #[test]
795 fn test_convert_to_claude_format_session_start() {
796 let mut hook_configs = HashMap::new();
798 hook_configs.insert(
799 "session-hook".to_string(),
800 HookConfig {
801 events: vec![HookEvent::SessionStart],
802 matcher: None, hook_type: "command".to_string(),
804 command: "echo 'session started'".to_string(),
805 timeout: Some(1000),
806 description: Some("Session start hook".to_string()),
807 },
808 );
809
810 let result = convert_to_claude_format(hook_configs).unwrap();
811 let expected = serde_json::json!({
812 "SessionStart": [
813 {
814 "hooks": [
815 {
816 "type": "command",
817 "command": "echo 'session started'",
818 "timeout": 1000
819 }
820 ]
821 }
822 ]
823 });
824
825 assert_eq!(result, expected);
826 }
827
828 #[test]
829 fn test_convert_to_claude_format_with_matcher() {
830 let mut hook_configs = HashMap::new();
832 hook_configs.insert(
833 "tool-hook".to_string(),
834 HookConfig {
835 events: vec![HookEvent::PreToolUse],
836 matcher: Some("Bash|Write".to_string()),
837 hook_type: "command".to_string(),
838 command: "echo 'before tool use'".to_string(),
839 timeout: None,
840 description: None,
841 },
842 );
843
844 let result = convert_to_claude_format(hook_configs).unwrap();
845 let expected = serde_json::json!({
846 "PreToolUse": [
847 {
848 "matcher": "Bash|Write",
849 "hooks": [
850 {
851 "type": "command",
852 "command": "echo 'before tool use'",
853 "timeout": null
854 }
855 ]
856 }
857 ]
858 });
859
860 assert_eq!(result, expected);
861 }
862
863 #[test]
864 fn test_convert_to_claude_format_multiple_events() {
865 let mut hook_configs = HashMap::new();
867 hook_configs.insert(
868 "multi-event-hook".to_string(),
869 HookConfig {
870 events: vec![HookEvent::PreToolUse, HookEvent::PostToolUse],
871 matcher: Some(".*".to_string()),
872 hook_type: "command".to_string(),
873 command: "echo 'tool event'".to_string(),
874 timeout: Some(5000),
875 description: None,
876 },
877 );
878
879 let result = convert_to_claude_format(hook_configs).unwrap();
880
881 assert!(result.get("PreToolUse").is_some());
883 assert!(result.get("PostToolUse").is_some());
884
885 let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
886 let post_tool = result.get("PostToolUse").unwrap().as_array().unwrap();
887
888 assert_eq!(pre_tool.len(), 1);
889 assert_eq!(post_tool.len(), 1);
890
891 assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*");
893 assert_eq!(post_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*");
894 }
895
896 #[test]
897 fn test_convert_to_claude_format_deduplication() {
898 let mut hook_configs = HashMap::new();
900
901 hook_configs.insert(
903 "hook1".to_string(),
904 HookConfig {
905 events: vec![HookEvent::SessionStart],
906 matcher: None,
907 hook_type: "command".to_string(),
908 command: "agpm update".to_string(),
909 timeout: None,
910 description: None,
911 },
912 );
913 hook_configs.insert(
914 "hook2".to_string(),
915 HookConfig {
916 events: vec![HookEvent::SessionStart],
917 matcher: None,
918 hook_type: "command".to_string(),
919 command: "agpm update".to_string(), timeout: None,
921 description: None,
922 },
923 );
924
925 let result = convert_to_claude_format(hook_configs).unwrap();
926 let session_start = result.get("SessionStart").unwrap().as_array().unwrap();
927
928 assert_eq!(session_start.len(), 1);
930
931 let hooks = session_start[0].get("hooks").unwrap().as_array().unwrap();
933 assert_eq!(hooks.len(), 1);
934 assert_eq!(hooks[0].get("command").unwrap().as_str().unwrap(), "agpm update");
935 }
936
937 #[test]
938 fn test_convert_to_claude_format_different_matchers() {
939 let mut hook_configs = HashMap::new();
941
942 hook_configs.insert(
943 "bash-hook".to_string(),
944 HookConfig {
945 events: vec![HookEvent::PreToolUse],
946 matcher: Some("Bash".to_string()),
947 hook_type: "command".to_string(),
948 command: "echo 'bash tool'".to_string(),
949 timeout: None,
950 description: None,
951 },
952 );
953 hook_configs.insert(
954 "write-hook".to_string(),
955 HookConfig {
956 events: vec![HookEvent::PreToolUse],
957 matcher: Some("Write".to_string()),
958 hook_type: "command".to_string(),
959 command: "echo 'write tool'".to_string(),
960 timeout: None,
961 description: None,
962 },
963 );
964
965 let result = convert_to_claude_format(hook_configs).unwrap();
966 let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
967
968 assert_eq!(pre_tool.len(), 2);
970
971 let bash_group = pre_tool
973 .iter()
974 .find(|g| g.get("matcher").and_then(|m| m.as_str()) == Some("Bash"))
975 .unwrap();
976 let write_group = pre_tool
977 .iter()
978 .find(|g| g.get("matcher").and_then(|m| m.as_str()) == Some("Write"))
979 .unwrap();
980
981 assert!(bash_group.get("hooks").unwrap().as_array().unwrap().len() == 1);
982 assert!(write_group.get("hooks").unwrap().as_array().unwrap().len() == 1);
983 }
984
985 #[test]
986 fn test_convert_to_claude_format_same_matcher() {
987 let mut hook_configs = HashMap::new();
989
990 hook_configs.insert(
991 "hook1".to_string(),
992 HookConfig {
993 events: vec![HookEvent::PreToolUse],
994 matcher: Some("Bash".to_string()),
995 hook_type: "command".to_string(),
996 command: "echo 'first'".to_string(),
997 timeout: None,
998 description: None,
999 },
1000 );
1001 hook_configs.insert(
1002 "hook2".to_string(),
1003 HookConfig {
1004 events: vec![HookEvent::PreToolUse],
1005 matcher: Some("Bash".to_string()), hook_type: "command".to_string(),
1007 command: "echo 'second'".to_string(),
1008 timeout: None,
1009 description: None,
1010 },
1011 );
1012
1013 let result = convert_to_claude_format(hook_configs).unwrap();
1014 let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
1015
1016 assert_eq!(pre_tool.len(), 1);
1018
1019 let hooks = pre_tool[0].get("hooks").unwrap().as_array().unwrap();
1021 assert_eq!(hooks.len(), 2);
1022 assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), "Bash");
1023 }
1024
1025 #[test]
1026 fn test_convert_to_claude_format_empty() {
1027 let hook_configs = HashMap::new();
1029 let result = convert_to_claude_format(hook_configs).unwrap();
1030
1031 assert_eq!(result.as_object().unwrap().len(), 0);
1032 }
1033
1034 #[test]
1035 fn test_convert_to_claude_format_other_event() {
1036 let mut hook_configs = HashMap::new();
1038 hook_configs.insert(
1039 "future-hook".to_string(),
1040 HookConfig {
1041 events: vec![HookEvent::Other("FutureEvent".to_string())],
1042 matcher: None,
1043 hook_type: "command".to_string(),
1044 command: "echo 'future event'".to_string(),
1045 timeout: None,
1046 description: None,
1047 },
1048 );
1049
1050 let result = convert_to_claude_format(hook_configs).unwrap();
1051 let expected = serde_json::json!({
1052 "FutureEvent": [
1053 {
1054 "hooks": [
1055 {
1056 "type": "command",
1057 "command": "echo 'future event'",
1058 "timeout": null
1059 }
1060 ]
1061 }
1062 ]
1063 });
1064
1065 assert_eq!(result, expected);
1066 }
1067
1068 #[test]
1069 fn test_hook_event_other_serialization() {
1070 let other_event = HookEvent::Other("CustomEvent".to_string());
1072 let json = serde_json::to_string(&other_event).unwrap();
1073 assert_eq!(json, r#""CustomEvent""#);
1074
1075 let parsed: HookEvent = serde_json::from_str(&json).unwrap();
1076 if let HookEvent::Other(event_name) = parsed {
1077 assert_eq!(event_name, "CustomEvent");
1078 } else {
1079 panic!("Expected Other variant");
1080 }
1081 }
1082}