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