Skip to main content

claude_agent/hooks/
rule.rs

1//! Shared hook rule and action types for hooks configuration.
2//!
3//! Used by plugin loader, skill frontmatter, and subagent frontmatter
4//! to define lifecycle hooks in a uniform format.
5
6use serde::{Deserialize, Serialize};
7
8use crate::config::HookConfig;
9
10/// A hook rule entry mapping an optional matcher to a list of actions.
11///
12/// Format: `{"matcher": "Write|Edit", "hooks": [{"type": "command", "command": "..."}]}`
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct HookRule {
15    #[serde(default)]
16    pub matcher: Option<String>,
17    pub hooks: Vec<HookAction>,
18}
19
20/// A single hook action within a rule.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct HookAction {
23    #[serde(rename = "type")]
24    pub hook_type: String,
25    #[serde(default)]
26    pub command: Option<String>,
27    #[serde(default)]
28    pub timeout: Option<u64>,
29}
30
31impl HookAction {
32    /// Create a `HookConfig` from this action, using the rule-level matcher.
33    ///
34    /// Returns `None` if `hook_type` is not `"command"` or `command` is missing.
35    pub fn to_hook_config(&self, rule_matcher: Option<&str>) -> Option<HookConfig> {
36        if self.hook_type != "command" {
37            return None;
38        }
39        let command = self.command.as_ref()?.clone();
40        let matcher = rule_matcher.map(String::from);
41        let timeout_secs = self.timeout;
42
43        if matcher.is_some() || timeout_secs.is_some() {
44            Some(HookConfig::Full {
45                command,
46                timeout_secs,
47                matcher,
48            })
49        } else {
50            Some(HookConfig::Command(command))
51        }
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn test_hook_rule_serde_roundtrip() {
61        let json = r#"{"matcher":"Write|Edit","hooks":[{"type":"command","command":"fmt.sh","timeout":10}]}"#;
62        let rule: HookRule = serde_json::from_str(json).unwrap();
63        assert_eq!(rule.matcher.as_deref(), Some("Write|Edit"));
64        assert_eq!(rule.hooks.len(), 1);
65        assert_eq!(rule.hooks[0].hook_type, "command");
66        assert_eq!(rule.hooks[0].command.as_deref(), Some("fmt.sh"));
67        assert_eq!(rule.hooks[0].timeout, Some(10));
68
69        let serialized = serde_json::to_string(&rule).unwrap();
70        let deserialized: HookRule = serde_json::from_str(&serialized).unwrap();
71        assert_eq!(deserialized.matcher, rule.matcher);
72        assert_eq!(deserialized.hooks.len(), 1);
73    }
74
75    #[test]
76    fn test_hook_rule_no_matcher() {
77        let json = r#"{"hooks":[{"type":"command","command":"check.sh"}]}"#;
78        let rule: HookRule = serde_json::from_str(json).unwrap();
79        assert!(rule.matcher.is_none());
80        assert_eq!(rule.hooks.len(), 1);
81    }
82
83    #[test]
84    fn test_hook_action_to_hook_config_command_only() {
85        let action = HookAction {
86            hook_type: "command".into(),
87            command: Some("echo hello".into()),
88            timeout: None,
89        };
90        let config = action.to_hook_config(None).unwrap();
91        assert!(matches!(config, HookConfig::Command(cmd) if cmd == "echo hello"));
92    }
93
94    #[test]
95    fn test_hook_action_to_hook_config_with_matcher() {
96        let action = HookAction {
97            hook_type: "command".into(),
98            command: Some("fmt.sh".into()),
99            timeout: None,
100        };
101        let config = action.to_hook_config(Some("Write|Edit")).unwrap();
102        match config {
103            HookConfig::Full {
104                command, matcher, ..
105            } => {
106                assert_eq!(command, "fmt.sh");
107                assert_eq!(matcher.as_deref(), Some("Write|Edit"));
108            }
109            _ => panic!("Expected Full config"),
110        }
111    }
112
113    #[test]
114    fn test_hook_action_to_hook_config_with_timeout() {
115        let action = HookAction {
116            hook_type: "command".into(),
117            command: Some("slow.sh".into()),
118            timeout: Some(30),
119        };
120        let config = action.to_hook_config(None).unwrap();
121        match config {
122            HookConfig::Full { timeout_secs, .. } => {
123                assert_eq!(timeout_secs, Some(30));
124            }
125            _ => panic!("Expected Full config"),
126        }
127    }
128
129    #[test]
130    fn test_hook_action_to_hook_config_non_command_type() {
131        let action = HookAction {
132            hook_type: "prompt".into(),
133            command: Some("ignored".into()),
134            timeout: None,
135        };
136        assert!(action.to_hook_config(None).is_none());
137    }
138
139    #[test]
140    fn test_hook_action_to_hook_config_missing_command() {
141        let action = HookAction {
142            hook_type: "command".into(),
143            command: None,
144            timeout: None,
145        };
146        assert!(action.to_hook_config(None).is_none());
147    }
148
149    #[test]
150    fn test_hook_action_to_hook_config_full_combination() {
151        let action = HookAction {
152            hook_type: "command".into(),
153            command: Some("lint.sh".into()),
154            timeout: Some(15),
155        };
156        let config = action.to_hook_config(Some("Bash")).unwrap();
157        match config {
158            HookConfig::Full {
159                command,
160                timeout_secs,
161                matcher,
162            } => {
163                assert_eq!(command, "lint.sh");
164                assert_eq!(timeout_secs, Some(15));
165                assert_eq!(matcher.as_deref(), Some("Bash"));
166            }
167            _ => panic!("Expected Full config"),
168        }
169    }
170}