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 mut hook_obj = serde_json::Map::new();
198 hook_obj.insert("type".to_string(), json!(config.hook_type));
199 hook_obj.insert("command".to_string(), json!(config.command));
200 if let Some(timeout) = config.timeout {
201 hook_obj.insert("timeout".to_string(), json!(timeout));
202 }
203 let hook_obj = Value::Object(hook_obj);
204
205 let event_array = events_map.entry(event_name).or_insert_with(|| json!([]));
207 let event_vec = event_array.as_array_mut().unwrap();
208
209 if let Some(ref matcher) = config.matcher {
210 let mut found_group = false;
213 for group in event_vec.iter_mut() {
214 if let Some(group_matcher) = group.get("matcher").and_then(|m| m.as_str())
215 && group_matcher == matcher
216 {
217 if let Some(hooks_array) =
219 group.get_mut("hooks").and_then(|h| h.as_array_mut())
220 {
221 hooks_array.push(hook_obj.clone());
222 found_group = true;
223 break;
224 }
225 }
226 }
227
228 if !found_group {
229 event_vec.push(json!({
231 "matcher": matcher,
232 "hooks": [hook_obj]
233 }));
234 }
235 } else {
236 if let Some(first_group) = event_vec.first_mut() {
238 if first_group.as_object().unwrap().contains_key("matcher") {
240 event_vec.push(json!({
242 "hooks": [hook_obj]
243 }));
244 } else if let Some(hooks_array) =
245 first_group.get_mut("hooks").and_then(|h| h.as_array_mut())
246 {
247 let hook_exists = hooks_array.iter().any(|existing_hook| {
249 existing_hook.get("command") == hook_obj.get("command")
250 && existing_hook.get("type") == hook_obj.get("type")
251 });
252 if !hook_exists {
253 hooks_array.push(hook_obj);
254 }
255 }
256 } else {
257 event_vec.push(json!({
259 "hooks": [hook_obj]
260 }));
261 }
262 }
263 }
264 }
265
266 Ok(Value::Object(events_map))
267}
268
269fn event_to_string(event: &HookEvent) -> String {
271 match event {
272 HookEvent::PreToolUse => "PreToolUse".to_string(),
273 HookEvent::PostToolUse => "PostToolUse".to_string(),
274 HookEvent::Notification => "Notification".to_string(),
275 HookEvent::UserPromptSubmit => "UserPromptSubmit".to_string(),
276 HookEvent::Stop => "Stop".to_string(),
277 HookEvent::SubagentStop => "SubagentStop".to_string(),
278 HookEvent::PreCompact => "PreCompact".to_string(),
279 HookEvent::SessionStart => "SessionStart".to_string(),
280 HookEvent::SessionEnd => "SessionEnd".to_string(),
281 HookEvent::Other(event_name) => event_name.clone(),
282 }
283}
284
285pub async fn install_hooks(
293 lockfile: &crate::lockfile::LockFile,
294 project_root: &Path,
295 cache: &crate::cache::Cache,
296) -> Result<()> {
297 if lockfile.hooks.is_empty() {
298 return Ok(());
299 }
300
301 let claude_dir = project_root.join(".claude");
302 let settings_path = claude_dir.join("settings.local.json");
303
304 crate::utils::fs::ensure_dir(&claude_dir)?;
306
307 let mut hook_configs = HashMap::new();
309
310 for entry in &lockfile.hooks {
311 let source_path = if let Some(source_name) = &entry.source {
313 let url = entry
314 .url
315 .as_ref()
316 .ok_or_else(|| anyhow::anyhow!("Hook {} has no URL", entry.name))?;
317
318 let is_local_source = entry.resolved_commit.as_deref().is_none_or(str::is_empty);
320
321 if is_local_source {
322 std::path::PathBuf::from(url).join(&entry.path)
324 } else {
325 let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
327 anyhow::anyhow!("Hook {} missing resolved commit SHA", entry.name)
328 })?;
329
330 let worktree = cache
331 .get_or_create_worktree_for_sha(source_name, url, sha, Some(&entry.name))
332 .await?;
333 worktree.join(&entry.path)
334 }
335 } else {
336 let candidate = Path::new(&entry.path);
338 if candidate.is_absolute() {
339 candidate.to_path_buf()
340 } else {
341 project_root.join(candidate)
342 }
343 };
344
345 let content = tokio::fs::read_to_string(&source_path)
347 .await
348 .with_context(|| format!("Failed to read hook file: {}", source_path.display()))?;
349
350 let config: HookConfig = serde_json::from_str(&content)
351 .with_context(|| format!("Failed to parse hook config: {}", source_path.display()))?;
352
353 hook_configs.insert(entry.name.clone(), config);
354 }
355
356 let mut settings = crate::mcp::ClaudeSettings::load_or_default(&settings_path)?;
358
359 let claude_hooks = convert_to_claude_format(hook_configs)?;
361
362 let hooks_changed = match &settings.hooks {
364 Some(existing_hooks) => existing_hooks != &claude_hooks,
365 None => claude_hooks.as_object().is_none_or(|obj| !obj.is_empty()),
366 };
367
368 if hooks_changed {
369 let configured_count = claude_hooks.as_object().map_or(0, |events| {
371 events
372 .values()
373 .filter_map(|event_groups| event_groups.as_array())
374 .map(|groups| {
375 groups
376 .iter()
377 .filter_map(|group| group.get("hooks")?.as_array())
378 .map(std::vec::Vec::len)
379 .sum::<usize>()
380 })
381 .sum::<usize>()
382 });
383
384 settings.hooks = Some(claude_hooks);
386
387 settings.save(&settings_path)?;
389
390 if configured_count > 0 {
391 if configured_count == 1 {
392 println!("✓ Configured 1 hook");
393 } else {
394 println!("✓ Configured {configured_count} hooks");
395 }
396 }
397 }
398
399 Ok(())
400}
401
402pub fn validate_hook_config(config: &HookConfig, script_path: &Path) -> Result<()> {
450 if config.events.is_empty() {
452 return Err(anyhow::anyhow!("Hook must specify at least one event"));
453 }
454
455 if let Some(ref matcher) = config.matcher {
457 regex::Regex::new(matcher)
458 .with_context(|| format!("Invalid regex pattern in matcher: {matcher}"))?;
459 }
460
461 if config.hook_type != "command" {
463 return Err(anyhow::anyhow!("Only 'command' hook type is currently supported"));
464 }
465
466 let script_full_path = if config.command.starts_with(".claude/scripts/") {
468 script_path
472 .parent() .and_then(|p| p.parent()) .map(|p| p.join(&config.command))
475 } else {
476 None
477 };
478
479 if let Some(path) = script_full_path
480 && !path.exists()
481 {
482 return Err(anyhow::anyhow!("Hook references non-existent script: {}", config.command));
483 }
484
485 Ok(())
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use std::fs;
492 use tempfile::tempdir;
493
494 #[test]
495 fn test_hook_event_serialization() {
496 let events = vec![
498 (HookEvent::PreToolUse, r#""PreToolUse""#),
499 (HookEvent::PostToolUse, r#""PostToolUse""#),
500 (HookEvent::Notification, r#""Notification""#),
501 (HookEvent::UserPromptSubmit, r#""UserPromptSubmit""#),
502 (HookEvent::Stop, r#""Stop""#),
503 (HookEvent::SubagentStop, r#""SubagentStop""#),
504 (HookEvent::PreCompact, r#""PreCompact""#),
505 (HookEvent::SessionStart, r#""SessionStart""#),
506 (HookEvent::SessionEnd, r#""SessionEnd""#),
507 (HookEvent::Other("CustomEvent".to_string()), r#""CustomEvent""#),
508 ];
509
510 for (event, expected) in events {
511 let json = serde_json::to_string(&event).unwrap();
512 assert_eq!(json, expected);
513 let parsed: HookEvent = serde_json::from_str(&json).unwrap();
514 assert_eq!(parsed, event);
515 }
516 }
517
518 #[test]
519 fn test_hook_config_serialization() {
520 let config = HookConfig {
521 events: vec![HookEvent::PreToolUse, HookEvent::PostToolUse],
522 matcher: Some("Bash|Write".to_string()),
523 hook_type: "command".to_string(),
524 command: ".claude/scripts/security-check.sh".to_string(),
525 timeout: Some(5000),
526 description: Some("Security validation".to_string()),
527 };
528
529 let json = serde_json::to_string_pretty(&config).unwrap();
530 let parsed: HookConfig = serde_json::from_str(&json).unwrap();
531
532 assert_eq!(parsed.events.len(), 2);
533 assert_eq!(parsed.matcher, Some("Bash|Write".to_string()));
534 assert_eq!(parsed.timeout, Some(5000));
535 assert_eq!(parsed.description, Some("Security validation".to_string()));
536 }
537
538 #[test]
539 fn test_hook_config_minimal() {
540 let config = HookConfig {
542 events: vec![HookEvent::UserPromptSubmit],
543 matcher: Some(".*".to_string()),
544 hook_type: "command".to_string(),
545 command: "echo 'test'".to_string(),
546 timeout: None,
547 description: None,
548 };
549
550 let json = serde_json::to_string(&config).unwrap();
551 assert!(!json.contains("timeout"));
552 assert!(!json.contains("description"));
553 }
554
555 #[test]
556 fn test_hook_command_serialization() {
557 let metadata = AgpmHookMetadata {
558 managed: true,
559 dependency_name: "test-hook".to_string(),
560 source: "community".to_string(),
561 version: "v1.0.0".to_string(),
562 installed_at: "2024-01-01T00:00:00Z".to_string(),
563 };
564
565 let command = HookCommand {
566 hook_type: "command".to_string(),
567 command: "test.sh".to_string(),
568 timeout: Some(3000),
569 agpm_metadata: Some(metadata.clone()),
570 };
571
572 let json = serde_json::to_string(&command).unwrap();
573 let parsed: HookCommand = serde_json::from_str(&json).unwrap();
574
575 assert_eq!(parsed.hook_type, "command");
576 assert_eq!(parsed.command, "test.sh");
577 assert_eq!(parsed.timeout, Some(3000));
578 assert!(parsed.agpm_metadata.is_some());
579 let meta = parsed.agpm_metadata.unwrap();
580 assert!(meta.managed);
581 assert_eq!(meta.dependency_name, "test-hook");
582 }
583
584 #[test]
585 fn test_matcher_group_serialization() {
586 let command = HookCommand {
587 hook_type: "command".to_string(),
588 command: "test.sh".to_string(),
589 timeout: None,
590 agpm_metadata: None,
591 };
592
593 let group = MatcherGroup {
594 matcher: "Bash.*".to_string(),
595 hooks: vec![command.clone(), command.clone()],
596 };
597
598 let json = serde_json::to_string(&group).unwrap();
599 let parsed: MatcherGroup = serde_json::from_str(&json).unwrap();
600
601 assert_eq!(parsed.matcher, "Bash.*");
602 assert_eq!(parsed.hooks.len(), 2);
603 }
604
605 #[test]
606 fn test_load_hook_configs() {
607 let temp = tempdir().unwrap();
608 let hooks_dir = temp.path().join("hooks");
609 std::fs::create_dir(&hooks_dir).unwrap();
610
611 let config1 = HookConfig {
613 events: vec![HookEvent::PreToolUse],
614 matcher: Some(".*".to_string()),
615 hook_type: "command".to_string(),
616 command: "test1.sh".to_string(),
617 timeout: None,
618 description: None,
619 };
620
621 let config2 = HookConfig {
622 events: vec![HookEvent::PostToolUse],
623 matcher: Some("Write".to_string()),
624 hook_type: "command".to_string(),
625 command: "test2.sh".to_string(),
626 timeout: Some(1000),
627 description: Some("Test hook 2".to_string()),
628 };
629
630 fs::write(hooks_dir.join("test-hook1.json"), serde_json::to_string(&config1).unwrap())
631 .unwrap();
632 fs::write(hooks_dir.join("test-hook2.json"), serde_json::to_string(&config2).unwrap())
633 .unwrap();
634
635 fs::write(hooks_dir.join("readme.txt"), "This is not a hook").unwrap();
637
638 let configs = load_hook_configs(&hooks_dir).unwrap();
639 assert_eq!(configs.len(), 2);
640 assert!(configs.contains_key("test-hook1"));
641 assert!(configs.contains_key("test-hook2"));
642
643 let hook1 = &configs["test-hook1"];
644 assert_eq!(hook1.events.len(), 1);
645 assert_eq!(hook1.command, "test1.sh");
646
647 let hook2 = &configs["test-hook2"];
648 assert_eq!(hook2.timeout, Some(1000));
649 }
650
651 #[test]
652 fn test_load_hook_configs_empty_dir() {
653 let temp = tempdir().unwrap();
654 let hooks_dir = temp.path().join("empty_hooks");
655 let configs = load_hook_configs(&hooks_dir).unwrap();
658 assert_eq!(configs.len(), 0);
659 }
660
661 #[test]
662 fn test_load_hook_configs_invalid_json() {
663 let temp = tempdir().unwrap();
664 let hooks_dir = temp.path().join("hooks");
665 fs::create_dir(&hooks_dir).unwrap();
666
667 fs::write(hooks_dir.join("invalid.json"), "{ not valid json").unwrap();
669
670 let result = load_hook_configs(&hooks_dir);
671 assert!(result.is_err());
672 assert!(result.unwrap_err().to_string().contains("Failed to parse hook config"));
673 }
674
675 #[test]
676 fn test_validate_hook_config_empty_events() {
677 let temp = tempdir().unwrap();
678
679 let config = HookConfig {
680 events: vec![], matcher: Some(".*".to_string()),
682 hook_type: "command".to_string(),
683 command: "test.sh".to_string(),
684 timeout: None,
685 description: None,
686 };
687
688 let result = validate_hook_config(&config, temp.path());
689 assert!(result.is_err());
690 assert!(result.unwrap_err().to_string().contains("at least one event"));
691 }
692
693 #[test]
694 fn test_validate_hook_config_invalid_regex() {
695 let temp = tempdir().unwrap();
696
697 let config = HookConfig {
698 events: vec![HookEvent::PreToolUse],
699 matcher: Some("[invalid regex".to_string()), hook_type: "command".to_string(),
701 command: "test.sh".to_string(),
702 timeout: None,
703 description: None,
704 };
705
706 let result = validate_hook_config(&config, temp.path());
707 assert!(result.is_err());
708 assert!(result.unwrap_err().to_string().contains("Invalid regex pattern"));
709 }
710
711 #[test]
712 fn test_validate_hook_config_unsupported_type() {
713 let temp = tempdir().unwrap();
714
715 let config = HookConfig {
716 events: vec![HookEvent::PreToolUse],
717 matcher: Some(".*".to_string()),
718 hook_type: "webhook".to_string(), command: "test.sh".to_string(),
720 timeout: None,
721 description: None,
722 };
723
724 let result = validate_hook_config(&config, temp.path());
725 assert!(result.is_err());
726 assert!(result.unwrap_err().to_string().contains("Only 'command' hook type"));
727 }
728
729 #[test]
730 fn test_validate_hook_config_script_exists() {
731 let temp = tempdir().unwrap();
732
733 let claude_dir = temp.path().join(".claude");
736 let scripts_dir = claude_dir.join("scripts");
737 fs::create_dir_all(&scripts_dir).unwrap();
738
739 let script_path = scripts_dir.join("test.sh");
740 fs::write(&script_path, "#!/bin/bash\necho test").unwrap();
741
742 let config = HookConfig {
743 events: vec![HookEvent::PreToolUse],
744 matcher: Some(".*".to_string()),
745 hook_type: "command".to_string(),
746 command: ".claude/scripts/test.sh".to_string(),
747 timeout: None,
748 description: None,
749 };
750
751 let settings_path = temp.path().join(".claude").join("settings.local.json");
754 fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
755 let result = validate_hook_config(&config, &settings_path);
756
757 assert!(result.is_ok(), "Expected validation to succeed, but got: {:?}", result);
759 }
760
761 #[test]
762 fn test_validate_hook_config_script_not_exists() {
763 let temp = tempdir().unwrap();
764
765 let config = HookConfig {
766 events: vec![HookEvent::PreToolUse],
767 matcher: Some(".*".to_string()),
768 hook_type: "command".to_string(),
769 command: ".claude/scripts/nonexistent.sh".to_string(),
770 timeout: None,
771 description: None,
772 };
773
774 let hook_path = temp.path().join(".claude").join("agpm").join("hooks").join("test.json");
776 let result = validate_hook_config(&config, &hook_path);
777 assert!(result.is_err());
778 assert!(result.unwrap_err().to_string().contains("non-existent script"));
779 }
780
781 #[test]
782 fn test_validate_hook_config_non_claude_path() {
783 let temp = tempdir().unwrap();
784
785 let config = HookConfig {
787 events: vec![HookEvent::PreToolUse],
788 matcher: Some(".*".to_string()),
789 hook_type: "command".to_string(),
790 command: "/usr/bin/echo".to_string(), timeout: None,
792 description: None,
793 };
794
795 let result = validate_hook_config(&config, temp.path());
796 assert!(result.is_ok());
798 }
799
800 #[test]
801 fn test_convert_to_claude_format_session_start() {
802 let mut hook_configs = HashMap::new();
804 hook_configs.insert(
805 "session-hook".to_string(),
806 HookConfig {
807 events: vec![HookEvent::SessionStart],
808 matcher: None, hook_type: "command".to_string(),
810 command: "echo 'session started'".to_string(),
811 timeout: Some(1000),
812 description: Some("Session start hook".to_string()),
813 },
814 );
815
816 let result = convert_to_claude_format(hook_configs).unwrap();
817 let expected = serde_json::json!({
818 "SessionStart": [
819 {
820 "hooks": [
821 {
822 "type": "command",
823 "command": "echo 'session started'",
824 "timeout": 1000
825 }
826 ]
827 }
828 ]
829 });
830
831 assert_eq!(result, expected);
832 }
833
834 #[test]
835 fn test_convert_to_claude_format_with_matcher() {
836 let mut hook_configs = HashMap::new();
838 hook_configs.insert(
839 "tool-hook".to_string(),
840 HookConfig {
841 events: vec![HookEvent::PreToolUse],
842 matcher: Some("Bash|Write".to_string()),
843 hook_type: "command".to_string(),
844 command: "echo 'before tool use'".to_string(),
845 timeout: None,
846 description: None,
847 },
848 );
849
850 let result = convert_to_claude_format(hook_configs).unwrap();
851 let expected = serde_json::json!({
852 "PreToolUse": [
853 {
854 "matcher": "Bash|Write",
855 "hooks": [
856 {
857 "type": "command",
858 "command": "echo 'before tool use'"
859 }
860 ]
861 }
862 ]
863 });
864
865 assert_eq!(result, expected);
866 }
867
868 #[test]
869 fn test_convert_to_claude_format_multiple_events() {
870 let mut hook_configs = HashMap::new();
872 hook_configs.insert(
873 "multi-event-hook".to_string(),
874 HookConfig {
875 events: vec![HookEvent::PreToolUse, HookEvent::PostToolUse],
876 matcher: Some(".*".to_string()),
877 hook_type: "command".to_string(),
878 command: "echo 'tool event'".to_string(),
879 timeout: Some(5000),
880 description: None,
881 },
882 );
883
884 let result = convert_to_claude_format(hook_configs).unwrap();
885
886 assert!(result.get("PreToolUse").is_some());
888 assert!(result.get("PostToolUse").is_some());
889
890 let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
891 let post_tool = result.get("PostToolUse").unwrap().as_array().unwrap();
892
893 assert_eq!(pre_tool.len(), 1);
894 assert_eq!(post_tool.len(), 1);
895
896 assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*");
898 assert_eq!(post_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*");
899 }
900
901 #[test]
902 fn test_convert_to_claude_format_deduplication() {
903 let mut hook_configs = HashMap::new();
905
906 hook_configs.insert(
908 "hook1".to_string(),
909 HookConfig {
910 events: vec![HookEvent::SessionStart],
911 matcher: None,
912 hook_type: "command".to_string(),
913 command: "agpm update".to_string(),
914 timeout: None,
915 description: None,
916 },
917 );
918 hook_configs.insert(
919 "hook2".to_string(),
920 HookConfig {
921 events: vec![HookEvent::SessionStart],
922 matcher: None,
923 hook_type: "command".to_string(),
924 command: "agpm update".to_string(), timeout: None,
926 description: None,
927 },
928 );
929
930 let result = convert_to_claude_format(hook_configs).unwrap();
931 let session_start = result.get("SessionStart").unwrap().as_array().unwrap();
932
933 assert_eq!(session_start.len(), 1);
935
936 let hooks = session_start[0].get("hooks").unwrap().as_array().unwrap();
938 assert_eq!(hooks.len(), 1);
939 assert_eq!(hooks[0].get("command").unwrap().as_str().unwrap(), "agpm update");
940 }
941
942 #[test]
943 fn test_convert_to_claude_format_different_matchers() {
944 let mut hook_configs = HashMap::new();
946
947 hook_configs.insert(
948 "bash-hook".to_string(),
949 HookConfig {
950 events: vec![HookEvent::PreToolUse],
951 matcher: Some("Bash".to_string()),
952 hook_type: "command".to_string(),
953 command: "echo 'bash tool'".to_string(),
954 timeout: None,
955 description: None,
956 },
957 );
958 hook_configs.insert(
959 "write-hook".to_string(),
960 HookConfig {
961 events: vec![HookEvent::PreToolUse],
962 matcher: Some("Write".to_string()),
963 hook_type: "command".to_string(),
964 command: "echo 'write tool'".to_string(),
965 timeout: None,
966 description: None,
967 },
968 );
969
970 let result = convert_to_claude_format(hook_configs).unwrap();
971 let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
972
973 assert_eq!(pre_tool.len(), 2);
975
976 let bash_group = pre_tool
978 .iter()
979 .find(|g| g.get("matcher").and_then(|m| m.as_str()) == Some("Bash"))
980 .unwrap();
981 let write_group = pre_tool
982 .iter()
983 .find(|g| g.get("matcher").and_then(|m| m.as_str()) == Some("Write"))
984 .unwrap();
985
986 assert!(bash_group.get("hooks").unwrap().as_array().unwrap().len() == 1);
987 assert!(write_group.get("hooks").unwrap().as_array().unwrap().len() == 1);
988 }
989
990 #[test]
991 fn test_convert_to_claude_format_same_matcher() {
992 let mut hook_configs = HashMap::new();
994
995 hook_configs.insert(
996 "hook1".to_string(),
997 HookConfig {
998 events: vec![HookEvent::PreToolUse],
999 matcher: Some("Bash".to_string()),
1000 hook_type: "command".to_string(),
1001 command: "echo 'first'".to_string(),
1002 timeout: None,
1003 description: None,
1004 },
1005 );
1006 hook_configs.insert(
1007 "hook2".to_string(),
1008 HookConfig {
1009 events: vec![HookEvent::PreToolUse],
1010 matcher: Some("Bash".to_string()), hook_type: "command".to_string(),
1012 command: "echo 'second'".to_string(),
1013 timeout: None,
1014 description: None,
1015 },
1016 );
1017
1018 let result = convert_to_claude_format(hook_configs).unwrap();
1019 let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
1020
1021 assert_eq!(pre_tool.len(), 1);
1023
1024 let hooks = pre_tool[0].get("hooks").unwrap().as_array().unwrap();
1026 assert_eq!(hooks.len(), 2);
1027 assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), "Bash");
1028 }
1029
1030 #[test]
1031 fn test_convert_to_claude_format_empty() {
1032 let hook_configs = HashMap::new();
1034 let result = convert_to_claude_format(hook_configs).unwrap();
1035
1036 assert_eq!(result.as_object().unwrap().len(), 0);
1037 }
1038
1039 #[test]
1040 fn test_convert_to_claude_format_other_event() {
1041 let mut hook_configs = HashMap::new();
1043 hook_configs.insert(
1044 "future-hook".to_string(),
1045 HookConfig {
1046 events: vec![HookEvent::Other("FutureEvent".to_string())],
1047 matcher: None,
1048 hook_type: "command".to_string(),
1049 command: "echo 'future event'".to_string(),
1050 timeout: None,
1051 description: None,
1052 },
1053 );
1054
1055 let result = convert_to_claude_format(hook_configs).unwrap();
1056 let expected = serde_json::json!({
1057 "FutureEvent": [
1058 {
1059 "hooks": [
1060 {
1061 "type": "command",
1062 "command": "echo 'future event'"
1063 }
1064 ]
1065 }
1066 ]
1067 });
1068
1069 assert_eq!(result, expected);
1070 }
1071
1072 #[test]
1073 fn test_hook_event_other_serialization() {
1074 let other_event = HookEvent::Other("CustomEvent".to_string());
1076 let json = serde_json::to_string(&other_event).unwrap();
1077 assert_eq!(json, r#""CustomEvent""#);
1078
1079 let parsed: HookEvent = serde_json::from_str(&json).unwrap();
1080 if let HookEvent::Other(event_name) = parsed {
1081 assert_eq!(event_name, "CustomEvent");
1082 } else {
1083 panic!("Expected Other variant");
1084 }
1085 }
1086}