agpm_cli/hooks/
mod.rs

1//! Hook configuration management for AGPM
2//!
3//! This module handles Claude Code hook configurations, including:
4//! - Installing hook JSON files to `.claude/agpm/hooks/`
5//! - Converting them to Claude Code format in `settings.local.json`
6//! - Managing hook lifecycle and dependencies
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13/// Hook event types supported by Claude Code
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub enum HookEvent {
16    /// Triggered before a tool is executed by Claude
17    #[serde(rename = "PreToolUse")]
18    PreToolUse,
19    /// Triggered after a tool has been executed by Claude
20    #[serde(rename = "PostToolUse")]
21    PostToolUse,
22    /// Triggered when Claude needs permission or input is idle
23    #[serde(rename = "Notification")]
24    Notification,
25    /// Triggered when the user submits a prompt
26    #[serde(rename = "UserPromptSubmit")]
27    UserPromptSubmit,
28    /// Triggered when main Claude Code agent finishes responding
29    #[serde(rename = "Stop")]
30    Stop,
31    /// Triggered when a subagent (Task tool) finishes responding
32    #[serde(rename = "SubagentStop")]
33    SubagentStop,
34    /// Triggered before compact operation
35    #[serde(rename = "PreCompact")]
36    PreCompact,
37    /// Triggered when starting/resuming a session
38    #[serde(rename = "SessionStart")]
39    SessionStart,
40    /// Triggered when session ends
41    #[serde(rename = "SessionEnd")]
42    SessionEnd,
43    /// Unknown or future hook event type
44    #[serde(untagged)]
45    Other(String),
46}
47
48/// Hook configuration as stored in JSON files
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct HookConfig {
51    /// Events this hook should trigger on
52    pub events: Vec<HookEvent>,
53    /// Regex matcher pattern for tools or commands (optional, only needed for tool-triggered events)
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub matcher: Option<String>,
56    /// Type of hook (usually "command")
57    #[serde(rename = "type")]
58    pub hook_type: String,
59    /// Command to execute
60    pub command: String,
61    /// Timeout in milliseconds
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub timeout: Option<u32>,
64    /// Description of what this hook does
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub description: Option<String>,
67}
68
69/// A single hook command within a matcher group
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct HookCommand {
72    /// Type of hook (usually "command")
73    #[serde(rename = "type")]
74    pub hook_type: String,
75    /// Command to execute
76    pub command: String,
77    /// Timeout in milliseconds
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub timeout: Option<u32>,
80    /// AGPM metadata for tracking
81    #[serde(rename = "_agpm", skip_serializing_if = "Option::is_none")]
82    pub agpm_metadata: Option<AgpmHookMetadata>,
83}
84
85/// Metadata for AGPM-managed hooks
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct AgpmHookMetadata {
88    /// Whether this hook is managed by AGPM (true) or manually configured (false)
89    pub managed: bool,
90    /// Name of the dependency that installed this hook
91    pub dependency_name: String,
92    /// Source repository name where this hook originated
93    pub source: String,
94    /// Version constraint or resolved version of the hook dependency
95    pub version: String,
96    /// ISO 8601 timestamp when this hook was installed
97    pub installed_at: String,
98}
99
100/// A matcher group containing multiple hooks with the same regex pattern.
101///
102/// In Claude Code's settings.local.json, hooks are organized into matcher groups
103/// where multiple hook commands can share the same regex pattern for tool matching.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct MatcherGroup {
106    /// Regex pattern that determines which tools this group applies to
107    pub matcher: String,
108    /// List of hook commands to execute when the matcher pattern matches
109    pub hooks: Vec<HookCommand>,
110}
111
112/// Load hook configurations from a directory containing JSON files.
113///
114/// Scans the specified directory for `.json` files and parses each one as a
115/// [`HookConfig`]. The filename (without extension) becomes the hook name in
116/// the returned map.
117///
118/// # Arguments
119///
120/// * `hooks_dir` - Directory path containing hook JSON configuration files
121///
122/// # Returns
123///
124/// A `HashMap` mapping hook names to their configurations. If the directory
125/// doesn't exist, returns an empty map.
126///
127/// # Errors
128///
129/// Returns an error if:
130/// - Directory reading fails due to permissions or I/O errors
131/// - Any JSON file cannot be read or parsed
132/// - A filename is invalid or cannot be converted to a string
133///
134/// # Examples
135///
136/// ```rust,no_run
137/// use agpm_cli::hooks::load_hook_configs;
138/// use std::path::Path;
139///
140/// # fn example() -> anyhow::Result<()> {
141/// let hooks_dir = Path::new(".claude/agpm/hooks");
142/// let configs = load_hook_configs(hooks_dir)?;
143///
144/// for (name, config) in configs {
145///     println!("Loaded hook '{}' with {} events", name, config.events.len());
146/// }
147/// # Ok(())
148/// # }
149/// ```
150pub 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
181/// Convert AGPM hook configs to Claude Code format
182///
183/// Transforms hooks from the AGPM format to the format expected by Claude Code.
184/// Groups hooks by event type and handles optional matchers correctly.
185fn 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            // Create the hook object in Claude format
197            let hook_obj = json!({
198                "type": config.hook_type,
199                "command": config.command,
200                "timeout": config.timeout
201            });
202
203            // Get or create the event array
204            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                // Tool-triggered event with matcher
209                // Find existing matcher group or create new one
210                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                        // Add to existing matcher group
216                        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                    // Create new matcher group
228                    event_vec.push(json!({
229                        "matcher": matcher,
230                        "hooks": [hook_obj]
231                    }));
232                }
233            } else {
234                // Session event without matcher - add to first group or create new one
235                if let Some(first_group) = event_vec.first_mut() {
236                    // Add to existing group if it has no matcher
237                    if first_group.as_object().unwrap().contains_key("matcher") {
238                        // Create new group for session events
239                        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                        // Check for duplicates before adding
246                        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                    // Create first group for session events
256                    event_vec.push(json!({
257                        "hooks": [hook_obj]
258                    }));
259                }
260            }
261        }
262    }
263
264    Ok(Value::Object(events_map))
265}
266
267/// Convert event enum to string
268fn 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
283/// Configure hooks from source files into .claude/settings.local.json
284///
285/// This function:
286/// 1. Reads hook JSON files directly from source locations (no file copying)
287/// 2. Converts them to Claude Code format
288/// 3. Updates .claude/settings.local.json with proper event-based structure
289/// 4. Can be called from both `add` and `install` commands
290pub 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    // Ensure directory exists
303    crate::utils::fs::ensure_dir(&claude_dir)?;
304
305    // Load hook configurations directly from source files
306    let mut hook_configs = HashMap::new();
307
308    for entry in &lockfile.hooks {
309        // Get the source file path
310        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            // Check if this is a local directory source
317            let is_local_source = entry.resolved_commit.as_deref().is_none_or(str::is_empty);
318
319            if is_local_source {
320                // Local directory source - use URL as path directly
321                std::path::PathBuf::from(url).join(&entry.path)
322            } else {
323                // Git-based source - get worktree
324                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            // Local file - resolve relative to project root
335            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        // Read and parse the hook configuration
344        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    // Load existing settings
355    let mut settings = crate::mcp::ClaudeSettings::load_or_default(&settings_path)?;
356
357    // Convert hooks to Claude Code format
358    let claude_hooks = convert_to_claude_format(hook_configs)?;
359
360    // Compare with existing hooks to detect changes
361    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        // Count actual configured hooks (after deduplication)
368        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        // Update settings with hooks (replaces existing hooks completely)
383        settings.hooks = Some(claude_hooks);
384
385        // Save updated settings
386        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
396/// Validate a hook configuration for correctness and safety.
397///
398/// Performs comprehensive validation of a hook configuration including:
399/// - Event list validation (must have at least one event)
400/// - Regex pattern syntax validation for the matcher
401/// - Hook type validation (only "command" type is supported)
402/// - Script existence validation for AGPM-managed scripts
403///
404/// # Arguments
405///
406/// * `config` - The hook configuration to validate
407/// * `script_path` - Path to the hook file (used to resolve relative script paths)
408///
409/// # Returns
410///
411/// Returns `Ok(())` if the configuration is valid.
412///
413/// # Errors
414///
415/// Returns an error if:
416/// - No events are specified
417/// - The matcher regex pattern is invalid
418/// - Unsupported hook type is used (only "command" is supported)
419/// - Referenced script file doesn't exist (for AGPM-managed scripts)
420///
421/// # Examples
422///
423/// ```rust,no_run
424/// use agpm_cli::hooks::{validate_hook_config, HookConfig, HookEvent};
425/// use std::path::Path;
426///
427/// # fn example() -> anyhow::Result<()> {
428/// let config = HookConfig {
429///     events: vec![HookEvent::PreToolUse],
430///     matcher: Some("Bash|Write".to_string()),
431///     hook_type: "command".to_string(),
432///     command: "echo 'validation'".to_string(),
433///     timeout: Some(5000),
434///     description: None,
435/// };
436///
437/// let hook_file = Path::new(".claude/agpm/hooks/test.json");
438/// validate_hook_config(&config, hook_file)?;
439/// println!("Hook configuration is valid!");
440/// # Ok(())
441/// # }
442/// ```
443pub fn validate_hook_config(config: &HookConfig, script_path: &Path) -> Result<()> {
444    // Validate events
445    if config.events.is_empty() {
446        return Err(anyhow::anyhow!("Hook must specify at least one event"));
447    }
448
449    // Validate matcher regex if present
450    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    // Validate hook type
456    if config.hook_type != "command" {
457        return Err(anyhow::anyhow!("Only 'command' hook type is currently supported"));
458    }
459
460    // Validate that the referenced script exists
461    let script_full_path = if config.command.starts_with(".claude/agpm/scripts/") {
462        // If script_path is the hook file (e.g., .claude/agpm/hooks/test.json),
463        // we need to go up to the project root:
464        // test.json -> hooks/ -> agpm/ -> .claude/ -> project_root
465        script_path
466            .parent() // hooks/
467            .and_then(|p| p.parent()) // agpm/
468            .and_then(|p| p.parent()) // .claude/
469            .and_then(|p| p.parent()) // project root
470            .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        // Test all hook event types serialize correctly
493        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        // Test minimal config without optional fields
537        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        // Create multiple hook configs
608        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        // Also create a non-JSON file that should be ignored
632        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        // Don't create the directory
652
653        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        // Write invalid JSON
664        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![], // Empty events
677            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()), // Invalid regex
696            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(), // Unsupported type
715            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        // Create the expected directory structure with script
730        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        // The hook file would be at .claude/agpm/hooks/test.json
749        // validate_hook_config goes up 3 levels from the hook path to find the project root
750        let hook_json_path = hooks_dir.join("test.json");
751        let result = validate_hook_config(&config, &hook_json_path);
752
753        // Since the script exists at the expected location, this should succeed
754        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        // Pass the hook file path
771        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        // Test with a command that doesn't start with .claude/agpm/scripts/
782        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(), // Absolute path not in .claude
787            timeout: None,
788            description: None,
789        };
790
791        let result = validate_hook_config(&config, temp.path());
792        // Should pass - we don't validate non-.claude paths
793        assert!(result.is_ok());
794    }
795
796    #[test]
797    fn test_convert_to_claude_format_session_start() {
798        // Test SessionStart hook without matcher
799        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, // No matcher for session events
805                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        // Test PreToolUse hook with matcher
833        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        // Test hook with multiple events
868        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        // Should appear in both events
884        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        // Both should have the matcher
894        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        // Test deduplication of identical session hooks
901        let mut hook_configs = HashMap::new();
902
903        // Add two identical SessionStart hooks
904        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(), // Same command
922                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        // Should have only one group
931        assert_eq!(session_start.len(), 1);
932
933        // That group should have only one hook (deduplicated)
934        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        // Test hooks with different matchers should be in separate groups
942        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        // Should have two separate groups
971        assert_eq!(pre_tool.len(), 2);
972
973        // Find the groups by matcher
974        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        // Test hooks with same matcher should be in same group
990        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()), // Same matcher
1008                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        // Should have only one group
1019        assert_eq!(pre_tool.len(), 1);
1020
1021        // That group should have both hooks
1022        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        // Test empty hook configs
1030        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        // Test unknown/future event type
1039        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        // Test that Other variant serializes/deserializes correctly
1073        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}