recursive/hooks/
config.rs1use 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, Deserialize, Serialize, Default)]
59pub struct HookCommand {
60 pub r#type: HookCommandType,
62 pub command: Option<String>,
64 pub url: Option<String>,
66 pub prompt: Option<String>,
69 #[serde(default = "default_timeout")]
71 pub timeout: u64,
72 pub status_message: Option<String>,
74 #[serde(default)]
76 pub once: bool,
77 #[serde(default)]
79 pub r#async: bool,
80 #[serde(default)]
83 pub async_rewake: bool,
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
88#[serde(rename_all = "lowercase")]
89pub enum HookCommandType {
90 #[default]
91 Command,
92 Http,
93 Prompt,
94 Agent,
95}
96
97fn default_timeout() -> u64 {
98 5
99}
100
101pub fn matches_hook(matcher: &Option<String>, tool_name: &str, args: &serde_json::Value) -> bool {
109 let Some(pattern) = matcher else {
110 return true;
111 };
112
113 if let Some(paren_pos) = pattern.find('(') {
114 let tool_pat = &pattern[..paren_pos];
115 if tool_name != tool_pat {
116 return false;
117 }
118 let arg_pat = pattern[paren_pos + 1..].trim_end_matches(')');
119 if let Some(first) = first_string_arg(args) {
120 return glob_match(arg_pat, &first);
121 }
122 return false;
123 }
124
125 tool_name == pattern.as_str()
126}
127
128fn first_string_arg(args: &serde_json::Value) -> Option<String> {
132 let obj = args.as_object()?;
133 for key in &["command", "path", "goal", "input"] {
134 if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) {
135 return Some(v.to_string());
136 }
137 }
138 obj.values().find_map(|v| v.as_str().map(|s| s.to_string()))
139}
140
141fn glob_match(pattern: &str, value: &str) -> bool {
143 if let Some(prefix) = pattern.strip_suffix('*') {
144 value.starts_with(prefix)
145 } else {
146 value == pattern
147 }
148}
149
150pub fn load_hooks_config(dirs: &[std::path::PathBuf]) -> HooksConfig {
157 for dir in dirs {
158 let path = dir.join("hooks.json");
159 if path.exists() {
160 if let Ok(text) = std::fs::read_to_string(&path) {
161 if let Ok(cfg) = serde_json::from_str::<HooksConfig>(&text) {
162 return cfg;
163 }
164 }
165 }
166 }
167 HooksConfig::default()
168}
169
170#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn hooks_config_deserializes_from_json() {
178 let json = r#"
179 {
180 "PreToolCall": [
181 {
182 "matcher": "run_shell",
183 "hooks": [
184 { "type": "command", "command": "/usr/local/bin/check.sh", "timeout": 10 }
185 ]
186 }
187 ]
188 }"#;
189 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
190 assert!(cfg.events.contains_key("PreToolCall"));
191 let matchers = &cfg.events["PreToolCall"];
192 assert_eq!(matchers.len(), 1);
193 assert_eq!(matchers[0].matcher.as_deref(), Some("run_shell"));
194 assert_eq!(matchers[0].hooks.len(), 1);
195 assert_eq!(matchers[0].hooks[0].timeout, 10);
196 assert_eq!(matchers[0].hooks[0].r#type, HookCommandType::Command);
197 }
198
199 #[test]
200 fn hooks_config_empty_is_default() {
201 let cfg: HooksConfig = serde_json::from_str("{}").unwrap();
202 assert!(cfg.events.is_empty());
203
204 let cfg = load_hooks_config(&[]);
205 assert!(cfg.events.is_empty());
206 }
207
208 #[test]
209 fn matcher_none_matches_all_tools() {
210 let args = serde_json::json!({"command": "ls"});
211 assert!(matches_hook(&None, "run_shell", &args));
212 assert!(matches_hook(&None, "write_file", &args));
213 assert!(matches_hook(&None, "anything", &serde_json::json!({})));
214 }
215
216 #[test]
217 fn matcher_tool_name_exact() {
218 let args = serde_json::json!({});
219 let m = Some("run_shell".to_string());
220 assert!(matches_hook(&m, "run_shell", &args));
221 assert!(!matches_hook(&m, "write_file", &args));
222 assert!(!matches_hook(&m, "read_file", &args));
223 }
224
225 #[test]
226 fn matcher_tool_name_with_arg_prefix() {
227 let m = Some("run_shell(git *)".to_string());
228 let git_args = serde_json::json!({"command": "git status"});
229 let ls_args = serde_json::json!({"command": "ls -la"});
230 assert!(matches_hook(&m, "run_shell", &git_args));
231 assert!(!matches_hook(&m, "run_shell", &ls_args));
232 }
233
234 #[test]
235 fn matcher_tool_name_with_arg_prefix_no_match() {
236 let m = Some("run_shell(git *)".to_string());
237 let args = serde_json::json!({"command": "rm -rf /"});
238 assert!(!matches_hook(&m, "run_shell", &args));
239 }
240
241 #[test]
242 fn matcher_tool_name_mismatch_with_arg_pattern() {
243 let m = Some("run_shell(git *)".to_string());
244 let args = serde_json::json!({"command": "git status"});
245 assert!(!matches_hook(&m, "write_file", &args));
247 }
248
249 #[test]
250 fn matcher_path_arg_used_for_write_file() {
251 let m = Some("write_file(src/*)".to_string());
252 let src_args = serde_json::json!({"path": "src/main.rs"});
253 let other_args = serde_json::json!({"path": "tests/foo.rs"});
254 assert!(matches_hook(&m, "write_file", &src_args));
255 assert!(!matches_hook(&m, "write_file", &other_args));
256 }
257
258 #[test]
259 fn load_hooks_config_reads_from_dir() {
260 let tmp = tempfile::tempdir().unwrap();
261 let json = r#"{"PreToolCall": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#;
262 std::fs::write(tmp.path().join("hooks.json"), json).unwrap();
263 let cfg = load_hooks_config(&[tmp.path().to_path_buf()]);
264 assert!(cfg.events.contains_key("PreToolCall"));
265 }
266
267 #[test]
268 fn load_hooks_config_skips_missing_file() {
269 let tmp = tempfile::tempdir().unwrap();
270 let cfg = load_hooks_config(&[tmp.path().to_path_buf()]);
271 assert!(cfg.events.is_empty());
272 }
273
274 #[test]
275 fn hook_command_defaults() {
276 let json = r#"{"type": "command", "command": "echo hi"}"#;
277 let cmd: HookCommand = serde_json::from_str(json).unwrap();
278 assert_eq!(cmd.timeout, 5);
279 assert!(!cmd.once);
280 assert!(!cmd.r#async);
281 assert!(!cmd.async_rewake);
282 }
283
284 #[test]
285 fn hook_command_type_http_deserializes() {
286 let json = r#"{"type": "http", "url": "https://example.com/hook"}"#;
287 let cmd: HookCommand = serde_json::from_str(json).unwrap();
288 assert_eq!(cmd.r#type, HookCommandType::Http);
289 assert_eq!(cmd.url.as_deref(), Some("https://example.com/hook"));
290 }
291
292 #[test]
293 fn hook_command_type_prompt_deserializes() {
294 let json = r#"{"type": "prompt", "prompt": "Is this safe? $ARGUMENTS"}"#;
295 let cmd: HookCommand = serde_json::from_str(json).unwrap();
296 assert_eq!(cmd.r#type, HookCommandType::Prompt);
297 assert!(cmd.prompt.as_deref().unwrap().contains("$ARGUMENTS"));
298 }
299}