1use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31
32#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37pub struct HooksConfig {
38 #[serde(flatten)]
40 pub events: HashMap<String, Vec<HookMatcher>>,
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize)]
45pub struct HookMatcher {
46 pub matcher: Option<String>,
53 pub hooks: Vec<HookCommand>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
60#[serde(rename_all = "lowercase")]
61pub enum HookFailMode {
62 #[default]
65 Open,
66 Closed,
70}
71
72#[derive(Debug, Clone, Deserialize, Serialize, Default)]
74pub struct HookCommand {
75 pub r#type: HookCommandType,
77 pub command: Option<String>,
79 pub url: Option<String>,
81 pub prompt: Option<String>,
84 #[serde(default = "default_timeout")]
86 pub timeout: u64,
87 pub status_message: Option<String>,
89 #[serde(default)]
91 pub once: bool,
92 #[serde(default)]
94 pub r#async: bool,
95 #[serde(default)]
98 pub async_rewake: bool,
99 #[serde(default)]
102 pub fail_mode: HookFailMode,
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
107#[serde(rename_all = "lowercase")]
108pub enum HookCommandType {
109 #[default]
110 Command,
111 Http,
112 Prompt,
113 Agent,
114}
115
116fn default_timeout() -> u64 {
117 5
118}
119
120pub fn matches_hook(matcher: &Option<String>, tool_name: &str, args: &serde_json::Value) -> bool {
128 let Some(pattern) = matcher else {
129 return true;
130 };
131
132 if let Some(paren_pos) = pattern.find('(') {
133 let tool_pat = &pattern[..paren_pos];
134 if tool_name != tool_pat {
135 return false;
136 }
137 let arg_pat = pattern[paren_pos + 1..].trim_end_matches(')');
138 if let Some(first) = first_string_arg(args) {
139 return glob_match(arg_pat, &first);
140 }
141 return false;
142 }
143
144 tool_name == pattern.as_str()
145}
146
147fn first_string_arg(args: &serde_json::Value) -> Option<String> {
151 let obj = args.as_object()?;
152 for key in &["command", "path", "goal", "input"] {
153 if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) {
154 return Some(v.to_string());
155 }
156 }
157 obj.values().find_map(|v| v.as_str().map(|s| s.to_string()))
158}
159
160fn glob_match(pattern: &str, value: &str) -> bool {
162 if let Some(prefix) = pattern.strip_suffix('*') {
163 value.starts_with(prefix)
164 } else {
165 value == pattern
166 }
167}
168
169pub fn load_hooks_config(dirs: &[std::path::PathBuf]) -> HooksConfig {
176 for dir in dirs {
177 let path = dir.join("hooks.json");
178 if path.exists() {
179 if let Ok(text) = std::fs::read_to_string(&path) {
180 if let Ok(cfg) = serde_json::from_str::<HooksConfig>(&text) {
181 return cfg;
182 }
183 }
184 }
185 }
186 HooksConfig::default()
187}
188
189#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn hooks_config_deserializes_from_json() {
197 let json = r#"
198 {
199 "PreToolCall": [
200 {
201 "matcher": "Bash",
202 "hooks": [
203 { "type": "command", "command": "/usr/local/bin/check.sh", "timeout": 10 }
204 ]
205 }
206 ]
207 }"#;
208 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
209 assert!(cfg.events.contains_key("PreToolCall"));
210 let matchers = &cfg.events["PreToolCall"];
211 assert_eq!(matchers.len(), 1);
212 assert_eq!(matchers[0].matcher.as_deref(), Some("Bash"));
213 assert_eq!(matchers[0].hooks.len(), 1);
214 assert_eq!(matchers[0].hooks[0].timeout, 10);
215 assert_eq!(matchers[0].hooks[0].r#type, HookCommandType::Command);
216 }
217
218 #[test]
219 fn hooks_config_empty_is_default() {
220 let cfg: HooksConfig = serde_json::from_str("{}").unwrap();
221 assert!(cfg.events.is_empty());
222
223 let cfg = load_hooks_config(&[]);
224 assert!(cfg.events.is_empty());
225 }
226
227 #[test]
228 fn matcher_none_matches_all_tools() {
229 let args = serde_json::json!({"command": "ls"});
230 assert!(matches_hook(&None, "Bash", &args));
231 assert!(matches_hook(&None, "Write", &args));
232 assert!(matches_hook(&None, "anything", &serde_json::json!({})));
233 }
234
235 #[test]
236 fn matcher_tool_name_exact() {
237 let args = serde_json::json!({});
238 let m = Some("Bash".to_string());
239 assert!(matches_hook(&m, "Bash", &args));
240 assert!(!matches_hook(&m, "Write", &args));
241 assert!(!matches_hook(&m, "Read", &args));
242 }
243
244 #[test]
245 fn matcher_tool_name_with_arg_prefix() {
246 let m = Some("Bash(git *)".to_string());
247 let git_args = serde_json::json!({"command": "git status"});
248 let ls_args = serde_json::json!({"command": "ls -la"});
249 assert!(matches_hook(&m, "Bash", &git_args));
250 assert!(!matches_hook(&m, "Bash", &ls_args));
251 }
252
253 #[test]
254 fn matcher_tool_name_with_arg_prefix_no_match() {
255 let m = Some("Bash(git *)".to_string());
256 let args = serde_json::json!({"command": "rm -rf /"});
257 assert!(!matches_hook(&m, "Bash", &args));
258 }
259
260 #[test]
261 fn matcher_tool_name_mismatch_with_arg_pattern() {
262 let m = Some("Bash(git *)".to_string());
263 let args = serde_json::json!({"command": "git status"});
264 assert!(!matches_hook(&m, "Write", &args));
266 }
267
268 #[test]
269 fn matcher_path_arg_used_for_write_file() {
270 let m = Some("Write(src/*)".to_string());
271 let src_args = serde_json::json!({"path": "src/main.rs"});
272 let other_args = serde_json::json!({"path": "tests/foo.rs"});
273 assert!(matches_hook(&m, "Write", &src_args));
274 assert!(!matches_hook(&m, "Write", &other_args));
275 }
276
277 #[test]
278 fn load_hooks_config_reads_from_dir() {
279 let tmp = tempfile::tempdir().unwrap();
280 let json = r#"{"PreToolCall": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#;
281 std::fs::write(tmp.path().join("hooks.json"), json).unwrap();
282 let cfg = load_hooks_config(&[tmp.path().to_path_buf()]);
283 assert!(cfg.events.contains_key("PreToolCall"));
284 }
285
286 #[test]
287 fn load_hooks_config_skips_missing_file() {
288 let tmp = tempfile::tempdir().unwrap();
289 let cfg = load_hooks_config(&[tmp.path().to_path_buf()]);
290 assert!(cfg.events.is_empty());
291 }
292
293 #[test]
294 fn hook_command_defaults() {
295 let json = r#"{"type": "command", "command": "echo hi"}"#;
296 let cmd: HookCommand = serde_json::from_str(json).unwrap();
297 assert_eq!(cmd.timeout, 5);
298 assert!(!cmd.once);
299 assert!(!cmd.r#async);
300 assert!(!cmd.async_rewake);
301 assert_eq!(cmd.fail_mode, HookFailMode::Open);
302 }
303
304 #[test]
305 fn hook_command_explicit_closed() {
306 let json = r#"{"type": "command", "command": "echo hi", "fail_mode": "closed"}"#;
307 let cmd: HookCommand = serde_json::from_str(json).unwrap();
308 assert_eq!(cmd.fail_mode, HookFailMode::Closed);
309 }
310
311 #[test]
312 fn hook_command_type_http_deserializes() {
313 let json = r#"{"type": "http", "url": "https://example.com/hook"}"#;
314 let cmd: HookCommand = serde_json::from_str(json).unwrap();
315 assert_eq!(cmd.r#type, HookCommandType::Http);
316 assert_eq!(cmd.url.as_deref(), Some("https://example.com/hook"));
317 }
318
319 #[test]
320 fn hook_command_type_prompt_deserializes() {
321 let json = r#"{"type": "prompt", "prompt": "Is this safe? $ARGUMENTS"}"#;
322 let cmd: HookCommand = serde_json::from_str(json).unwrap();
323 assert_eq!(cmd.r#type, HookCommandType::Prompt);
324 assert!(cmd.prompt.as_deref().unwrap().contains("$ARGUMENTS"));
325 }
326}