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 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            // Get or create the event array
206            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                // Tool-triggered event with matcher
211                // Find existing matcher group or create new one
212                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                        // Add to existing matcher group
218                        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                    // Create new matcher group
230                    event_vec.push(json!({
231                        "matcher": matcher,
232                        "hooks": [hook_obj]
233                    }));
234                }
235            } else {
236                // Session event without matcher - add to first group or create new one
237                if let Some(first_group) = event_vec.first_mut() {
238                    // Add to existing group if it has no matcher
239                    if first_group.as_object().unwrap().contains_key("matcher") {
240                        // Create new group for session events
241                        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                        // Check for duplicates before adding
248                        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                    // Create first group for session events
258                    event_vec.push(json!({
259                        "hooks": [hook_obj]
260                    }));
261                }
262            }
263        }
264    }
265
266    Ok(Value::Object(events_map))
267}
268
269/// Convert event enum to string
270fn 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
285/// Configure hooks from source files into .claude/settings.local.json
286///
287/// This function:
288/// 1. Reads hook JSON files directly from source locations (no file copying)
289/// 2. Converts them to Claude Code format
290/// 3. Updates .claude/settings.local.json with proper event-based structure
291/// 4. Can be called from both `add` and `install` commands
292pub 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    // Ensure directory exists
305    crate::utils::fs::ensure_dir(&claude_dir)?;
306
307    // Load hook configurations directly from source files
308    let mut hook_configs = HashMap::new();
309
310    for entry in &lockfile.hooks {
311        // Get the source file path
312        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            // Check if this is a local directory source
319            let is_local_source = entry.resolved_commit.as_deref().is_none_or(str::is_empty);
320
321            if is_local_source {
322                // Local directory source - use URL as path directly
323                std::path::PathBuf::from(url).join(&entry.path)
324            } else {
325                // Git-based source - get worktree
326                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            // Local file - resolve relative to project root
337            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        // Read and parse the hook configuration
346        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    // Load existing settings
357    let mut settings = crate::mcp::ClaudeSettings::load_or_default(&settings_path)?;
358
359    // Convert hooks to Claude Code format
360    let claude_hooks = convert_to_claude_format(hook_configs)?;
361
362    // Compare with existing hooks to detect changes
363    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        // Count actual configured hooks (after deduplication)
370        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        // Update settings with hooks (replaces existing hooks completely)
385        settings.hooks = Some(claude_hooks);
386
387        // Save updated settings
388        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
402/// Validate a hook configuration for correctness and safety.
403///
404/// Performs comprehensive validation of a hook configuration including:
405/// - Event list validation (must have at least one event)
406/// - Regex pattern syntax validation for the matcher
407/// - Hook type validation (only "command" type is supported)
408/// - Script existence validation for AGPM-managed scripts
409///
410/// # Arguments
411///
412/// * `config` - The hook configuration to validate
413/// * `script_path` - Path to the hook file (used to resolve relative script paths)
414///
415/// # Returns
416///
417/// Returns `Ok(())` if the configuration is valid.
418///
419/// # Errors
420///
421/// Returns an error if:
422/// - No events are specified
423/// - The matcher regex pattern is invalid
424/// - Unsupported hook type is used (only "command" is supported)
425/// - Referenced script file doesn't exist (for AGPM-managed scripts)
426///
427/// # Examples
428///
429/// ```rust,no_run
430/// use agpm_cli::hooks::{validate_hook_config, HookConfig, HookEvent};
431/// use std::path::Path;
432///
433/// # fn example() -> anyhow::Result<()> {
434/// let config = HookConfig {
435///     events: vec![HookEvent::PreToolUse],
436///     matcher: Some("Bash|Write".to_string()),
437///     hook_type: "command".to_string(),
438///     command: "echo 'validation'".to_string(),
439///     timeout: Some(5000),
440///     description: None,
441/// };
442///
443/// let hook_file = Path::new(".claude/hooks/test.json");
444/// validate_hook_config(&config, hook_file)?;
445/// println!("Hook configuration is valid!");
446/// # Ok(())
447/// # }
448/// ```
449pub fn validate_hook_config(config: &HookConfig, script_path: &Path) -> Result<()> {
450    // Validate events
451    if config.events.is_empty() {
452        return Err(anyhow::anyhow!("Hook must specify at least one event"));
453    }
454
455    // Validate matcher regex if present
456    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    // Validate hook type
462    if config.hook_type != "command" {
463        return Err(anyhow::anyhow!("Only 'command' hook type is currently supported"));
464    }
465
466    // Validate that the referenced script exists
467    let script_full_path = if config.command.starts_with(".claude/scripts/") {
468        // Hooks are now merged into .claude/settings.local.json
469        // We need to find the project root from the script_path
470        // From .claude/settings.local.json: settings.local.json -> .claude/ -> project_root
471        script_path
472            .parent() // .claude/
473            .and_then(|p| p.parent()) // project root
474            .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        // Test all hook event types serialize correctly
497        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        // Test minimal config without optional fields
541        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        // Create multiple hook configs
612        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        // Also create a non-JSON file that should be ignored
636        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        // Don't create the directory
656
657        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        // Write invalid JSON
668        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![], // Empty events
681            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()), // Invalid regex
700            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(), // Unsupported type
719            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        // Create the expected directory structure with script
734        // Scripts are in .claude/scripts/ directory
735        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        // Hooks are merged into settings.local.json, but for validation purposes
752        // we need a reference path. Use the project root.
753        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        // Since the script exists at the expected location, this should succeed
758        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        // Pass the hook file path
775        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        // Test with a command that doesn't start with .claude/scripts/
786        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(), // Absolute path not in .claude
791            timeout: None,
792            description: None,
793        };
794
795        let result = validate_hook_config(&config, temp.path());
796        // Should pass - we don't validate non-.claude paths
797        assert!(result.is_ok());
798    }
799
800    #[test]
801    fn test_convert_to_claude_format_session_start() {
802        // Test SessionStart hook without matcher
803        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, // No matcher for session events
809                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        // Test PreToolUse hook with matcher
837        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        // Test hook with multiple events
871        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        // Should appear in both events
887        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        // Both should have the matcher
897        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        // Test deduplication of identical session hooks
904        let mut hook_configs = HashMap::new();
905
906        // Add two identical SessionStart hooks
907        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(), // Same command
925                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        // Should have only one group
934        assert_eq!(session_start.len(), 1);
935
936        // That group should have only one hook (deduplicated)
937        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        // Test hooks with different matchers should be in separate groups
945        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        // Should have two separate groups
974        assert_eq!(pre_tool.len(), 2);
975
976        // Find the groups by matcher
977        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        // Test hooks with same matcher should be in same group
993        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()), // Same matcher
1011                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        // Should have only one group
1022        assert_eq!(pre_tool.len(), 1);
1023
1024        // That group should have both hooks
1025        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        // Test empty hook configs
1033        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        // Test unknown/future event type
1042        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        // Test that Other variant serializes/deserializes correctly
1075        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}