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/agpm/scripts/") {
462 script_path
466 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent()) .and_then(|p| p.parent()) .map(|p| p.join(&config.command))
471 } else {
472 None
473 };
474
475 if let Some(path) = script_full_path
476 && !path.exists()
477 {
478 return Err(anyhow::anyhow!("Hook references non-existent script: {}", config.command));
479 }
480
481 Ok(())
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use std::fs;
488 use tempfile::tempdir;
489
490 #[test]
491 fn test_hook_event_serialization() {
492 let events = vec![
494 (HookEvent::PreToolUse, r#""PreToolUse""#),
495 (HookEvent::PostToolUse, r#""PostToolUse""#),
496 (HookEvent::Notification, r#""Notification""#),
497 (HookEvent::UserPromptSubmit, r#""UserPromptSubmit""#),
498 (HookEvent::Stop, r#""Stop""#),
499 (HookEvent::SubagentStop, r#""SubagentStop""#),
500 (HookEvent::PreCompact, r#""PreCompact""#),
501 (HookEvent::SessionStart, r#""SessionStart""#),
502 (HookEvent::SessionEnd, r#""SessionEnd""#),
503 (HookEvent::Other("CustomEvent".to_string()), r#""CustomEvent""#),
504 ];
505
506 for (event, expected) in events {
507 let json = serde_json::to_string(&event).unwrap();
508 assert_eq!(json, expected);
509 let parsed: HookEvent = serde_json::from_str(&json).unwrap();
510 assert_eq!(parsed, event);
511 }
512 }
513
514 #[test]
515 fn test_hook_config_serialization() {
516 let config = HookConfig {
517 events: vec![HookEvent::PreToolUse, HookEvent::PostToolUse],
518 matcher: Some("Bash|Write".to_string()),
519 hook_type: "command".to_string(),
520 command: ".claude/agpm/scripts/security-check.sh".to_string(),
521 timeout: Some(5000),
522 description: Some("Security validation".to_string()),
523 };
524
525 let json = serde_json::to_string_pretty(&config).unwrap();
526 let parsed: HookConfig = serde_json::from_str(&json).unwrap();
527
528 assert_eq!(parsed.events.len(), 2);
529 assert_eq!(parsed.matcher, Some("Bash|Write".to_string()));
530 assert_eq!(parsed.timeout, Some(5000));
531 assert_eq!(parsed.description, Some("Security validation".to_string()));
532 }
533
534 #[test]
535 fn test_hook_config_minimal() {
536 let config = HookConfig {
538 events: vec![HookEvent::UserPromptSubmit],
539 matcher: Some(".*".to_string()),
540 hook_type: "command".to_string(),
541 command: "echo 'test'".to_string(),
542 timeout: None,
543 description: None,
544 };
545
546 let json = serde_json::to_string(&config).unwrap();
547 assert!(!json.contains("timeout"));
548 assert!(!json.contains("description"));
549 }
550
551 #[test]
552 fn test_hook_command_serialization() {
553 let metadata = AgpmHookMetadata {
554 managed: true,
555 dependency_name: "test-hook".to_string(),
556 source: "community".to_string(),
557 version: "v1.0.0".to_string(),
558 installed_at: "2024-01-01T00:00:00Z".to_string(),
559 };
560
561 let command = HookCommand {
562 hook_type: "command".to_string(),
563 command: "test.sh".to_string(),
564 timeout: Some(3000),
565 agpm_metadata: Some(metadata.clone()),
566 };
567
568 let json = serde_json::to_string(&command).unwrap();
569 let parsed: HookCommand = serde_json::from_str(&json).unwrap();
570
571 assert_eq!(parsed.hook_type, "command");
572 assert_eq!(parsed.command, "test.sh");
573 assert_eq!(parsed.timeout, Some(3000));
574 assert!(parsed.agpm_metadata.is_some());
575 let meta = parsed.agpm_metadata.unwrap();
576 assert!(meta.managed);
577 assert_eq!(meta.dependency_name, "test-hook");
578 }
579
580 #[test]
581 fn test_matcher_group_serialization() {
582 let command = HookCommand {
583 hook_type: "command".to_string(),
584 command: "test.sh".to_string(),
585 timeout: None,
586 agpm_metadata: None,
587 };
588
589 let group = MatcherGroup {
590 matcher: "Bash.*".to_string(),
591 hooks: vec![command.clone(), command.clone()],
592 };
593
594 let json = serde_json::to_string(&group).unwrap();
595 let parsed: MatcherGroup = serde_json::from_str(&json).unwrap();
596
597 assert_eq!(parsed.matcher, "Bash.*");
598 assert_eq!(parsed.hooks.len(), 2);
599 }
600
601 #[test]
602 fn test_load_hook_configs() {
603 let temp = tempdir().unwrap();
604 let hooks_dir = temp.path().join("hooks");
605 std::fs::create_dir(&hooks_dir).unwrap();
606
607 let config1 = HookConfig {
609 events: vec![HookEvent::PreToolUse],
610 matcher: Some(".*".to_string()),
611 hook_type: "command".to_string(),
612 command: "test1.sh".to_string(),
613 timeout: None,
614 description: None,
615 };
616
617 let config2 = HookConfig {
618 events: vec![HookEvent::PostToolUse],
619 matcher: Some("Write".to_string()),
620 hook_type: "command".to_string(),
621 command: "test2.sh".to_string(),
622 timeout: Some(1000),
623 description: Some("Test hook 2".to_string()),
624 };
625
626 fs::write(hooks_dir.join("test-hook1.json"), serde_json::to_string(&config1).unwrap())
627 .unwrap();
628 fs::write(hooks_dir.join("test-hook2.json"), serde_json::to_string(&config2).unwrap())
629 .unwrap();
630
631 fs::write(hooks_dir.join("readme.txt"), "This is not a hook").unwrap();
633
634 let configs = load_hook_configs(&hooks_dir).unwrap();
635 assert_eq!(configs.len(), 2);
636 assert!(configs.contains_key("test-hook1"));
637 assert!(configs.contains_key("test-hook2"));
638
639 let hook1 = &configs["test-hook1"];
640 assert_eq!(hook1.events.len(), 1);
641 assert_eq!(hook1.command, "test1.sh");
642
643 let hook2 = &configs["test-hook2"];
644 assert_eq!(hook2.timeout, Some(1000));
645 }
646
647 #[test]
648 fn test_load_hook_configs_empty_dir() {
649 let temp = tempdir().unwrap();
650 let hooks_dir = temp.path().join("empty_hooks");
651 let configs = load_hook_configs(&hooks_dir).unwrap();
654 assert_eq!(configs.len(), 0);
655 }
656
657 #[test]
658 fn test_load_hook_configs_invalid_json() {
659 let temp = tempdir().unwrap();
660 let hooks_dir = temp.path().join("hooks");
661 fs::create_dir(&hooks_dir).unwrap();
662
663 fs::write(hooks_dir.join("invalid.json"), "{ not valid json").unwrap();
665
666 let result = load_hook_configs(&hooks_dir);
667 assert!(result.is_err());
668 assert!(result.unwrap_err().to_string().contains("Failed to parse hook config"));
669 }
670
671 #[test]
672 fn test_validate_hook_config_empty_events() {
673 let temp = tempdir().unwrap();
674
675 let config = HookConfig {
676 events: vec![], matcher: Some(".*".to_string()),
678 hook_type: "command".to_string(),
679 command: "test.sh".to_string(),
680 timeout: None,
681 description: None,
682 };
683
684 let result = validate_hook_config(&config, temp.path());
685 assert!(result.is_err());
686 assert!(result.unwrap_err().to_string().contains("at least one event"));
687 }
688
689 #[test]
690 fn test_validate_hook_config_invalid_regex() {
691 let temp = tempdir().unwrap();
692
693 let config = HookConfig {
694 events: vec![HookEvent::PreToolUse],
695 matcher: Some("[invalid regex".to_string()), hook_type: "command".to_string(),
697 command: "test.sh".to_string(),
698 timeout: None,
699 description: None,
700 };
701
702 let result = validate_hook_config(&config, temp.path());
703 assert!(result.is_err());
704 assert!(result.unwrap_err().to_string().contains("Invalid regex pattern"));
705 }
706
707 #[test]
708 fn test_validate_hook_config_unsupported_type() {
709 let temp = tempdir().unwrap();
710
711 let config = HookConfig {
712 events: vec![HookEvent::PreToolUse],
713 matcher: Some(".*".to_string()),
714 hook_type: "webhook".to_string(), command: "test.sh".to_string(),
716 timeout: None,
717 description: None,
718 };
719
720 let result = validate_hook_config(&config, temp.path());
721 assert!(result.is_err());
722 assert!(result.unwrap_err().to_string().contains("Only 'command' hook type"));
723 }
724
725 #[test]
726 fn test_validate_hook_config_script_exists() {
727 let temp = tempdir().unwrap();
728
729 let claude_dir = temp.path().join(".claude").join("agpm");
731 let scripts_dir = claude_dir.join("scripts");
732 let hooks_dir = claude_dir.join("hooks");
733 fs::create_dir_all(&scripts_dir).unwrap();
734 fs::create_dir_all(&hooks_dir).unwrap();
735
736 let script_path = scripts_dir.join("test.sh");
737 fs::write(&script_path, "#!/bin/bash\necho test").unwrap();
738
739 let config = HookConfig {
740 events: vec![HookEvent::PreToolUse],
741 matcher: Some(".*".to_string()),
742 hook_type: "command".to_string(),
743 command: ".claude/agpm/scripts/test.sh".to_string(),
744 timeout: None,
745 description: None,
746 };
747
748 let hook_json_path = hooks_dir.join("test.json");
751 let result = validate_hook_config(&config, &hook_json_path);
752
753 assert!(result.is_ok(), "Expected validation to succeed, but got: {:?}", result);
755 }
756
757 #[test]
758 fn test_validate_hook_config_script_not_exists() {
759 let temp = tempdir().unwrap();
760
761 let config = HookConfig {
762 events: vec![HookEvent::PreToolUse],
763 matcher: Some(".*".to_string()),
764 hook_type: "command".to_string(),
765 command: ".claude/agpm/scripts/nonexistent.sh".to_string(),
766 timeout: None,
767 description: None,
768 };
769
770 let hook_path = temp.path().join(".claude").join("agpm").join("hooks").join("test.json");
772 let result = validate_hook_config(&config, &hook_path);
773 assert!(result.is_err());
774 assert!(result.unwrap_err().to_string().contains("non-existent script"));
775 }
776
777 #[test]
778 fn test_validate_hook_config_non_claude_path() {
779 let temp = tempdir().unwrap();
780
781 let config = HookConfig {
783 events: vec![HookEvent::PreToolUse],
784 matcher: Some(".*".to_string()),
785 hook_type: "command".to_string(),
786 command: "/usr/bin/echo".to_string(), timeout: None,
788 description: None,
789 };
790
791 let result = validate_hook_config(&config, temp.path());
792 assert!(result.is_ok());
794 }
795
796 #[test]
797 fn test_convert_to_claude_format_session_start() {
798 let mut hook_configs = HashMap::new();
800 hook_configs.insert(
801 "session-hook".to_string(),
802 HookConfig {
803 events: vec![HookEvent::SessionStart],
804 matcher: None, hook_type: "command".to_string(),
806 command: "echo 'session started'".to_string(),
807 timeout: Some(1000),
808 description: Some("Session start hook".to_string()),
809 },
810 );
811
812 let result = convert_to_claude_format(hook_configs).unwrap();
813 let expected = serde_json::json!({
814 "SessionStart": [
815 {
816 "hooks": [
817 {
818 "type": "command",
819 "command": "echo 'session started'",
820 "timeout": 1000
821 }
822 ]
823 }
824 ]
825 });
826
827 assert_eq!(result, expected);
828 }
829
830 #[test]
831 fn test_convert_to_claude_format_with_matcher() {
832 let mut hook_configs = HashMap::new();
834 hook_configs.insert(
835 "tool-hook".to_string(),
836 HookConfig {
837 events: vec![HookEvent::PreToolUse],
838 matcher: Some("Bash|Write".to_string()),
839 hook_type: "command".to_string(),
840 command: "echo 'before tool use'".to_string(),
841 timeout: None,
842 description: None,
843 },
844 );
845
846 let result = convert_to_claude_format(hook_configs).unwrap();
847 let expected = serde_json::json!({
848 "PreToolUse": [
849 {
850 "matcher": "Bash|Write",
851 "hooks": [
852 {
853 "type": "command",
854 "command": "echo 'before tool use'",
855 "timeout": null
856 }
857 ]
858 }
859 ]
860 });
861
862 assert_eq!(result, expected);
863 }
864
865 #[test]
866 fn test_convert_to_claude_format_multiple_events() {
867 let mut hook_configs = HashMap::new();
869 hook_configs.insert(
870 "multi-event-hook".to_string(),
871 HookConfig {
872 events: vec![HookEvent::PreToolUse, HookEvent::PostToolUse],
873 matcher: Some(".*".to_string()),
874 hook_type: "command".to_string(),
875 command: "echo 'tool event'".to_string(),
876 timeout: Some(5000),
877 description: None,
878 },
879 );
880
881 let result = convert_to_claude_format(hook_configs).unwrap();
882
883 assert!(result.get("PreToolUse").is_some());
885 assert!(result.get("PostToolUse").is_some());
886
887 let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
888 let post_tool = result.get("PostToolUse").unwrap().as_array().unwrap();
889
890 assert_eq!(pre_tool.len(), 1);
891 assert_eq!(post_tool.len(), 1);
892
893 assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*");
895 assert_eq!(post_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*");
896 }
897
898 #[test]
899 fn test_convert_to_claude_format_deduplication() {
900 let mut hook_configs = HashMap::new();
902
903 hook_configs.insert(
905 "hook1".to_string(),
906 HookConfig {
907 events: vec![HookEvent::SessionStart],
908 matcher: None,
909 hook_type: "command".to_string(),
910 command: "agpm update".to_string(),
911 timeout: None,
912 description: None,
913 },
914 );
915 hook_configs.insert(
916 "hook2".to_string(),
917 HookConfig {
918 events: vec![HookEvent::SessionStart],
919 matcher: None,
920 hook_type: "command".to_string(),
921 command: "agpm update".to_string(), timeout: None,
923 description: None,
924 },
925 );
926
927 let result = convert_to_claude_format(hook_configs).unwrap();
928 let session_start = result.get("SessionStart").unwrap().as_array().unwrap();
929
930 assert_eq!(session_start.len(), 1);
932
933 let hooks = session_start[0].get("hooks").unwrap().as_array().unwrap();
935 assert_eq!(hooks.len(), 1);
936 assert_eq!(hooks[0].get("command").unwrap().as_str().unwrap(), "agpm update");
937 }
938
939 #[test]
940 fn test_convert_to_claude_format_different_matchers() {
941 let mut hook_configs = HashMap::new();
943
944 hook_configs.insert(
945 "bash-hook".to_string(),
946 HookConfig {
947 events: vec![HookEvent::PreToolUse],
948 matcher: Some("Bash".to_string()),
949 hook_type: "command".to_string(),
950 command: "echo 'bash tool'".to_string(),
951 timeout: None,
952 description: None,
953 },
954 );
955 hook_configs.insert(
956 "write-hook".to_string(),
957 HookConfig {
958 events: vec![HookEvent::PreToolUse],
959 matcher: Some("Write".to_string()),
960 hook_type: "command".to_string(),
961 command: "echo 'write tool'".to_string(),
962 timeout: None,
963 description: None,
964 },
965 );
966
967 let result = convert_to_claude_format(hook_configs).unwrap();
968 let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
969
970 assert_eq!(pre_tool.len(), 2);
972
973 let bash_group = pre_tool
975 .iter()
976 .find(|g| g.get("matcher").and_then(|m| m.as_str()) == Some("Bash"))
977 .unwrap();
978 let write_group = pre_tool
979 .iter()
980 .find(|g| g.get("matcher").and_then(|m| m.as_str()) == Some("Write"))
981 .unwrap();
982
983 assert!(bash_group.get("hooks").unwrap().as_array().unwrap().len() == 1);
984 assert!(write_group.get("hooks").unwrap().as_array().unwrap().len() == 1);
985 }
986
987 #[test]
988 fn test_convert_to_claude_format_same_matcher() {
989 let mut hook_configs = HashMap::new();
991
992 hook_configs.insert(
993 "hook1".to_string(),
994 HookConfig {
995 events: vec![HookEvent::PreToolUse],
996 matcher: Some("Bash".to_string()),
997 hook_type: "command".to_string(),
998 command: "echo 'first'".to_string(),
999 timeout: None,
1000 description: None,
1001 },
1002 );
1003 hook_configs.insert(
1004 "hook2".to_string(),
1005 HookConfig {
1006 events: vec![HookEvent::PreToolUse],
1007 matcher: Some("Bash".to_string()), hook_type: "command".to_string(),
1009 command: "echo 'second'".to_string(),
1010 timeout: None,
1011 description: None,
1012 },
1013 );
1014
1015 let result = convert_to_claude_format(hook_configs).unwrap();
1016 let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
1017
1018 assert_eq!(pre_tool.len(), 1);
1020
1021 let hooks = pre_tool[0].get("hooks").unwrap().as_array().unwrap();
1023 assert_eq!(hooks.len(), 2);
1024 assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), "Bash");
1025 }
1026
1027 #[test]
1028 fn test_convert_to_claude_format_empty() {
1029 let hook_configs = HashMap::new();
1031 let result = convert_to_claude_format(hook_configs).unwrap();
1032
1033 assert_eq!(result.as_object().unwrap().len(), 0);
1034 }
1035
1036 #[test]
1037 fn test_convert_to_claude_format_other_event() {
1038 let mut hook_configs = HashMap::new();
1040 hook_configs.insert(
1041 "future-hook".to_string(),
1042 HookConfig {
1043 events: vec![HookEvent::Other("FutureEvent".to_string())],
1044 matcher: None,
1045 hook_type: "command".to_string(),
1046 command: "echo 'future event'".to_string(),
1047 timeout: None,
1048 description: None,
1049 },
1050 );
1051
1052 let result = convert_to_claude_format(hook_configs).unwrap();
1053 let expected = serde_json::json!({
1054 "FutureEvent": [
1055 {
1056 "hooks": [
1057 {
1058 "type": "command",
1059 "command": "echo 'future event'",
1060 "timeout": null
1061 }
1062 ]
1063 }
1064 ]
1065 });
1066
1067 assert_eq!(result, expected);
1068 }
1069
1070 #[test]
1071 fn test_hook_event_other_serialization() {
1072 let other_event = HookEvent::Other("CustomEvent".to_string());
1074 let json = serde_json::to_string(&other_event).unwrap();
1075 assert_eq!(json, r#""CustomEvent""#);
1076
1077 let parsed: HookEvent = serde_json::from_str(&json).unwrap();
1078 if let HookEvent::Other(event_name) = parsed {
1079 assert_eq!(event_name, "CustomEvent");
1080 } else {
1081 panic!("Expected Other variant");
1082 }
1083 }
1084}