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}