Skip to main content

cli/
intercept.rs

1//! `mushroomdb intercept` — the optional `PreToolUse` hook body (experimental).
2//!
3//! Claude Code runs this before a `Grep`, handing it the tool call as JSON on
4//! stdin. When the pattern is a bare identifier the graph already holds as a
5//! symbol, the search is a question `explore` answers exactly — the definition,
6//! the callers and the callees, in one reply — where the grep returns every
7//! line the name appears on and leaves the reading to the model. Exit 2 with a
8//! message on stderr is Claude Code's way of saying so: the tool call is
9//! blocked and the message reaches the model.
10//!
11//! # Why it is this conservative
12//!
13//! A hook that blocks a search wrongly is far worse than one that never fires,
14//! so [`decide`] answers `Some` only where the graph provably has the better
15//! answer:
16//!
17//! - The pattern must be a bare identifier ([`is_identifier`]). Anything with
18//!   regex syntax in it — `.`, `*`, `|`, an anchor, a space — is a search, not
19//!   a name, and the graph has no opinion about it.
20//! - It must be at least three characters. `id` may well be a symbol, and a
21//!   grep for it is still almost certainly not a request for that symbol.
22//! - It must resolve to at least one `Symbol` node, through the same lookup
23//!   `context` uses for a bare name, so the `explore` the message points at is
24//!   one that will actually answer.
25//!
26//! Everything else — a store that will not open, a payload that will not parse,
27//! a missing field — is silence and exit 0, like every other hook this binary
28//! writes.
29
30use crate::hook::open_for_hook;
31use crate::structure::Db;
32use core_api::repograph;
33use std::path::Path;
34
35/// Shortest pattern worth redirecting. Two-character names are common enough
36/// as substrings that a grep for one is not evidence the symbol was meant.
37const MIN_PATTERN_LEN: usize = 3;
38
39/// Whether `pattern` is a bare identifier: `^[A-Za-z_][A-Za-z0-9_:]{2,}$`.
40///
41/// The `:` is there for the qualified names extractors produce (`Type::method`)
42/// — still one name, still something the graph can be asked about — and it is
43/// not a regex metacharacter, so a pattern containing it is no more likely to
44/// be a search than a plain name is.
45///
46/// Shared with [`crate::enrich`], which asks the same question of the tokens in
47/// a search result: the floor that makes a pattern worth a lookup is the floor
48/// that makes a matched word worth one.
49pub(crate) fn is_identifier(pattern: &str) -> bool {
50    if pattern.len() < MIN_PATTERN_LEN {
51        return false;
52    }
53    let mut chars = pattern.chars();
54    match chars.next() {
55        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
56        _ => return false,
57    }
58    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
59}
60
61/// The payload's search pattern, when it is a name the graph could hold.
62/// `None` for a missing or non-string `tool_input.pattern` and for anything
63/// that is not a bare identifier — neither needs the store to be opened.
64fn redirectable_pattern(input: &serde_json::Value) -> Option<&str> {
65    input["tool_input"]["pattern"]
66        .as_str()
67        .filter(|p| is_identifier(p))
68}
69
70/// Whether this `Grep` should be redirected, and what to say if so.
71///
72/// `input` is Claude Code's `PreToolUse` payload; the pattern is
73/// `tool_input.pattern`. `None` means "no opinion — run the grep".
74#[must_use]
75pub fn decide(db: &Db, input: &serde_json::Value) -> Option<String> {
76    let pattern = redirectable_pattern(input)?;
77    if repograph::named_symbols(db, pattern).is_empty() {
78        return None;
79    }
80    Some(format!(
81        "mushroomdb: '{pattern}' is a known symbol — call explore(\"{pattern}\") for its \
82         definition, callers and callees instead of grepping the tree."
83    ))
84}
85
86/// The whole hook body: parse the payload, open the store, decide.
87///
88/// `None` for every failure as well as for every pattern that passes through,
89/// because the caller's only two options are "block with this message" and
90/// "stay out of the way", and a store that is missing or busy is the second.
91///
92/// The pattern is tested before the store is opened. Opening one is the whole
93/// cost of this hook — measured at ~360 ms on a small store, against ~0 for
94/// the parse — and this runs before every single `Grep`, most of which are
95/// searches no identifier test will ever accept.
96#[must_use]
97pub fn run_intercept(db_dir: &Path, payload: &str) -> Option<String> {
98    let input: serde_json::Value = serde_json::from_str(payload).ok()?;
99    redirectable_pattern(&input)?;
100    let db = open_for_hook(db_dir)?;
101    decide(&db, &input)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::is_identifier;
107
108    #[test]
109    fn identifiers_are_names_and_nothing_else() {
110        for ok in ["render_map", "RenderMap", "_private", "Type::method", "abc"] {
111            assert!(is_identifier(ok), "{ok} is an identifier");
112        }
113        for no in [
114            "ab",
115            "",
116            "1abc",
117            "render_.*",
118            "a|b",
119            "^abc",
120            "abc$",
121            "two words",
122            "path/to/file",
123            "fn abc(",
124        ] {
125            assert!(!is_identifier(no), "{no} is not an identifier");
126        }
127    }
128}