claude_agent/hooks/
rule.rs1use serde::{Deserialize, Serialize};
7
8use crate::config::HookConfig;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct HookRule {
15 #[serde(default)]
16 pub matcher: Option<String>,
17 pub hooks: Vec<HookAction>,
18}
19
20#[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 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}