Skip to main content

recursive/hooks/
config.rs

1//! Hook configuration schema and loader.
2//!
3//! Hooks can be configured via a `hooks.json` file in `~/.recursive/` or
4//! `<workspace>/.recursive/`. The file maps event names to lists of hook
5//! matchers, each of which may filter by tool name / argument prefix.
6//!
7//! # Example `hooks.json`
8//!
9//! ```json
10//! {
11//!   "PreToolCall": [
12//!     {
13//!       "matcher": "run_shell(git *)",
14//!       "hooks": [
15//!         { "type": "command", "command": "~/.recursive/hooks/git-check.sh", "timeout": 10 }
16//!       ]
17//!     }
18//!   ],
19//!   "UserPromptSubmit": [
20//!     {
21//!       "hooks": [
22//!         { "type": "command", "command": "~/.recursive/hooks/log-prompt.sh", "async": true }
23//!       ]
24//!     }
25//!   ]
26//! }
27//! ```
28
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31
32/// Top-level hook configuration, loaded from `hooks.json`.
33///
34/// Keys are event names (e.g. `"PreToolCall"`, `"UserPromptSubmit"`),
35/// values are lists of matchers with associated hook commands.
36#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37pub struct HooksConfig {
38    /// Event name → list of matchers.
39    #[serde(flatten)]
40    pub events: HashMap<String, Vec<HookMatcher>>,
41}
42
43/// A hook matcher: an optional filter condition plus the hooks to run.
44#[derive(Debug, Clone, Deserialize, Serialize)]
45pub struct HookMatcher {
46    /// Optional match pattern. `None` matches all invocations.
47    ///
48    /// Supported syntax:
49    /// - `"Bash"` — tool name exact match
50    /// - `"Bash(git *)"` — tool name + `command` arg prefix
51    /// - `"Write(src/*)"` — tool name + `path` arg prefix
52    pub matcher: Option<String>,
53    /// Hook commands to execute when the matcher fires (in order).
54    pub hooks: Vec<HookCommand>,
55}
56
57/// Fail behavior for an external hook when the command times out,
58/// errors, exits non-zero, or returns unparseable output.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
60#[serde(rename_all = "lowercase")]
61pub enum HookFailMode {
62    /// Treat failures as `HookAction::Continue` — same as today.
63    /// Use for notification hooks (PostToolCall, SessionStart, etc.).
64    #[default]
65    Open,
66    /// Treat failures as a blocking decision:
67    /// - `PreToolCall`, `UserPromptSubmit`, `PermissionRequest` → `Error(reason)`
68    /// - All other events → `Continue` (Closed is a no-op for notifications).
69    Closed,
70}
71
72/// A single hook command entry.
73#[derive(Debug, Clone, Deserialize, Serialize, Default)]
74pub struct HookCommand {
75    /// How to run the hook.
76    pub r#type: HookCommandType,
77    /// Shell command string (used when `type = "command"`).
78    pub command: Option<String>,
79    /// HTTP endpoint URL (used when `type = "http"`).
80    pub url: Option<String>,
81    /// LLM prompt template with optional `$ARGUMENTS` placeholder
82    /// (used when `type = "prompt"` or `"agent"`).
83    pub prompt: Option<String>,
84    /// Seconds to wait before timing out (default: 5).
85    #[serde(default = "default_timeout")]
86    pub timeout: u64,
87    /// Optional human-readable spinner message shown in TUI.
88    pub status_message: Option<String>,
89    /// When `true`, the hook runs only the first time and is then ignored.
90    #[serde(default)]
91    pub once: bool,
92    /// When `true`, run hook in background — Agent continues immediately.
93    #[serde(default)]
94    pub r#async: bool,
95    /// When `true`, run in background but interrupt the Agent if hook
96    /// exits with code 2.
97    #[serde(default)]
98    pub async_rewake: bool,
99    /// Fail behavior on timeout / error / non-zero exit.
100    /// Default: `open` (fail-open, same as pre-Goal-281 behavior).
101    #[serde(default)]
102    pub fail_mode: HookFailMode,
103}
104
105/// Supported hook execution types.
106#[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
120// ── Matcher evaluation ─────────────────────────────────────────────
121
122/// Returns `true` if `input_tool` / `args` satisfy the given matcher pattern.
123///
124/// - `None` pattern matches everything.
125/// - `"Bash"` matches only that tool name.
126/// - `"Bash(git *)"` matches `Bash` with `command` starting with `"git "`.
127pub 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
147/// Extract the "primary" string argument from a tool args object.
148///
149/// Priority: `command`, `path`, `goal`, `input` — then any first string field.
150fn 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
160/// Minimal glob: supports a trailing `*` wildcard (prefix match).
161fn 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
169// ── Loader ─────────────────────────────────────────────────────────
170
171/// Load `HooksConfig` from the first `hooks.json` found in `dirs`.
172///
173/// Silently ignores parse errors and missing files; returns an empty config
174/// if none is found.
175pub 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// ── tests ──────────────────────────────────────────────────────────
190
191#[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        // tool name doesn't match even though arg would
265        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}