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    /// - `"run_shell"` — tool name exact match
50    /// - `"run_shell(git *)"` — tool name + `command` arg prefix
51    /// - `"write_file(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/// A single hook command entry.
58#[derive(Debug, Clone, Deserialize, Serialize, Default)]
59pub struct HookCommand {
60    /// How to run the hook.
61    pub r#type: HookCommandType,
62    /// Shell command string (used when `type = "command"`).
63    pub command: Option<String>,
64    /// HTTP endpoint URL (used when `type = "http"`).
65    pub url: Option<String>,
66    /// LLM prompt template with optional `$ARGUMENTS` placeholder
67    /// (used when `type = "prompt"` or `"agent"`).
68    pub prompt: Option<String>,
69    /// Seconds to wait before timing out (default: 5).
70    #[serde(default = "default_timeout")]
71    pub timeout: u64,
72    /// Optional human-readable spinner message shown in TUI.
73    pub status_message: Option<String>,
74    /// When `true`, the hook runs only the first time and is then ignored.
75    #[serde(default)]
76    pub once: bool,
77    /// When `true`, run hook in background — Agent continues immediately.
78    #[serde(default)]
79    pub r#async: bool,
80    /// When `true`, run in background but interrupt the Agent if hook
81    /// exits with code 2.
82    #[serde(default)]
83    pub async_rewake: bool,
84}
85
86/// Supported hook execution types.
87#[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
101// ── Matcher evaluation ─────────────────────────────────────────────
102
103/// Returns `true` if `input_tool` / `args` satisfy the given matcher pattern.
104///
105/// - `None` pattern matches everything.
106/// - `"run_shell"` matches only that tool name.
107/// - `"run_shell(git *)"` matches `run_shell` with `command` starting with `"git "`.
108pub 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
128/// Extract the "primary" string argument from a tool args object.
129///
130/// Priority: `command`, `path`, `goal`, `input` — then any first string field.
131fn 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
141/// Minimal glob: supports a trailing `*` wildcard (prefix match).
142fn 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
150// ── Loader ─────────────────────────────────────────────────────────
151
152/// Load `HooksConfig` from the first `hooks.json` found in `dirs`.
153///
154/// Silently ignores parse errors and missing files; returns an empty config
155/// if none is found.
156pub 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// ── tests ──────────────────────────────────────────────────────────
171
172#[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        // tool name doesn't match even though arg would
246        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}