agpm_cli/hooks/
mod.rs

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