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::structure::Db;
31use core_api::repograph;
32use std::path::Path;
33
34/// Shortest pattern worth redirecting. Two-character names are common enough
35/// as substrings that a grep for one is not evidence the symbol was meant.
36const MIN_PATTERN_LEN: usize = 3;
37
38/// Whether `pattern` is a bare identifier: `^[A-Za-z_][A-Za-z0-9_:]{2,}$`.
39///
40/// The `:` is there for the qualified names extractors produce (`Type::method`)
41/// — still one name, still something the graph can be asked about — and it is
42/// not a regex metacharacter, so a pattern containing it is no more likely to
43/// be a search than a plain name is.
44fn is_identifier(pattern: &str) -> bool {
45 if pattern.len() < MIN_PATTERN_LEN {
46 return false;
47 }
48 let mut chars = pattern.chars();
49 match chars.next() {
50 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
51 _ => return false,
52 }
53 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
54}
55
56/// The payload's search pattern, when it is a name the graph could hold.
57/// `None` for a missing or non-string `tool_input.pattern` and for anything
58/// that is not a bare identifier — neither needs the store to be opened.
59fn redirectable_pattern(input: &serde_json::Value) -> Option<&str> {
60 input["tool_input"]["pattern"]
61 .as_str()
62 .filter(|p| is_identifier(p))
63}
64
65/// Whether this `Grep` should be redirected, and what to say if so.
66///
67/// `input` is Claude Code's `PreToolUse` payload; the pattern is
68/// `tool_input.pattern`. `None` means "no opinion — run the grep".
69#[must_use]
70pub fn decide(db: &Db, input: &serde_json::Value) -> Option<String> {
71 let pattern = redirectable_pattern(input)?;
72 if repograph::named_symbols(db, pattern).is_empty() {
73 return None;
74 }
75 Some(format!(
76 "mushroomdb: '{pattern}' is a known symbol — call explore(\"{pattern}\") for its \
77 definition, callers and callees instead of grepping the tree."
78 ))
79}
80
81/// The whole hook body: parse the payload, open the store, decide.
82///
83/// `None` for every failure as well as for every pattern that passes through,
84/// because the caller's only two options are "block with this message" and
85/// "stay out of the way", and a store that is missing or busy is the second.
86///
87/// The pattern is tested before the store is opened. Opening one is the whole
88/// cost of this hook — measured at ~360 ms on a small store, against ~0 for
89/// the parse — and this runs before every single `Grep`, most of which are
90/// searches no identifier test will ever accept.
91#[must_use]
92pub fn run_intercept(db_dir: &Path, payload: &str) -> Option<String> {
93 let input: serde_json::Value = serde_json::from_str(payload).ok()?;
94 redirectable_pattern(&input)?;
95 // Guard the open, as `run_recall` does: `RealFs::new` runs
96 // `create_dir_all`, so a hook left behind by an uninstall — or pointed at
97 // a typo'd path — would otherwise create an empty store before every
98 // `Grep` and answer out of it.
99 if !db_dir.exists() {
100 return None;
101 }
102 // Read-only, no migration, no WAL repair: a hook in front of a tool call
103 // has no business writing to the store, and must never wait on a lock.
104 let db = core_api::GraphDb::open_with_options(
105 db_dir,
106 core_api::OpenOptions {
107 auto_migrate: false,
108 repair_wal: false,
109 read_only: true,
110 },
111 )
112 .ok()?;
113 decide(&db, &input)
114}
115
116#[cfg(test)]
117mod tests {
118 use super::is_identifier;
119
120 #[test]
121 fn identifiers_are_names_and_nothing_else() {
122 for ok in ["render_map", "RenderMap", "_private", "Type::method", "abc"] {
123 assert!(is_identifier(ok), "{ok} is an identifier");
124 }
125 for no in [
126 "ab",
127 "",
128 "1abc",
129 "render_.*",
130 "a|b",
131 "^abc",
132 "abc$",
133 "two words",
134 "path/to/file",
135 "fn abc(",
136 ] {
137 assert!(!is_identifier(no), "{no} is not an identifier");
138 }
139 }
140}