Skip to main content

cli/
impact_hook.rs

1//! `mushroomdb impact-hook` — the optional `PreToolUse` hook body for edits.
2//!
3//! Claude Code runs this before an `Edit`, a `Write` or a `MultiEdit`, handing
4//! it the tool call as JSON on stdin. The file about to change has a blast
5//! radius the graph already knows — what imports it, what usually changes with
6//! it, which tests cover it — and that is exactly the thing an assistant
7//! otherwise finds out by reading half the repository, or does not find out at
8//! all. So the hook says it, in one line, before the edit lands.
9//!
10//! # Why it never blocks
11//!
12//! Unlike [`crate::intercept`], this hook has no opinion about whether the
13//! edit should happen: it exits 0 always, and the only thing it can do is put
14//! a sentence in front of the change. That is what makes the budget the real
15//! constraint rather than the accuracy — a wrong redirect costs a tool call, a
16//! wrong line of context costs [`MAX_CONTEXT_BYTES`] bytes of attention.
17//!
18//! Every failure is silence: a payload that will not parse, a store that is
19//! missing or busy, a path the graph has no `File` for. Silence here means the
20//! edit proceeds exactly as it would with no hook installed.
21
22use crate::hook::{cut_to, open_for_hook};
23use core_api::repograph::{impact, ImpactOptions, ImpactReport};
24use std::collections::BTreeSet;
25use std::fmt::Write as _;
26use std::path::{Path, PathBuf};
27
28/// The most this hook may add to a turn. It fires before every edit, so the
29/// line has to be worth reading at a glance and cheap enough to pay for on a
30/// long editing session; past this it stops being a nudge and becomes
31/// something to skim past.
32pub const MAX_CONTEXT_BYTES: usize = 600;
33
34/// Paths named on each of the three lists. Three is what fits alongside the
35/// counts inside the budget, and a fourth co-change partner has never been the
36/// one that decided anything.
37const MAX_NAMED: usize = 3;
38
39/// The payload's edited file, as written. `None` for a missing or non-string
40/// `tool_input.file_path` — neither needs the store to be opened.
41fn edited_path(input: &serde_json::Value) -> Option<&str> {
42    input["tool_input"]["file_path"]
43        .as_str()
44        .filter(|p| !p.trim().is_empty())
45}
46
47/// Whether `path` looks like a test rather than a dependency.
48///
49/// A heuristic on the path alone, because that is all the graph stores about a
50/// file's role. It only ever decides which of three lists a path is printed
51/// on, so a miss costs a reader nothing but a slightly worse label.
52fn looks_like_test(path: &str) -> bool {
53    let lower = path.to_ascii_lowercase();
54    lower.starts_with("test/")
55        || lower.starts_with("tests/")
56        || lower.contains("/test/")
57        || lower.contains("/tests/")
58        || lower.contains("_test.")
59        || lower.contains(".test.")
60        || lower.contains("/test_")
61        || lower.starts_with("test_")
62        || lower.contains("_spec.")
63        || lower.contains(".spec.")
64}
65
66/// The store's own idea of where the repository is, as the `ingest-git` marker
67/// recorded it.
68fn recorded_repo(db: &crate::structure::Db) -> Option<PathBuf> {
69    match db
70        .node_ref(crate::ingest_git::SYNC_KEY)
71        .and_then(|n| n.prop("repo"))
72    {
73        Some(core_api::Value::Str(s)) if !s.is_empty() => Some(PathBuf::from(s)),
74        _ => None,
75    }
76}
77
78/// The key the graph would hold for `path`, which is always repo-relative with
79/// `/` separators.
80///
81/// Claude Code sends an absolute path, so the common case is stripping the
82/// recorded repository root off the front. A relative path is taken as already
83/// relative to that root — that is the only root it could be relative to — and
84/// a leading `./` is dropped either way. An absolute path *outside* the
85/// recorded repository belongs to some other checkout and resolves to nothing:
86/// answering about a same-named file in this one would be worse than silence.
87///
88/// # It is lexical, deliberately
89///
90/// Nothing here touches the filesystem: no `canonicalize`, no `metadata`, no
91/// symlink resolution. Two consequences a caller should know about, both of
92/// which end in silence rather than in a wrong answer:
93///
94/// - `..` components are **dropped**, not resolved. `a/../b/x.rs` becomes
95///   `a/b/x.rs`, which is very unlikely to be a key the graph holds, so such a
96///   path simply finds nothing.
97/// - A path that reaches the repository by a **different spelling** than the
98///   recorded root — through a symlink, or macOS's `/var` → `/private/var` —
99///   does not match the prefix and finds nothing either.
100///
101/// Resolving either would mean `stat`-ing paths inside a hook that runs before
102/// every edit, to rescue cases a host does not produce: Claude Code sends the
103/// path it opened the file at, and the marker records the root `git rev-parse`
104/// printed. Silence on the odd one out is the cheaper trade.
105fn repo_relative(path: &str, repo: Option<&Path>) -> Option<String> {
106    let p = Path::new(path);
107    let rel = if p.is_absolute() {
108        p.strip_prefix(repo?).ok()?
109    } else {
110        p.strip_prefix("./").unwrap_or(p)
111    };
112    let joined = rel
113        .components()
114        .filter_map(|c| match c {
115            std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
116            _ => None,
117        })
118        .collect::<Vec<_>>()
119        .join("/");
120    (!joined.is_empty()).then_some(joined)
121}
122
123/// The blast radius of editing `path`, in one line, or `None` when the graph
124/// has nothing to say about it.
125///
126/// The three lists are disjoint, and each path lands on the most specific one
127/// that claims it. A test that also imports the file is a test — that is the
128/// more useful thing to know about it — and a file that both imports this one
129/// and changes with it is named as a caller, because an import is a stated
130/// dependency and a co-change is a statistic about the same relationship.
131/// Repeating it would spend the budget saying one thing twice.
132#[must_use]
133fn render(report: &ImpactReport) -> Option<String> {
134    let file = report.files.first()?;
135
136    let mut tests: Vec<&str> = Vec::new();
137    let mut callers: Vec<&str> = Vec::new();
138    for p in &file.importers {
139        if looks_like_test(&p.path) {
140            tests.push(&p.path);
141        } else {
142            callers.push(&p.path);
143        }
144    }
145    let mut partners: Vec<&str> = Vec::new();
146    for p in &file.partners {
147        if looks_like_test(&p.path) {
148            if !tests.contains(&p.path.as_str()) {
149                tests.push(&p.path);
150            }
151        } else if !callers.contains(&p.path.as_str()) {
152            partners.push(&p.path);
153        }
154    }
155
156    let mut sections: Vec<String> = Vec::new();
157    if !callers.is_empty() {
158        // `(N)` counts what this line is about — the non-test importers — so a
159        // reader can tell a three-name list that is complete from one the
160        // display cap shortened. It is deliberately *not* the file's fan-in:
161        // `impact` stops at `ImpactOptions::max_importers`, so when its list
162        // came back full the true count is unknown and the line says `+more`
163        // rather than reporting the cap as if it were the answer.
164        let truncated = file.importers.len() >= ImpactOptions::default().max_importers;
165        sections.push(format!(
166            "callers {} ({}{})",
167            join_capped(&callers),
168            callers.len(),
169            if truncated { "+more" } else { "" }
170        ));
171    }
172    if !partners.is_empty() {
173        sections.push(format!("changes with {}", join_capped(&partners)));
174    }
175    if !tests.is_empty() {
176        sections.push(format!("tests {}", join_capped(&tests)));
177    }
178    if sections.is_empty() {
179        // A file nothing imports, nothing changes with and nothing tests has
180        // no blast radius, and "no blast radius" is not worth a turn's
181        // attention.
182        return None;
183    }
184
185    let mut out = format!("impact of editing {}: ", file.path);
186    for (i, s) in sections.iter().enumerate() {
187        if i > 0 {
188            let _ = write!(out, "; ");
189        }
190        out.push_str(s);
191    }
192    Some(cut_to(out, MAX_CONTEXT_BYTES))
193}
194
195/// At most [`MAX_NAMED`] paths, comma-separated, with `…` where the rest were.
196fn join_capped(paths: &[&str]) -> String {
197    let shown = paths.len().min(MAX_NAMED);
198    let mut out = paths[..shown].join(", ");
199    if paths.len() > shown {
200        out.push('…');
201    }
202    out
203}
204
205/// The whole hook body: parse the payload, open the store, render the radius.
206///
207/// `None` for every failure as well as for every file the graph has nothing to
208/// say about, because the caller's only two options are "add this line" and
209/// "stay out of the way".
210#[must_use]
211pub fn run(db_dir: &Path, payload: &str) -> Option<String> {
212    let input: serde_json::Value = serde_json::from_str(payload).ok()?;
213    let path = edited_path(&input)?;
214    let db = open_for_hook(db_dir)?;
215    let key = repo_relative(path, recorded_repo(&db).as_deref())?;
216    let report = impact(&db, &[key], &BTreeSet::new(), &ImpactOptions::default());
217    render(&report)
218}
219
220#[cfg(test)]
221mod tests {
222    use super::{looks_like_test, repo_relative};
223    use std::path::Path;
224
225    #[test]
226    fn paths_resolve_against_the_recorded_root() {
227        let repo = Path::new("/home/me/proj");
228        assert_eq!(
229            repo_relative("/home/me/proj/src/lib.rs", Some(repo)).as_deref(),
230            Some("src/lib.rs")
231        );
232        assert_eq!(
233            repo_relative("./src/lib.rs", Some(repo)).as_deref(),
234            Some("src/lib.rs")
235        );
236        assert_eq!(
237            repo_relative("src/lib.rs", None).as_deref(),
238            Some("src/lib.rs"),
239            "a relative path needs no root"
240        );
241        assert_eq!(repo_relative("/etc/hosts", Some(repo)), None);
242        assert_eq!(repo_relative("/etc/hosts", None), None);
243        assert_eq!(repo_relative("./", Some(repo)), None);
244    }
245
246    #[test]
247    fn tests_are_told_apart_from_dependencies() {
248        for yes in [
249            "tests/install.rs",
250            "crates/cli/tests/install.rs",
251            "src/install_test.rs",
252            "web/app.test.ts",
253            "app/models_spec.rb",
254        ] {
255            assert!(looks_like_test(yes), "{yes}");
256        }
257        for no in ["src/install.rs", "crates/cli/src/latest.rs", "protest/a.rs"] {
258            assert!(!looks_like_test(no), "{no}");
259        }
260    }
261
262    #[test]
263    fn a_dot_dot_path_is_flattened_rather_than_resolved() {
264        let repo = Path::new("/home/me/proj");
265        assert_eq!(
266            repo_relative("/home/me/proj/src/../src/lib.rs", Some(repo)).as_deref(),
267            Some("src/src/lib.rs"),
268            "lexical only: `..` is dropped, so the key simply does not match"
269        );
270    }
271}