Skip to main content

atomcode_core/hook/
config.rs

1use super::{HookConfig, HookEvent};
2
3/// Check whether a tool-name matcher pattern matches a given tool name.
4///
5/// Supported patterns:
6/// - `None`  — matches everything (no filter).
7/// - `"*"`   — matches everything (explicit wildcard).
8/// - `"foo"` — exact match.
9/// - `"foo_*"` — prefix wildcard: matches any name starting with `"foo_"`.
10pub fn matches_tool(matcher: &Option<String>, tool_name: &str) -> bool {
11    match matcher {
12        None => true,
13        Some(pattern) => {
14            if pattern == "*" {
15                return true;
16            }
17            if let Some(prefix) = pattern.strip_suffix('*') {
18                tool_name.starts_with(prefix)
19            } else {
20                pattern == tool_name
21            }
22        }
23    }
24}
25
26/// Return all hooks that match a given event and (optionally) a tool name.
27///
28/// For tool-related events (`PreToolUse`, `PostToolUse`) the caller should pass
29/// the tool name so that per-tool matchers are evaluated. For session-level
30/// events the tool name is typically `None` and matchers are ignored.
31pub fn matching_hooks<'a>(
32    hooks: &'a [HookConfig],
33    event: HookEvent,
34    tool_name: Option<&str>,
35) -> Vec<&'a HookConfig> {
36    hooks
37        .iter()
38        .filter(|h| h.event == event)
39        .filter(|h| match tool_name {
40            Some(name) => matches_tool(&h.matcher, name),
41            // Session-level events: matcher is irrelevant, always include.
42            None => true,
43        })
44        .collect()
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use crate::hook::{HookConfig, HookEvent};
51
52    // ── matches_tool ─────────────────────────────────────────────
53
54    #[test]
55    fn none_matcher_matches_all() {
56        assert!(matches_tool(&None, "bash"));
57        assert!(matches_tool(&None, "edit_file"));
58        assert!(matches_tool(&None, "anything"));
59    }
60
61    #[test]
62    fn star_matcher_matches_all() {
63        let m = Some("*".to_string());
64        assert!(matches_tool(&m, "bash"));
65        assert!(matches_tool(&m, "edit_file"));
66        assert!(matches_tool(&m, "write_file"));
67    }
68
69    #[test]
70    fn exact_match_works() {
71        let m = Some("bash".to_string());
72        assert!(matches_tool(&m, "bash"));
73        assert!(!matches_tool(&m, "grep"));
74        assert!(!matches_tool(&m, "bash_extra"));
75    }
76
77    #[test]
78    fn prefix_wildcard_works() {
79        let m = Some("edit_*".to_string());
80        assert!(matches_tool(&m, "edit_file"));
81        assert!(matches_tool(&m, "edit_config"));
82        assert!(!matches_tool(&m, "write_file"));
83        assert!(!matches_tool(&m, "edit")); // no underscore, no match
84    }
85
86    // ── matching_hooks ───────────────────────────────────────────
87
88    fn make_hook(event: HookEvent, matcher: Option<&str>, cmd: &str) -> HookConfig {
89        HookConfig {
90            event,
91            matcher: matcher.map(String::from),
92            command: cmd.to_string(),
93            timeout_ms: 10_000,
94            plugin_root: None,
95        }
96    }
97
98    #[test]
99    fn matching_hooks_filters_by_event() {
100        let hooks = vec![
101            make_hook(HookEvent::PreToolUse, None, "pre.sh"),
102            make_hook(HookEvent::PostToolUse, None, "post.sh"),
103            make_hook(HookEvent::SessionStart, None, "start.sh"),
104        ];
105
106        let matched = matching_hooks(&hooks, HookEvent::PreToolUse, Some("bash"));
107        assert_eq!(matched.len(), 1);
108        assert_eq!(matched[0].command, "pre.sh");
109    }
110
111    #[test]
112    fn matching_hooks_filters_by_tool_name() {
113        let hooks = vec![
114            make_hook(HookEvent::PreToolUse, Some("bash"), "bash-hook.sh"),
115            make_hook(HookEvent::PreToolUse, Some("edit_*"), "edit-hook.sh"),
116            make_hook(HookEvent::PreToolUse, None, "catch-all.sh"),
117        ];
118
119        // "bash" matches the exact hook and the catch-all
120        let matched = matching_hooks(&hooks, HookEvent::PreToolUse, Some("bash"));
121        assert_eq!(matched.len(), 2);
122        assert_eq!(matched[0].command, "bash-hook.sh");
123        assert_eq!(matched[1].command, "catch-all.sh");
124
125        // "edit_file" matches the prefix hook and the catch-all
126        let matched = matching_hooks(&hooks, HookEvent::PreToolUse, Some("edit_file"));
127        assert_eq!(matched.len(), 2);
128        assert_eq!(matched[0].command, "edit-hook.sh");
129        assert_eq!(matched[1].command, "catch-all.sh");
130
131        // "grep" matches only the catch-all
132        let matched = matching_hooks(&hooks, HookEvent::PreToolUse, Some("grep"));
133        assert_eq!(matched.len(), 1);
134        assert_eq!(matched[0].command, "catch-all.sh");
135    }
136
137    #[test]
138    fn session_events_with_no_tool_name() {
139        let hooks = vec![
140            make_hook(HookEvent::SessionStart, Some("bash"), "should-match.sh"),
141            make_hook(HookEvent::SessionStart, None, "also-match.sh"),
142            make_hook(HookEvent::PreToolUse, None, "wrong-event.sh"),
143        ];
144
145        // Session events pass tool_name = None, all SessionStart hooks should match
146        let matched = matching_hooks(&hooks, HookEvent::SessionStart, None);
147        assert_eq!(matched.len(), 2);
148        assert_eq!(matched[0].command, "should-match.sh");
149        assert_eq!(matched[1].command, "also-match.sh");
150    }
151}