Skip to main content

amont_runtime/hooks/
rust_tools.rs

1//! The three Rust hooks: `cargo fmt --check`, `cargo clippy`, `cargo test`.
2//!
3//! Scoped like every other language hook — they fire only when the commit (or
4//! push) touches Rust, and only in a directory that actually has a `Cargo.toml`.
5//! A Python repo never invokes cargo.
6//!
7//! Split across the two dispatchers by COST, matching what the other languages
8//! already do: `fmt` and `clippy` are pre-commit (as ruff and pyright are),
9//! `test` is pre-push (as `run-tests-js` is). Nobody wants to wait for a
10//! workspace test run on every commit.
11//!
12//! Each is a separate check rather than one "rust" hook, so `hook.skip` can
13//! disable them individually — `git config hook.skip clippy` when you are
14//! mid-refactor, without losing the formatting gate.
15
16use super::common::{
17    fail, fixing_enabled, hl, ok, repo_root, restage, run as run_tool, staged_files, warn, which,
18    Restaged,
19};
20use crate::check::Outcome;
21use crate::git;
22use std::collections::BTreeSet;
23use std::path::{Path, PathBuf};
24use std::process::{Command, Stdio};
25
26/// Files that mean "this commit touches Rust". `Cargo.toml` and `Cargo.lock`
27/// count: a dependency bump compiles differently without a single `.rs` edit,
28/// and that is exactly when clippy earns its keep.
29///
30/// Exported for the registry's drift guard: clippy and cargo-test consume this
31/// whole set while declaring only `.rs` plus a `Cargo.toml` opt-in.
32pub const RUST_PATHS: &[&str] = &[
33    ".rs",
34    "Cargo.toml",
35    "Cargo.lock",
36    "rustfmt.toml",
37    "clippy.toml",
38];
39
40/// What `cargo fmt` is handed. Exported so `registry.rs` declares the scope
41/// from the same constant — see `lint_json_yaml::EXTS`.
42pub const EXTS: &[&str] = &[".rs"];
43
44fn is_rust_path(f: &str) -> bool {
45    let name = f.rsplit('/').next().unwrap_or(f);
46    RUST_PATHS.iter().any(|pattern| {
47        if pattern.starts_with('.') {
48            name.ends_with(pattern)
49        } else {
50            name == *pattern
51        }
52    })
53}
54
55/// The nearest ancestor of `file` holding a `Cargo.toml`, bounded by the repo.
56///
57/// Not simply "the repo root": plenty of repos keep a Rust component in a
58/// subdirectory next to services in other languages, and cargo must run where
59/// the manifest is. `--workspace` then covers every member from that point, so
60/// one invocation per manifest root is enough.
61fn cargo_root_for(root: &str, file: &str) -> Option<PathBuf> {
62    let mut dir = Path::new(root).join(file);
63    dir.pop();
64    loop {
65        if dir.join("Cargo.toml").is_file() {
66            return Some(dir);
67        }
68        if dir == Path::new(root) || !dir.starts_with(root) {
69            return None;
70        }
71        if !dir.pop() {
72            return None;
73        }
74    }
75}
76
77fn cargo_roots<'a>(root: &str, files: impl Iterator<Item = &'a str>) -> Vec<PathBuf> {
78    let mut seen = BTreeSet::new();
79    for f in files.filter(|f| is_rust_path(f)) {
80        if let Some(d) = cargo_root_for(root, f) {
81            seen.insert(d);
82        }
83    }
84    seen.into_iter().collect()
85}
86
87/// True when `cargo <component> --version` works.
88///
89/// Only for separately-installable COMPONENTS — rustfmt and clippy, which a
90/// toolchain can legitimately lack. A missing one must warn and pass, never
91/// fail a commit for a tool the developer never chose.
92///
93/// Do NOT probe a BUILT-IN subcommand this way: `cargo test --version` is
94/// "unexpected argument '--version'", so the probe reports test as unavailable
95/// and the gate silently passes — it would never have run a test anywhere.
96fn component_available(dir: &Path, sub: &str) -> bool {
97    let Some(cargo) = which("cargo") else {
98        return false;
99    };
100    Command::new(cargo)
101        .arg(sub)
102        .arg("--version")
103        .current_dir(dir)
104        .stdin(Stdio::null())
105        .stdout(Stdio::null())
106        .stderr(Stdio::null())
107        .status()
108        .map(|s| s.success())
109        .unwrap_or(false)
110}
111
112fn cargo_argv() -> Option<Vec<String>> {
113    which("cargo").map(|c| vec![c])
114}
115
116/// Resolve cargo and verify the component, or warn and give up.
117///
118/// Split out from `each_root` because `fmt` now runs TWO passes — a `--check`
119/// and, when repairing, a write — and the second must not re-probe rustfmt
120/// (a second `cargo fmt --version` per manifest root) nor duplicate the
121/// resolution it would have to get identical.
122fn cargo_for(roots: &[PathBuf], component: Option<&str>, missing: &str) -> Option<Vec<String>> {
123    let argv = cargo_argv().or_else(|| {
124        warn(missing);
125        None
126    })?;
127    if let Some(c) = component {
128        for dir in roots {
129            if !component_available(dir, c) {
130                warn(missing);
131                return None;
132            }
133        }
134    }
135    Some(argv)
136}
137
138/// Run one cargo invocation in every manifest root. True when all succeeded.
139fn run_in_roots(
140    settings: &crate::config::Settings,
141    roots: &[PathBuf],
142    argv: &[String],
143    args: &[&str],
144) -> bool {
145    let extra: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
146    let mut all_ok = true;
147    for dir in roots {
148        let d = dir.to_string_lossy().into_owned();
149        if !run_tool(settings, &d, argv, &extra) {
150            all_ok = false;
151        }
152    }
153    all_ok
154}
155
156/// Shared shape: find the manifest roots, verify the component if there is one
157/// to verify, run the command in each, report once.
158///
159/// `component` is `Some` only for rustfmt/clippy; `None` means a built-in
160/// subcommand where cargo's own presence is the whole requirement.
161fn each_root(
162    settings: &crate::config::Settings,
163    roots: &[PathBuf],
164    component: Option<&str>,
165    args: &[&str],
166    missing: &str,
167) -> Option<bool> {
168    let argv = cargo_for(roots, component, missing)?;
169    Some(run_in_roots(settings, roots, &argv, args))
170}
171
172pub fn fmt(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
173    let files = staged_files(EXTS);
174    if files.is_empty() {
175        return Outcome::Passed;
176    }
177    let root = repo_root();
178    let roots = cargo_roots(&root, files.iter().map(String::as_str));
179    if roots.is_empty() {
180        return Outcome::Passed;
181    }
182    const MISSING: &str =
183        "Rust staged but rustfmt is not installed. `rustup component add rustfmt`.";
184    let Some(argv) = cargo_for(&roots, Some("fmt"), MISSING) else {
185        return Outcome::Unavailable;
186    };
187
188    // `--all -- --check` per the project convention. It inspects the working
189    // TREE rather than the index — and that is now correct, because the
190    // pre-commit stage holds the unstaged changes aside for the duration, so
191    // the tree IS the staged content. This comment used to call that "the same
192    // trade-off cargo fmt gives everyone", which was true of the observation
193    // and wrong about the conclusion: see `staged_only`.
194    if run_in_roots(settings, &roots, &argv, &["fmt", "--all", "--", "--check"]) {
195        ok(settings, "Rust formatting is clean");
196        return Outcome::Passed;
197    }
198
199    // The registry has always declared `Fix::Rewrite` for this check, and
200    // `amont list --json` reported `"fix":"rewrite"` — which `agents_md`
201    // explicitly tells agents to trust — while no fixing code existed
202    // anywhere. Only prettier and the manifest's externals ever called
203    // `restage`. Rather than downgrade the declaration, the fixing is now
204    // real.
205    if fixing_enabled(settings) && run_in_roots(settings, &roots, &argv, &["fmt", "--all"]) {
206        // The non-obvious guard: `cargo fmt --all` formats the WHOLE
207        // workspace, not just the staged files — but `restage` is handed the
208        // staged `.rs` list, so nothing the author did not stage is staged
209        // here. The formatter's other edits stay in the working tree, exactly
210        // as an unrelated unstaged change would.
211        match restage(&files) {
212            Restaged::Staged => {
213                ok(settings, "Rust reformatted and re-staged");
214                return Outcome::Fixed;
215            }
216            Restaged::Failed(stuck) => {
217                fail(&format!(
218                    "cargo fmt rewrote these files but {} failed — the index still holds the \
219                     UNFORMATTED content: {}",
220                    hl("git add"),
221                    stuck.join(", ")
222                ));
223                return Outcome::Failed;
224            }
225            // Nothing staged differed, so whatever `--check` objected to was
226            // outside the staged set. Fall through and report it.
227            Restaged::Nothing => {}
228        }
229    }
230
231    fail(&format!("Unformatted Rust. Run {}.", hl("cargo fmt --all")));
232    Outcome::Failed
233}
234
235pub fn clippy(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
236    // `staged_files` matches by suffix, which would also accept
237    // `vendor/NotCargo.toml`. `is_rust_path` compares the basename, so let it
238    // be the only filter rather than keeping two that disagree.
239    let files: Vec<String> = staged_files(&[])
240        .into_iter()
241        .filter(|f| is_rust_path(f))
242        .collect();
243    if files.is_empty() {
244        return Outcome::Passed;
245    }
246    let root = repo_root();
247    let roots = cargo_roots(&root, files.iter().map(String::as_str));
248    if roots.is_empty() {
249        return Outcome::Passed;
250    }
251    match each_root(
252        settings,
253        &roots,
254        Some("clippy"),
255        &[
256            "clippy",
257            "--workspace",
258            "--all-targets",
259            "--all-features",
260            "--",
261            "-D",
262            "warnings",
263        ],
264        "Rust staged but clippy is not installed. `rustup component add clippy`.",
265    ) {
266        None => Outcome::Unavailable,
267        Some(true) => {
268            ok(settings, "Clippy passed");
269            Outcome::Passed
270        }
271        Some(false) => {
272            fail(&format!(
273                "Clippy warnings. Fix them or run {}.",
274                hl("cargo clippy --fix")
275            ));
276            Outcome::Failed
277        }
278    }
279}
280
281/// pre-push. Mirrors `run-tests-js`: the range that is actually being pushed
282/// decides whether the suite runs, so a docs-only push costs nothing.
283///
284/// PER REF, not once for the whole push: `git push origin a b` carries two
285/// tips, and a single worktree checked out to the first one would run the
286/// second ref's tests against the first ref's tree — a real failure in the
287/// untested branch reported as a pass because nothing actually ran against
288/// it. Each ref that touches Rust gets its own worktree and its own verdict.
289pub fn test(
290    settings: &crate::config::Settings,
291    refs: &[crate::pushrefs::PushRef],
292    gate: &str,
293) -> Outcome {
294    // `Unavailable`, never `Passed`, when git will not answer — same argument
295    // and same wording as run-tests-js: a gate must not report green having
296    // asked nothing.
297    let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
298        super::common::warn("cargo-test: git would not answer — the gate did NOT run");
299        return Outcome::Unavailable;
300    };
301    let zero = git::stdout(&["hash-object", "--stdin"])
302        .map(|h| "0".repeat(h.len()))
303        .unwrap_or_else(|| "0".repeat(40));
304    let mut ran_any = false;
305    for r in refs {
306        let changed = crate::pushrefs::changed_files_for(r, &zero);
307        let roots = cargo_roots(&root, changed.iter().map(String::as_str));
308        if roots.is_empty() {
309            continue;
310        }
311        // Where THIS ref's suite runs decides what it is answering about.
312        // `_guard` owns the checkout for the length of this ref's run;
313        // dropping it removes the worktree before the next ref's begins.
314        let (where_, _guard) =
315            crate::pushed_tree::where_to_run(settings, &r.local_oid, &root, gate);
316        let roots: Vec<PathBuf> = roots
317            .iter()
318            .map(|rt| {
319                rt.strip_prefix(&root)
320                    .map(|rel| where_.join(rel))
321                    .unwrap_or_else(|_| rt.clone())
322            })
323            .collect();
324        match each_root(
325            settings,
326            &roots,
327            None,
328            &["test", "--workspace", "--all-features"],
329            "Rust changed but cargo is not installed.",
330        ) {
331            None => return Outcome::Unavailable,
332            Some(true) => ran_any = true,
333            Some(false) => {
334                fail("Rust tests failed. Push aborted.");
335                return Outcome::Failed;
336            }
337        }
338    }
339    if ran_any {
340        ok(settings, "Rust tests passed");
341    }
342    Outcome::Passed
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn recognises_rust_paths() {
351        assert!(is_rust_path("src/main.rs"));
352        assert!(is_rust_path("Cargo.toml"));
353        assert!(is_rust_path("crates/a/Cargo.lock"));
354        assert!(is_rust_path("rustfmt.toml"));
355        assert!(!is_rust_path("README.md"));
356        assert!(!is_rust_path("src/main.rsx"));
357        // A file merely CONTAINING the name is not the manifest.
358        assert!(!is_rust_path("docs/Cargo.toml.md"));
359        assert!(!is_rust_path("vendor/NotCargo.toml"));
360    }
361
362    #[test]
363    fn finds_the_nearest_manifest_not_the_repo_root() {
364        let tmp = std::env::temp_dir().join("amont-cargo-roots");
365        let _ = std::fs::remove_dir_all(&tmp);
366        let nested = tmp.join("services/engine");
367        std::fs::create_dir_all(nested.join("src")).unwrap();
368        std::fs::write(nested.join("Cargo.toml"), "[package]\n").unwrap();
369        let root = tmp.to_string_lossy().into_owned();
370
371        let got = cargo_roots(&root, ["services/engine/src/main.rs"].into_iter());
372        assert_eq!(got, vec![nested.clone()], "should find the nested manifest");
373
374        // A Rust file with no manifest anywhere above it is not a cargo project.
375        std::fs::create_dir_all(tmp.join("scripts")).unwrap();
376        let none = cargo_roots(&root, ["scripts/loose.rs"].into_iter());
377        assert!(none.is_empty(), "no manifest above it: {none:?}");
378        let _ = std::fs::remove_dir_all(&tmp);
379    }
380
381    #[test]
382    fn several_files_in_one_crate_yield_one_root() {
383        let tmp = std::env::temp_dir().join("amont-cargo-dedupe");
384        let _ = std::fs::remove_dir_all(&tmp);
385        std::fs::create_dir_all(tmp.join("src")).unwrap();
386        std::fs::write(tmp.join("Cargo.toml"), "[package]\n").unwrap();
387        let root = tmp.to_string_lossy().into_owned();
388        let got = cargo_roots(&root, ["src/a.rs", "src/b.rs", "Cargo.toml"].into_iter());
389        assert_eq!(got.len(), 1, "one cargo invocation, not three: {got:?}");
390        let _ = std::fs::remove_dir_all(&tmp);
391    }
392
393    #[test]
394    fn non_rust_files_select_nothing() {
395        let got = cargo_roots("/tmp", ["README.md", "a.py"].into_iter());
396        assert!(got.is_empty());
397    }
398}