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 crate::core::file_error::{FileOperation, FileResultExt};
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::Path;
13
14/// Hook event types supported by Claude Code
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub enum HookEvent {
17    /// Triggered before a tool is executed by Claude
18    #[serde(rename = "PreToolUse")]
19    PreToolUse,
20    /// Triggered after a tool has been executed by Claude
21    #[serde(rename = "PostToolUse")]
22    PostToolUse,
23    /// Triggered when Claude needs permission or input is idle
24    #[serde(rename = "Notification")]
25    Notification,
26    /// Triggered when the user submits a prompt
27    #[serde(rename = "UserPromptSubmit")]
28    UserPromptSubmit,
29    /// Triggered when main Claude Code agent finishes responding
30    #[serde(rename = "Stop")]
31    Stop,
32    /// Triggered when a subagent (Task tool) finishes responding
33    #[serde(rename = "SubagentStop")]
34    SubagentStop,
35    /// Triggered before compact operation
36    #[serde(rename = "PreCompact")]
37    PreCompact,
38    /// Triggered when starting/resuming a session
39    #[serde(rename = "SessionStart")]
40    SessionStart,
41    /// Triggered when session ends
42    #[serde(rename = "SessionEnd")]
43    SessionEnd,
44    /// Unknown or future hook event type
45    #[serde(untagged)]
46    Other(String),
47}
48
49/// Hook configuration as stored in JSON files
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct HookConfig {
52    /// Events this hook should trigger on
53    pub events: Vec<HookEvent>,
54    /// Regex matcher pattern for tools or commands (optional, only needed for tool-triggered events)
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub matcher: Option<String>,
57    /// Type of hook (usually "command")
58    #[serde(rename = "type")]
59    pub hook_type: String,
60    /// Command to execute
61    pub command: String,
62    /// Timeout in milliseconds
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub timeout: Option<u32>,
65    /// Description of what this hook does
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub description: Option<String>,
68}
69
70/// A single hook command within a matcher group
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct HookCommand {
73    /// Type of hook (usually "command")
74    #[serde(rename = "type")]
75    pub hook_type: String,
76    /// Command to execute
77    pub command: String,
78    /// Timeout in milliseconds
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub timeout: Option<u32>,
81    /// AGPM metadata for tracking
82    #[serde(rename = "_agpm", skip_serializing_if = "Option::is_none")]
83    pub agpm_metadata: Option<AgpmHookMetadata>,
84}
85
86/// Metadata for AGPM-managed hooks
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct AgpmHookMetadata {
89    /// Whether this hook is managed by AGPM (true) or manually configured (false)
90    pub managed: bool,
91    /// Name of the dependency that installed this hook
92    pub dependency_name: String,
93    /// Source repository name where this hook originated
94    pub source: String,
95    /// Version constraint or resolved version of the hook dependency
96    pub version: String,
97    /// ISO 8601 timestamp when this hook was installed
98    pub installed_at: String,
99}
100
101/// A matcher group containing multiple hooks with the same regex pattern.
102///
103/// In Claude Code's settings.local.json, hooks are organized into matcher groups
104/// where multiple hook commands can share the same regex pattern for tool matching.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct MatcherGroup {
107    /// Regex pattern that determines which tools this group applies to
108    pub matcher: String,
109    /// List of hook commands to execute when the matcher pattern matches
110    pub hooks: Vec<HookCommand>,
111}
112
113/// Load hook configurations from a directory containing JSON files.
114///
115/// Scans the specified directory for `.json` files and parses each one as a
116/// [`HookConfig`]. The filename (without extension) becomes the hook name in
117/// the returned map.
118///
119/// # Arguments
120///
121/// * `hooks_dir` - Directory path containing hook JSON configuration files
122///
123/// # Returns
124///
125/// A `HashMap` mapping hook names to their configurations. If the directory
126/// doesn't exist, returns an empty map.
127///
128/// # Errors
129///
130/// Returns an error if:
131/// - Directory reading fails due to permissions or I/O errors
132/// - Any JSON file cannot be read or parsed
133/// - A filename is invalid or cannot be converted to a string
134///
135/// # Examples
136///
137/// ```rust,no_run
138/// use agpm_cli::hooks::load_hook_configs;
139/// use std::path::Path;
140///
141/// # fn example() -> anyhow::Result<()> {
142/// let hooks_dir = Path::new(".claude/hooks");
143/// let configs = load_hook_configs(hooks_dir)?;
144///
145/// for (name, config) in configs {
146///     println!("Loaded hook '{}' with {} events", name, config.events.len());
147/// }
148/// # Ok(())
149/// # }
150/// ```
151pub fn load_hook_configs(hooks_dir: &Path) -> Result<HashMap<String, HookConfig>> {
152    let mut configs = HashMap::new();
153
154    if !hooks_dir.exists() {
155        return Ok(configs);
156    }
157
158    for entry in std::fs::read_dir(hooks_dir).with_file_context(
159        FileOperation::Read,
160        hooks_dir,
161        "reading hooks directory",
162        "hooks_module",
163    )? {
164        let entry = entry?;
165        let path = entry.path();
166
167        if path.extension().and_then(|s| s.to_str()) == Some("json") {
168            let name = path
169                .file_stem()
170                .and_then(|s| s.to_str())
171                .ok_or_else(|| anyhow::anyhow!("Invalid hook filename"))?
172                .to_string();
173
174            let content = std::fs::read_to_string(&path).with_file_context(
175                FileOperation::Read,
176                &path,
177                "reading hook file",
178                "hooks_module",
179            )?;
180
181            let config: HookConfig = serde_json::from_str(&content)
182                .with_context(|| format!("Failed to parse hook config: {}", path.display()))?;
183
184            configs.insert(name, config);
185        }
186    }
187
188    Ok(configs)
189}
190
191/// Convert AGPM hook configs to Claude Code format
192///
193/// Transforms hooks from the AGPM format to the format expected by Claude Code.
194/// Groups hooks by event type and handles optional matchers correctly.
195fn convert_to_claude_format(
196    hook_configs: HashMap<String, HookConfig>,
197) -> Result<serde_json::Value> {
198    use serde_json::{Map, Value, json};
199
200    let mut events_map: Map<String, Value> = Map::new();
201
202    for (_name, config) in hook_configs {
203        for event in &config.events {
204            let event_name = event_to_string(event);
205
206            // Create the hook object in Claude format
207            let mut hook_obj = serde_json::Map::new();
208            hook_obj.insert("type".to_string(), json!(config.hook_type));
209            hook_obj.insert("command".to_string(), json!(config.command));
210            if let Some(timeout) = config.timeout {
211                hook_obj.insert("timeout".to_string(), json!(timeout));
212            }
213            let hook_obj = Value::Object(hook_obj);
214
215            // Get or create the event array
216            let event_array = events_map.entry(event_name).or_insert_with(|| json!([]));
217            let event_vec = event_array.as_array_mut().unwrap();
218
219            if let Some(ref matcher) = config.matcher {
220                // Tool-triggered event with matcher
221                // Find existing matcher group or create new one
222                let mut found_group = false;
223                for group in event_vec.iter_mut() {
224                    if let Some(group_matcher) = group.get("matcher").and_then(|m| m.as_str())
225                        && group_matcher == matcher
226                    {
227                        // Add to existing matcher group
228                        if let Some(hooks_array) =
229                            group.get_mut("hooks").and_then(|h| h.as_array_mut())
230                        {
231                            hooks_array.push(hook_obj.clone());
232                            found_group = true;
233                            break;
234                        }
235                    }
236                }
237
238                if !found_group {
239                    // Create new matcher group
240                    event_vec.push(json!({
241                        "matcher": matcher,
242                        "hooks": [hook_obj]
243                    }));
244                }
245            } else {
246                // Session event without matcher - add to first group or create new one
247                if let Some(first_group) = event_vec.first_mut() {
248                    // Add to existing group if it has no matcher
249                    if first_group.as_object().unwrap().contains_key("matcher") {
250                        // Create new group for session events
251                        event_vec.push(json!({
252                            "hooks": [hook_obj]
253                        }));
254                    } else if let Some(hooks_array) =
255                        first_group.get_mut("hooks").and_then(|h| h.as_array_mut())
256                    {
257                        // Check for duplicates before adding
258                        let hook_exists = hooks_array.iter().any(|existing_hook| {
259                            existing_hook.get("command") == hook_obj.get("command")
260                                && existing_hook.get("type") == hook_obj.get("type")
261                        });
262                        if !hook_exists {
263                            hooks_array.push(hook_obj);
264                        }
265                    }
266                } else {
267                    // Create first group for session events
268                    event_vec.push(json!({
269                        "hooks": [hook_obj]
270                    }));
271                }
272            }
273        }
274    }
275
276    Ok(Value::Object(events_map))
277}
278
279/// Convert event enum to string
280fn event_to_string(event: &HookEvent) -> String {
281    match event {
282        HookEvent::PreToolUse => "PreToolUse".to_string(),
283        HookEvent::PostToolUse => "PostToolUse".to_string(),
284        HookEvent::Notification => "Notification".to_string(),
285        HookEvent::UserPromptSubmit => "UserPromptSubmit".to_string(),
286        HookEvent::Stop => "Stop".to_string(),
287        HookEvent::SubagentStop => "SubagentStop".to_string(),
288        HookEvent::PreCompact => "PreCompact".to_string(),
289        HookEvent::SessionStart => "SessionStart".to_string(),
290        HookEvent::SessionEnd => "SessionEnd".to_string(),
291        HookEvent::Other(event_name) => event_name.clone(),
292    }
293}
294
295/// Configure hooks from source files into .claude/settings.local.json
296///
297/// This function:
298/// 1. Reads hook JSON files directly from source locations (no file copying)
299/// 2. Converts them to Claude Code format
300/// 3. Updates .claude/settings.local.json with proper event-based structure
301/// 4. Can be called from both `add` and `install` commands
302///
303/// Returns the count of hooks that were actually changed
304pub async fn install_hooks(
305    lockfile: &crate::lockfile::LockFile,
306    project_root: &Path,
307    cache: &crate::cache::Cache,
308) -> Result<usize> {
309    if lockfile.hooks.is_empty() {
310        return Ok(0);
311    }
312
313    let claude_dir = project_root.join(".claude");
314    let settings_path = claude_dir.join("settings.local.json");
315
316    // Ensure directory exists
317    crate::utils::fs::ensure_dir(&claude_dir)?;
318
319    // Load hook configurations directly from source files
320    let mut hook_configs = HashMap::new();
321
322    for entry in &lockfile.hooks {
323        // Get the source file path
324        let source_path = if let Some(source_name) = &entry.source {
325            let url = entry
326                .url
327                .as_ref()
328                .ok_or_else(|| anyhow::anyhow!("Hook {} has no URL", entry.name))?;
329
330            // Check if this is a local directory source
331            let is_local_source = entry.is_local();
332
333            if is_local_source {
334                // Local directory source - use URL as path directly
335                std::path::PathBuf::from(url).join(&entry.path)
336            } else {
337                // Git-based source - get worktree
338                let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
339                    anyhow::anyhow!("Hook {} missing resolved commit SHA", entry.name)
340                })?;
341
342                let worktree = cache
343                    .get_or_create_worktree_for_sha(source_name, url, sha, Some(&entry.name))
344                    .await?;
345                worktree.join(&entry.path)
346            }
347        } else {
348            // Local file - resolve relative to project root
349            let candidate = Path::new(&entry.path);
350            if candidate.is_absolute() {
351                candidate.to_path_buf()
352            } else {
353                project_root.join(candidate)
354            }
355        };
356
357        // Read and parse the hook configuration
358        let content = tokio::fs::read_to_string(&source_path).await.with_file_context(
359            FileOperation::Read,
360            &source_path,
361            "reading hook file",
362            "hooks_module",
363        )?;
364
365        let config: HookConfig = serde_json::from_str(&content)
366            .with_context(|| format!("Failed to parse hook config: {}", source_path.display()))?;
367
368        hook_configs.insert(entry.name.clone(), config);
369    }
370
371    // Load existing settings
372    let mut settings = crate::mcp::ClaudeSettings::load_or_default(&settings_path)?;
373
374    // Convert hooks to Claude Code format
375    let claude_hooks = convert_to_claude_format(hook_configs)?;
376
377    // Compare with existing hooks to detect changes
378    let hooks_changed = match &settings.hooks {
379        Some(existing_hooks) => existing_hooks != &claude_hooks,
380        None => claude_hooks.as_object().is_none_or(|obj| !obj.is_empty()),
381    };
382
383    if hooks_changed {
384        // Count actual configured hooks (after deduplication) before moving
385        let configured_count = claude_hooks.as_object().map_or(0, |events| {
386            events
387                .values()
388                .filter_map(|event_groups| event_groups.as_array())
389                .map(|groups| {
390                    groups
391                        .iter()
392                        .filter_map(|group| group.get("hooks")?.as_array())
393                        .map(std::vec::Vec::len)
394                        .sum::<usize>()
395                })
396                .sum::<usize>()
397        });
398
399        // Update settings with hooks (replaces existing hooks completely)
400        settings.hooks = Some(claude_hooks);
401
402        // Save updated settings
403        settings.save(&settings_path)?;
404
405        Ok(configured_count)
406    } else {
407        Ok(0)
408    }
409}
410
411/// Validate a hook configuration for correctness and safety.
412///
413/// Performs comprehensive validation of a hook configuration including:
414/// - Event list validation (must have at least one event)
415/// - Regex pattern syntax validation for the matcher
416/// - Hook type validation (only "command" type is supported)
417/// - Script existence validation for AGPM-managed scripts
418///
419/// # Arguments
420///
421/// * `config` - The hook configuration to validate
422/// * `script_path` - Path to the hook file (used to resolve relative script paths)
423///
424/// # Returns
425///
426/// Returns `Ok(())` if the configuration is valid.
427///
428/// # Errors
429///
430/// Returns an error if:
431/// - No events are specified
432/// - The matcher regex pattern is invalid
433/// - Unsupported hook type is used (only "command" is supported)
434/// - Referenced script file doesn't exist (for AGPM-managed scripts)
435///
436/// # Examples
437///
438/// ```rust,no_run
439/// use agpm_cli::hooks::{validate_hook_config, HookConfig, HookEvent};
440/// use std::path::Path;
441///
442/// # fn example() -> anyhow::Result<()> {
443/// let config = HookConfig {
444///     events: vec![HookEvent::PreToolUse],
445///     matcher: Some("Bash|Write".to_string()),
446///     hook_type: "command".to_string(),
447///     command: "echo 'validation'".to_string(),
448///     timeout: Some(5000),
449///     description: None,
450/// };
451///
452/// let hook_file = Path::new(".claude/hooks/test.json");
453/// validate_hook_config(&config, hook_file)?;
454/// println!("Hook configuration is valid!");
455/// # Ok(())
456/// # }
457/// ```
458pub fn validate_hook_config(config: &HookConfig, script_path: &Path) -> Result<()> {
459    // Validate events
460    if config.events.is_empty() {
461        return Err(anyhow::anyhow!("Hook must specify at least one event"));
462    }
463
464    // Validate matcher regex if present
465    if let Some(ref matcher) = config.matcher {
466        regex::Regex::new(matcher)
467            .with_context(|| format!("Invalid regex pattern in matcher: {matcher}"))?;
468    }
469
470    // Validate hook type
471    if config.hook_type != "command" {
472        return Err(anyhow::anyhow!("Only 'command' hook type is currently supported"));
473    }
474
475    // Validate that the referenced script exists
476    let script_full_path = if config.command.starts_with(".claude/scripts/") {
477        // Hooks are now merged into .claude/settings.local.json
478        // We need to find the project root from the script_path
479        // From .claude/settings.local.json: settings.local.json -> .claude/ -> project_root
480        script_path
481            .parent() // .claude/
482            .and_then(|p| p.parent()) // project root
483            .map(|p| p.join(&config.command))
484    } else {
485        None
486    };
487
488    if let Some(path) = script_full_path
489        && !path.exists()
490    {
491        return Err(anyhow::anyhow!("Hook references non-existent script: {}", config.command));
492    }
493
494    Ok(())
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use std::fs;
501    use tempfile::tempdir;
502
503    #[test]
504    fn test_hook_event_serialization() {
505        // Test all hook event types serialize correctly
506        let events = vec![
507            (HookEvent::PreToolUse, r#""PreToolUse""#),
508            (HookEvent::PostToolUse, r#""PostToolUse""#),
509            (HookEvent::Notification, r#""Notification""#),
510            (HookEvent::UserPromptSubmit, r#""UserPromptSubmit""#),
511            (HookEvent::Stop, r#""Stop""#),
512            (HookEvent::SubagentStop, r#""SubagentStop""#),
513            (HookEvent::PreCompact, r#""PreCompact""#),
514            (HookEvent::SessionStart, r#""SessionStart""#),
515            (HookEvent::SessionEnd, r#""SessionEnd""#),
516            (HookEvent::Other("CustomEvent".to_string()), r#""CustomEvent""#),
517        ];
518
519        for (event, expected) in events {
520            let json = serde_json::to_string(&event).unwrap();
521            assert_eq!(json, expected);
522            let parsed: HookEvent = serde_json::from_str(&json).unwrap();
523            assert_eq!(parsed, event);
524        }
525    }
526
527    #[test]
528    fn test_hook_config_serialization() {
529        let config = HookConfig {
530            events: vec![HookEvent::PreToolUse, HookEvent::PostToolUse],
531            matcher: Some("Bash|Write".to_string()),
532            hook_type: "command".to_string(),
533            command: ".claude/scripts/security-check.sh".to_string(),
534            timeout: Some(5000),
535            description: Some("Security validation".to_string()),
536        };
537
538        let json = serde_json::to_string_pretty(&config).unwrap();
539        let parsed: HookConfig = serde_json::from_str(&json).unwrap();
540
541        assert_eq!(parsed.events.len(), 2);
542        assert_eq!(parsed.matcher, Some("Bash|Write".to_string()));
543        assert_eq!(parsed.timeout, Some(5000));
544        assert_eq!(parsed.description, Some("Security validation".to_string()));
545    }
546
547    #[test]
548    fn test_hook_config_minimal() {
549        // Test minimal config without optional fields
550        let config = HookConfig {
551            events: vec![HookEvent::UserPromptSubmit],
552            matcher: Some(".*".to_string()),
553            hook_type: "command".to_string(),
554            command: "echo 'test'".to_string(),
555            timeout: None,
556            description: None,
557        };
558
559        let json = serde_json::to_string(&config).unwrap();
560        assert!(!json.contains("timeout"));
561        assert!(!json.contains("description"));
562    }
563
564    #[test]
565    fn test_hook_command_serialization() {
566        let metadata = AgpmHookMetadata {
567            managed: true,
568            dependency_name: "test-hook".to_string(),
569            source: "community".to_string(),
570            version: "v1.0.0".to_string(),
571            installed_at: "2024-01-01T00:00:00Z".to_string(),
572        };
573
574        let command = HookCommand {
575            hook_type: "command".to_string(),
576            command: "test.sh".to_string(),
577            timeout: Some(3000),
578            agpm_metadata: Some(metadata.clone()),
579        };
580
581        let json = serde_json::to_string(&command).unwrap();
582        let parsed: HookCommand = serde_json::from_str(&json).unwrap();
583
584        assert_eq!(parsed.hook_type, "command");
585        assert_eq!(parsed.command, "test.sh");
586        assert_eq!(parsed.timeout, Some(3000));
587        assert!(parsed.agpm_metadata.is_some());
588        let meta = parsed.agpm_metadata.unwrap();
589        assert!(meta.managed);
590        assert_eq!(meta.dependency_name, "test-hook");
591    }
592
593    #[test]
594    fn test_matcher_group_serialization() {
595        let command = HookCommand {
596            hook_type: "command".to_string(),
597            command: "test.sh".to_string(),
598            timeout: None,
599            agpm_metadata: None,
600        };
601
602        let group = MatcherGroup {
603            matcher: "Bash.*".to_string(),
604            hooks: vec![command.clone(), command.clone()],
605        };
606
607        let json = serde_json::to_string(&group).unwrap();
608        let parsed: MatcherGroup = serde_json::from_str(&json).unwrap();
609
610        assert_eq!(parsed.matcher, "Bash.*");
611        assert_eq!(parsed.hooks.len(), 2);
612    }
613
614    #[test]
615    fn test_load_hook_configs() {
616        let temp = tempdir().unwrap();
617        let hooks_dir = temp.path().join("hooks");
618        std::fs::create_dir(&hooks_dir).unwrap();
619
620        // Create multiple hook configs
621        let config1 = HookConfig {
622            events: vec![HookEvent::PreToolUse],
623            matcher: Some(".*".to_string()),
624            hook_type: "command".to_string(),
625            command: "test1.sh".to_string(),
626            timeout: None,
627            description: None,
628        };
629
630        let config2 = HookConfig {
631            events: vec![HookEvent::PostToolUse],
632            matcher: Some("Write".to_string()),
633            hook_type: "command".to_string(),
634            command: "test2.sh".to_string(),
635            timeout: Some(1000),
636            description: Some("Test hook 2".to_string()),
637        };
638
639        fs::write(hooks_dir.join("test-hook1.json"), serde_json::to_string(&config1).unwrap())
640            .unwrap();
641        fs::write(hooks_dir.join("test-hook2.json"), serde_json::to_string(&config2).unwrap())
642            .unwrap();
643
644        // Also create a non-JSON file that should be ignored
645        fs::write(hooks_dir.join("readme.txt"), "This is not a hook").unwrap();
646
647        let configs = load_hook_configs(&hooks_dir).unwrap();
648        assert_eq!(configs.len(), 2);
649        assert!(configs.contains_key("test-hook1"));
650        assert!(configs.contains_key("test-hook2"));
651
652        let hook1 = &configs["test-hook1"];
653        assert_eq!(hook1.events.len(), 1);
654        assert_eq!(hook1.command, "test1.sh");
655
656        let hook2 = &configs["test-hook2"];
657        assert_eq!(hook2.timeout, Some(1000));
658    }
659
660    #[test]
661    fn test_load_hook_configs_empty_dir() {
662        let temp = tempdir().unwrap();
663        let hooks_dir = temp.path().join("empty_hooks");
664        // Don't create the directory
665
666        let configs = load_hook_configs(&hooks_dir).unwrap();
667        assert_eq!(configs.len(), 0);
668    }
669
670    #[test]
671    fn test_load_hook_configs_invalid_json() {
672        let temp = tempdir().unwrap();
673        let hooks_dir = temp.path().join("hooks");
674        fs::create_dir(&hooks_dir).unwrap();
675
676        // Write invalid JSON
677        fs::write(hooks_dir.join("invalid.json"), "{ not valid json").unwrap();
678
679        let result = load_hook_configs(&hooks_dir);
680        assert!(result.is_err());
681        assert!(result.unwrap_err().to_string().contains("Failed to parse hook config"));
682    }
683
684    #[test]
685    fn test_validate_hook_config_empty_events() {
686        let temp = tempdir().unwrap();
687
688        let config = HookConfig {
689            events: vec![], // Empty events
690            matcher: Some(".*".to_string()),
691            hook_type: "command".to_string(),
692            command: "test.sh".to_string(),
693            timeout: None,
694            description: None,
695        };
696
697        let result = validate_hook_config(&config, temp.path());
698        assert!(result.is_err());
699        assert!(result.unwrap_err().to_string().contains("at least one event"));
700    }
701
702    #[test]
703    fn test_validate_hook_config_invalid_regex() {
704        let temp = tempdir().unwrap();
705
706        let config = HookConfig {
707            events: vec![HookEvent::PreToolUse],
708            matcher: Some("[invalid regex".to_string()), // Invalid regex
709            hook_type: "command".to_string(),
710            command: "test.sh".to_string(),
711            timeout: None,
712            description: None,
713        };
714
715        let result = validate_hook_config(&config, temp.path());
716        assert!(result.is_err());
717        assert!(result.unwrap_err().to_string().contains("Invalid regex pattern"));
718    }
719
720    #[test]
721    fn test_validate_hook_config_unsupported_type() {
722        let temp = tempdir().unwrap();
723
724        let config = HookConfig {
725            events: vec![HookEvent::PreToolUse],
726            matcher: Some(".*".to_string()),
727            hook_type: "webhook".to_string(), // Unsupported type
728            command: "test.sh".to_string(),
729            timeout: None,
730            description: None,
731        };
732
733        let result = validate_hook_config(&config, temp.path());
734        assert!(result.is_err());
735        assert!(result.unwrap_err().to_string().contains("Only 'command' hook type"));
736    }
737
738    #[test]
739    fn test_validate_hook_config_script_exists() {
740        let temp = tempdir().unwrap();
741
742        // Create the expected directory structure with script
743        // Scripts are in .claude/scripts/ directory
744        let claude_dir = temp.path().join(".claude");
745        let scripts_dir = claude_dir.join("scripts");
746        fs::create_dir_all(&scripts_dir).unwrap();
747
748        let script_path = scripts_dir.join("test.sh");
749        fs::write(&script_path, "#!/bin/bash\necho test").unwrap();
750
751        let config = HookConfig {
752            events: vec![HookEvent::PreToolUse],
753            matcher: Some(".*".to_string()),
754            hook_type: "command".to_string(),
755            command: ".claude/scripts/test.sh".to_string(),
756            timeout: None,
757            description: None,
758        };
759
760        // Hooks are merged into settings.local.json, but for validation purposes
761        // we need a reference path. Use the project root.
762        let settings_path = temp.path().join(".claude").join("settings.local.json");
763        fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
764        let result = validate_hook_config(&config, &settings_path);
765
766        // Since the script exists at the expected location, this should succeed
767        assert!(result.is_ok(), "Expected validation to succeed, but got: {:?}", result);
768    }
769
770    #[test]
771    fn test_validate_hook_config_script_not_exists() {
772        let temp = tempdir().unwrap();
773
774        let config = HookConfig {
775            events: vec![HookEvent::PreToolUse],
776            matcher: Some(".*".to_string()),
777            hook_type: "command".to_string(),
778            command: ".claude/scripts/nonexistent.sh".to_string(),
779            timeout: None,
780            description: None,
781        };
782
783        // Pass the hook file path
784        let hook_path = temp.path().join(".claude").join("agpm").join("hooks").join("test.json");
785        let result = validate_hook_config(&config, &hook_path);
786        assert!(result.is_err());
787        assert!(result.unwrap_err().to_string().contains("non-existent script"));
788    }
789
790    #[test]
791    fn test_validate_hook_config_non_claude_path() -> anyhow::Result<()> {
792        let temp = tempdir().unwrap();
793
794        // Test with a command that doesn't start with .claude/scripts/
795        let config = HookConfig {
796            events: vec![HookEvent::PreToolUse],
797            matcher: Some(".*".to_string()),
798            hook_type: "command".to_string(),
799            command: "/usr/bin/echo".to_string(), // Absolute path not in .claude
800            timeout: None,
801            description: None,
802        };
803
804        let result = validate_hook_config(&config, temp.path());
805        // Should pass - we don't validate non-.claude paths
806        result?;
807        Ok(())
808    }
809
810    #[test]
811    fn test_convert_to_claude_format_session_start() {
812        // Test SessionStart hook without matcher
813        let mut hook_configs = HashMap::new();
814        hook_configs.insert(
815            "session-hook".to_string(),
816            HookConfig {
817                events: vec![HookEvent::SessionStart],
818                matcher: None, // No matcher for session events
819                hook_type: "command".to_string(),
820                command: "echo 'session started'".to_string(),
821                timeout: Some(1000),
822                description: Some("Session start hook".to_string()),
823            },
824        );
825
826        let result = convert_to_claude_format(hook_configs).unwrap();
827        let expected = serde_json::json!({
828            "SessionStart": [
829                {
830                    "hooks": [
831                        {
832                            "type": "command",
833                            "command": "echo 'session started'",
834                            "timeout": 1000
835                        }
836                    ]
837                }
838            ]
839        });
840
841        assert_eq!(result, expected);
842    }
843
844    #[test]
845    fn test_convert_to_claude_format_with_matcher() {
846        // Test PreToolUse hook with matcher
847        let mut hook_configs = HashMap::new();
848        hook_configs.insert(
849            "tool-hook".to_string(),
850            HookConfig {
851                events: vec![HookEvent::PreToolUse],
852                matcher: Some("Bash|Write".to_string()),
853                hook_type: "command".to_string(),
854                command: "echo 'before tool use'".to_string(),
855                timeout: None,
856                description: None,
857            },
858        );
859
860        let result = convert_to_claude_format(hook_configs).unwrap();
861        let expected = serde_json::json!({
862            "PreToolUse": [
863                {
864                    "matcher": "Bash|Write",
865                    "hooks": [
866                        {
867                            "type": "command",
868                            "command": "echo 'before tool use'"
869                        }
870                    ]
871                }
872            ]
873        });
874
875        assert_eq!(result, expected);
876    }
877
878    #[test]
879    fn test_convert_to_claude_format_multiple_events() {
880        // Test hook with multiple events
881        let mut hook_configs = HashMap::new();
882        hook_configs.insert(
883            "multi-event-hook".to_string(),
884            HookConfig {
885                events: vec![HookEvent::PreToolUse, HookEvent::PostToolUse],
886                matcher: Some(".*".to_string()),
887                hook_type: "command".to_string(),
888                command: "echo 'tool event'".to_string(),
889                timeout: Some(5000),
890                description: None,
891            },
892        );
893
894        let result = convert_to_claude_format(hook_configs).unwrap();
895
896        // Should appear in both events
897        assert!(result.get("PreToolUse").is_some());
898        assert!(result.get("PostToolUse").is_some());
899
900        let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
901        let post_tool = result.get("PostToolUse").unwrap().as_array().unwrap();
902
903        assert_eq!(pre_tool.len(), 1);
904        assert_eq!(post_tool.len(), 1);
905
906        // Both should have the matcher
907        assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*");
908        assert_eq!(post_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*");
909    }
910
911    #[test]
912    fn test_convert_to_claude_format_deduplication() {
913        // Test deduplication of identical session hooks
914        let mut hook_configs = HashMap::new();
915
916        // Add two identical SessionStart hooks
917        hook_configs.insert(
918            "hook1".to_string(),
919            HookConfig {
920                events: vec![HookEvent::SessionStart],
921                matcher: None,
922                hook_type: "command".to_string(),
923                command: "agpm update".to_string(),
924                timeout: None,
925                description: None,
926            },
927        );
928        hook_configs.insert(
929            "hook2".to_string(),
930            HookConfig {
931                events: vec![HookEvent::SessionStart],
932                matcher: None,
933                hook_type: "command".to_string(),
934                command: "agpm update".to_string(), // Same command
935                timeout: None,
936                description: None,
937            },
938        );
939
940        let result = convert_to_claude_format(hook_configs).unwrap();
941        let session_start = result.get("SessionStart").unwrap().as_array().unwrap();
942
943        // Should have only one group
944        assert_eq!(session_start.len(), 1);
945
946        // That group should have only one hook (deduplicated)
947        let hooks = session_start[0].get("hooks").unwrap().as_array().unwrap();
948        assert_eq!(hooks.len(), 1);
949        assert_eq!(hooks[0].get("command").unwrap().as_str().unwrap(), "agpm update");
950    }
951
952    #[test]
953    fn test_convert_to_claude_format_different_matchers() {
954        // Test hooks with different matchers should be in separate groups
955        let mut hook_configs = HashMap::new();
956
957        hook_configs.insert(
958            "bash-hook".to_string(),
959            HookConfig {
960                events: vec![HookEvent::PreToolUse],
961                matcher: Some("Bash".to_string()),
962                hook_type: "command".to_string(),
963                command: "echo 'bash tool'".to_string(),
964                timeout: None,
965                description: None,
966            },
967        );
968        hook_configs.insert(
969            "write-hook".to_string(),
970            HookConfig {
971                events: vec![HookEvent::PreToolUse],
972                matcher: Some("Write".to_string()),
973                hook_type: "command".to_string(),
974                command: "echo 'write tool'".to_string(),
975                timeout: None,
976                description: None,
977            },
978        );
979
980        let result = convert_to_claude_format(hook_configs).unwrap();
981        let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
982
983        // Should have two separate groups
984        assert_eq!(pre_tool.len(), 2);
985
986        // Find the groups by matcher
987        let bash_group = pre_tool
988            .iter()
989            .find(|g| g.get("matcher").and_then(|m| m.as_str()) == Some("Bash"))
990            .unwrap();
991        let write_group = pre_tool
992            .iter()
993            .find(|g| g.get("matcher").and_then(|m| m.as_str()) == Some("Write"))
994            .unwrap();
995
996        assert!(bash_group.get("hooks").unwrap().as_array().unwrap().len() == 1);
997        assert!(write_group.get("hooks").unwrap().as_array().unwrap().len() == 1);
998    }
999
1000    #[test]
1001    fn test_convert_to_claude_format_same_matcher() {
1002        // Test hooks with same matcher should be in same group
1003        let mut hook_configs = HashMap::new();
1004
1005        hook_configs.insert(
1006            "hook1".to_string(),
1007            HookConfig {
1008                events: vec![HookEvent::PreToolUse],
1009                matcher: Some("Bash".to_string()),
1010                hook_type: "command".to_string(),
1011                command: "echo 'first'".to_string(),
1012                timeout: None,
1013                description: None,
1014            },
1015        );
1016        hook_configs.insert(
1017            "hook2".to_string(),
1018            HookConfig {
1019                events: vec![HookEvent::PreToolUse],
1020                matcher: Some("Bash".to_string()), // Same matcher
1021                hook_type: "command".to_string(),
1022                command: "echo 'second'".to_string(),
1023                timeout: None,
1024                description: None,
1025            },
1026        );
1027
1028        let result = convert_to_claude_format(hook_configs).unwrap();
1029        let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
1030
1031        // Should have only one group
1032        assert_eq!(pre_tool.len(), 1);
1033
1034        // That group should have both hooks
1035        let hooks = pre_tool[0].get("hooks").unwrap().as_array().unwrap();
1036        assert_eq!(hooks.len(), 2);
1037        assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), "Bash");
1038    }
1039
1040    #[test]
1041    fn test_convert_to_claude_format_empty() {
1042        // Test empty hook configs
1043        let hook_configs = HashMap::new();
1044        let result = convert_to_claude_format(hook_configs).unwrap();
1045
1046        assert_eq!(result.as_object().unwrap().len(), 0);
1047    }
1048
1049    #[test]
1050    fn test_convert_to_claude_format_other_event() {
1051        // Test unknown/future event type
1052        let mut hook_configs = HashMap::new();
1053        hook_configs.insert(
1054            "future-hook".to_string(),
1055            HookConfig {
1056                events: vec![HookEvent::Other("FutureEvent".to_string())],
1057                matcher: None,
1058                hook_type: "command".to_string(),
1059                command: "echo 'future event'".to_string(),
1060                timeout: None,
1061                description: None,
1062            },
1063        );
1064
1065        let result = convert_to_claude_format(hook_configs).unwrap();
1066        let expected = serde_json::json!({
1067            "FutureEvent": [
1068                {
1069                    "hooks": [
1070                        {
1071                            "type": "command",
1072                            "command": "echo 'future event'"
1073                        }
1074                    ]
1075                }
1076            ]
1077        });
1078
1079        assert_eq!(result, expected);
1080    }
1081
1082    #[test]
1083    fn test_hook_event_other_serialization() {
1084        // Test that Other variant serializes/deserializes correctly
1085        let other_event = HookEvent::Other("CustomEvent".to_string());
1086        let json = serde_json::to_string(&other_event).unwrap();
1087        assert_eq!(json, r#""CustomEvent""#);
1088
1089        let parsed: HookEvent = serde_json::from_str(&json).unwrap();
1090        if let HookEvent::Other(event_name) = parsed {
1091            assert_eq!(event_name, "CustomEvent");
1092        } else {
1093            panic!("Expected Other variant");
1094        }
1095    }
1096}