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    // A pin bump changes which clippy judges the same source.
39    "rust-toolchain.toml",
40    "rust-toolchain",
41];
42
43/// What `cargo fmt` is handed. Exported so `registry.rs` declares the scope
44/// from the same constant — see `lint_json_yaml::EXTS`.
45pub const EXTS: &[&str] = &[".rs"];
46
47fn is_rust_path(f: &str) -> bool {
48    let name = f.rsplit('/').next().unwrap_or(f);
49    RUST_PATHS.iter().any(|pattern| {
50        if pattern.starts_with('.') {
51            name.ends_with(pattern)
52        } else {
53            name == *pattern
54        }
55    })
56}
57
58/// The nearest ancestor of `file` holding a `Cargo.toml`, bounded by the repo.
59///
60/// Not simply "the repo root": plenty of repos keep a Rust component in a
61/// subdirectory next to services in other languages, and cargo must run where
62/// the manifest is. `--workspace` then covers every member from that point, so
63/// one invocation per manifest root is enough.
64fn cargo_root_for(root: &str, file: &str) -> Option<PathBuf> {
65    let mut dir = Path::new(root).join(file);
66    dir.pop();
67    loop {
68        if dir.join("Cargo.toml").is_file() {
69            return Some(dir);
70        }
71        if dir == Path::new(root) || !dir.starts_with(root) {
72            return None;
73        }
74        if !dir.pop() {
75            return None;
76        }
77    }
78}
79
80fn cargo_roots<'a>(root: &str, files: impl Iterator<Item = &'a str>) -> Vec<PathBuf> {
81    let mut seen = BTreeSet::new();
82    for f in files.filter(|f| is_rust_path(f)) {
83        if let Some(d) = cargo_root_for(root, f) {
84            seen.insert(d);
85        }
86    }
87    seen.into_iter().collect()
88}
89
90/// True when `cargo <component> --version` works.
91///
92/// Only for separately-installable COMPONENTS — rustfmt and clippy, which a
93/// toolchain can legitimately lack. A missing one must warn and pass, never
94/// fail a commit for a tool the developer never chose.
95///
96/// Do NOT probe a BUILT-IN subcommand this way: `cargo test --version` is
97/// "unexpected argument '--version'", so the probe reports test as unavailable
98/// and the gate silently passes — it would never have run a test anywhere.
99fn component_available(cargo: &[String], dir: &Path, sub: &str) -> bool {
100    let Some((program, rest)) = cargo.split_first() else {
101        return false;
102    };
103    Command::new(program)
104        .args(rest)
105        .arg(sub)
106        .arg("--version")
107        .current_dir(dir)
108        .stdin(Stdio::null())
109        .stdout(Stdio::null())
110        .stderr(Stdio::null())
111        .status()
112        .map(|s| s.success())
113        .unwrap_or(false)
114}
115
116/// The toolchain `dir` (or an ancestor) pins, with the file that pins it:
117/// `rust-toolchain.toml`'s `[toolchain] channel`, or the legacy one-line
118/// `rust-toolchain`. The walk is rustup's own — it reads the nearest pin
119/// above the working directory — so what this finds is what rustup applies.
120fn pinned_toolchain(dir: &Path) -> Option<(PathBuf, String)> {
121    let mut d = Some(dir);
122    while let Some(cur) = d {
123        let toml = cur.join("rust-toolchain.toml");
124        if let Ok(text) = std::fs::read_to_string(&toml) {
125            if let Some(ch) = parse_toolchain_toml(&text) {
126                return Some((toml, ch));
127            }
128        }
129        let legacy = cur.join("rust-toolchain");
130        if let Ok(text) = std::fs::read_to_string(&legacy) {
131            let ch = text.lines().next().unwrap_or("").trim();
132            if !ch.is_empty() {
133                return Some((legacy, ch.to_string()));
134            }
135        }
136        d = cur.parent();
137    }
138    None
139}
140
141/// `channel = "1.94.1"` out of a `rust-toolchain.toml`, ignoring comments.
142fn parse_toolchain_toml(text: &str) -> Option<String> {
143    text.lines()
144        .map(str::trim)
145        .filter(|l| !l.starts_with('#'))
146        .find_map(|l| {
147            let rest = l
148                .strip_prefix("channel")?
149                .trim_start()
150                .strip_prefix('=')?
151                .trim();
152            let rest = rest.split('#').next()?.trim();
153            let v = rest.trim_matches(|c| c == '"' || c == '\'');
154            (!v.is_empty()).then(|| v.to_string())
155        })
156}
157
158/// A pin we can hold a `cargo --version` against: `1.94` or `1.94.1`. A
159/// channel name (`stable`, `nightly-2026-01-01`) is a moving target and is
160/// left to rustup.
161fn version_pin(pin: &str) -> Option<&str> {
162    let parts: Vec<&str> = pin.split('.').collect();
163    let numeric = (2..=3).contains(&parts.len())
164        && parts
165            .iter()
166            .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
167    numeric.then_some(pin)
168}
169
170/// `1.94.1` out of `cargo 1.94.1 (29ea6fb6a 2026-03-24)`, run where the pin
171/// applies so a rustup shim answers for that directory.
172fn cargo_version(cargo: &[String], dir: &Path) -> Option<String> {
173    let (program, rest) = cargo.split_first()?;
174    let out = Command::new(program)
175        .args(rest)
176        .arg("--version")
177        .current_dir(dir)
178        .stdin(Stdio::null())
179        .stderr(Stdio::null())
180        .output()
181        .ok()?;
182    let text = String::from_utf8_lossy(&out.stdout);
183    let v = text.split_whitespace().nth(1)?;
184    Some(v.to_string())
185}
186
187/// `1.94` matches `1.94.1`; `1.94.1` matches only itself.
188fn version_matches(pin: &str, version: &str) -> bool {
189    version == pin
190        || version
191            .strip_prefix(pin)
192            .is_some_and(|rest| rest.starts_with('.'))
193}
194
195/// The cargo invocation a directory means, as an argv. With a pin and a
196/// rustup: `rustup run <pin> cargo`, so the pin is applied by rustup AND
197/// exported (`RUSTUP_TOOLCHAIN`, PATH) to every `rustc` cargo spawns. Without
198/// either: the first `cargo` on PATH, as before.
199///
200/// Why not `which`: on a machine with a second cargo ahead of the rustup shim
201/// (Homebrew's `rust` formula in /usr/local/bin, measured 2026-09-21) it finds
202/// the wrong one and the pin is silently ignored — clippy then judges with
203/// lints CI never sees.
204///
205/// Why not the toolchain's cargo binary by path (`rustup which cargo`, the
206/// first shape of this fix): run directly, that cargo spawns each `rustc`
207/// through the PATH proxy with cwd set to the crate being compiled, and the
208/// proxy re-resolves the toolchain from THERE. A dependency that ships its own
209/// `rust-toolchain` — `convert_case` 0.10 pins 1.83.0 — was built by 1.83.0
210/// inside a 1.94.1 build: `E0514: crate compiled by an incompatible version
211/// of rustc`, on the release commit of the change itself. `rustup run` is
212/// the invocation that keeps one toolchain for the whole build.
213fn resolve_cargo(dir: &Path) -> Option<Vec<String>> {
214    if let Some((_, pin)) = pinned_toolchain(dir) {
215        if let Some(rustup) = which("rustup") {
216            return Some(vec![rustup, "run".into(), pin, "cargo".into()]);
217        }
218    }
219    which("cargo").map(|c| vec![c])
220}
221
222/// Resolve cargo, hold it against the pin, verify the component.
223///
224/// `Err(Outcome::Unavailable)` when there is no cargo or no component (warned,
225/// never failed: a tool the developer never chose cannot block a commit).
226/// `Err(Outcome::Failed)` when the cargo that would run is NOT the pinned
227/// toolchain: a green verdict from the wrong clippy is worth less than none,
228/// and a red one wastes time on lints that are not CI's.
229///
230/// Split out from `each_root` because `fmt` now runs TWO passes — a `--check`
231/// and, when repairing, a write — and the second must not re-probe rustfmt
232/// (a second `cargo fmt --version` per manifest root) nor duplicate the
233/// resolution it would have to get identical.
234fn cargo_for(
235    roots: &[PathBuf],
236    component: Option<&str>,
237    missing: &str,
238) -> Result<Vec<String>, Outcome> {
239    let first = roots
240        .first()
241        .map(PathBuf::as_path)
242        .unwrap_or(Path::new("."));
243    let Some(cargo) = resolve_cargo(first) else {
244        warn(missing);
245        return Err(Outcome::Unavailable);
246    };
247    for dir in roots {
248        let Some((file, pin)) = pinned_toolchain(dir) else {
249            continue;
250        };
251        let Some(got) = cargo_version(&cargo, dir) else {
252            // `rustup run <pin>` with nothing installed under that name
253            // answers nothing: say which toolchain to install rather than
254            // let the real run fail on rustup's own message.
255            fail(&format!(
256                "{} pins `{pin}` and no cargo answers for it. {}.",
257                file.display(),
258                hl(&format!("rustup toolchain install {pin}"))
259            ));
260            return Err(Outcome::Failed);
261        };
262        let Some(want) = version_pin(&pin) else {
263            continue;
264        };
265        if !version_matches(want, &got) {
266            fail(&format!(
267                "cargo is {got} but {} pins {want}, so this check would judge with a \
268                 toolchain CI never runs. Put the rustup shim first on PATH — a second \
269                 cargo ahead of it (Homebrew's {} in /usr/local/bin) is the usual cause — \
270                 or {}.",
271                file.display(),
272                hl("rust"),
273                hl(&format!("rustup toolchain install {want}"))
274            ));
275            return Err(Outcome::Failed);
276        }
277    }
278    if let Some(c) = component {
279        for dir in roots {
280            if !component_available(&cargo, dir, c) {
281                warn(missing);
282                return Err(Outcome::Unavailable);
283            }
284        }
285    }
286    Ok(cargo)
287}
288
289/// Run one cargo invocation in every manifest root. True when all succeeded.
290fn run_in_roots(
291    settings: &crate::config::Settings,
292    roots: &[PathBuf],
293    argv: &[String],
294    args: &[&str],
295) -> bool {
296    let extra: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
297    let mut all_ok = true;
298    for dir in roots {
299        let d = dir.to_string_lossy().into_owned();
300        if !run_tool(settings, &d, argv, &extra) {
301            all_ok = false;
302        }
303    }
304    all_ok
305}
306
307/// Shared shape: find the manifest roots, verify the component if there is one
308/// to verify, run the command in each, report once.
309///
310/// `component` is `Some` only for rustfmt/clippy; `None` means a built-in
311/// subcommand where cargo's own presence is the whole requirement.
312fn each_root(
313    settings: &crate::config::Settings,
314    roots: &[PathBuf],
315    component: Option<&str>,
316    args: &[&str],
317    missing: &str,
318) -> Result<bool, Outcome> {
319    let argv = cargo_for(roots, component, missing)?;
320    Ok(run_in_roots(settings, roots, &argv, args))
321}
322
323pub fn fmt(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
324    let files = staged_files(EXTS);
325    if files.is_empty() {
326        return Outcome::Passed;
327    }
328    let root = repo_root();
329    let roots = cargo_roots(&root, files.iter().map(String::as_str));
330    if roots.is_empty() {
331        return Outcome::Passed;
332    }
333    const MISSING: &str =
334        "Rust staged but rustfmt is not installed. `rustup component add rustfmt`.";
335    let argv = match cargo_for(&roots, Some("fmt"), MISSING) {
336        Ok(argv) => argv,
337        Err(outcome) => return outcome,
338    };
339
340    // `--all -- --check` per the project convention. It inspects the working
341    // TREE rather than the index — and that is now correct, because the
342    // pre-commit stage holds the unstaged changes aside for the duration, so
343    // the tree IS the staged content. This comment used to call that "the same
344    // trade-off cargo fmt gives everyone", which was true of the observation
345    // and wrong about the conclusion: see `staged_only`.
346    if run_in_roots(settings, &roots, &argv, &["fmt", "--all", "--", "--check"]) {
347        ok(settings, "Rust formatting is clean");
348        return Outcome::Passed;
349    }
350
351    // The registry has always declared `Fix::Rewrite` for this check, and
352    // `amont list --json` reported `"fix":"rewrite"` — which `agents_md`
353    // explicitly tells agents to trust — while no fixing code existed
354    // anywhere. Only prettier and the manifest's externals ever called
355    // `restage`. Rather than downgrade the declaration, the fixing is now
356    // real.
357    if fixing_enabled(settings) && run_in_roots(settings, &roots, &argv, &["fmt", "--all"]) {
358        // The non-obvious guard: `cargo fmt --all` formats the WHOLE
359        // workspace, not just the staged files — but `restage` is handed the
360        // staged `.rs` list, so nothing the author did not stage is staged
361        // here. The formatter's other edits stay in the working tree, exactly
362        // as an unrelated unstaged change would.
363        match restage(&files) {
364            Restaged::Staged => {
365                ok(settings, "Rust reformatted and re-staged");
366                return Outcome::Fixed;
367            }
368            Restaged::Failed(stuck) => {
369                fail(&format!(
370                    "cargo fmt rewrote these files but {} failed — the index still holds the \
371                     UNFORMATTED content: {}",
372                    hl("git add"),
373                    stuck.join(", ")
374                ));
375                return Outcome::Failed;
376            }
377            // Nothing staged differed, so whatever `--check` objected to was
378            // outside the staged set. Fall through and report it.
379            Restaged::Nothing => {}
380        }
381    }
382
383    fail(&format!("Unformatted Rust. Run {}.", hl("cargo fmt --all")));
384    Outcome::Failed
385}
386
387pub fn clippy(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
388    // `staged_files` matches by suffix, which would also accept
389    // `vendor/NotCargo.toml`. `is_rust_path` compares the basename, so let it
390    // be the only filter rather than keeping two that disagree.
391    let files: Vec<String> = staged_files(&[])
392        .into_iter()
393        .filter(|f| is_rust_path(f))
394        .collect();
395    if files.is_empty() {
396        return Outcome::Passed;
397    }
398    let root = repo_root();
399    let roots = cargo_roots(&root, files.iter().map(String::as_str));
400    if roots.is_empty() {
401        return Outcome::Passed;
402    }
403    match each_root(
404        settings,
405        &roots,
406        Some("clippy"),
407        &[
408            "clippy",
409            "--workspace",
410            "--all-targets",
411            "--all-features",
412            "--",
413            "-D",
414            "warnings",
415        ],
416        "Rust staged but clippy is not installed. `rustup component add clippy`.",
417    ) {
418        Err(outcome) => outcome,
419        Ok(true) => {
420            ok(settings, "Clippy passed");
421            Outcome::Passed
422        }
423        Ok(false) => {
424            fail(&format!(
425                "Clippy warnings. Fix them or run {}.",
426                hl("cargo clippy --fix")
427            ));
428            Outcome::Failed
429        }
430    }
431}
432
433/// pre-push. Mirrors `run-tests-js`: the range that is actually being pushed
434/// decides whether the suite runs, so a docs-only push costs nothing.
435///
436/// PER REF, not once for the whole push: `git push origin a b` carries two
437/// tips, and a single worktree checked out to the first one would run the
438/// second ref's tests against the first ref's tree — a real failure in the
439/// untested branch reported as a pass because nothing actually ran against
440/// it. Each ref that touches Rust gets its own worktree and its own verdict.
441pub fn test(
442    settings: &crate::config::Settings,
443    refs: &[crate::pushrefs::PushRef],
444    gate: &str,
445) -> Outcome {
446    // `Unavailable`, never `Passed`, when git will not answer — same argument
447    // and same wording as run-tests-js: a gate must not report green having
448    // asked nothing.
449    let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
450        super::common::warn("cargo-test: git would not answer — the gate did NOT run");
451        return Outcome::Unavailable;
452    };
453    let zero = git::stdout(&["hash-object", "--stdin"])
454        .map(|h| "0".repeat(h.len()))
455        .unwrap_or_else(|| "0".repeat(40));
456    let mut ran_any = false;
457    for r in refs {
458        let changed = crate::pushrefs::changed_files_for(r, &zero);
459        let roots = cargo_roots(&root, changed.iter().map(String::as_str));
460        if roots.is_empty() {
461            continue;
462        }
463        // Where THIS ref's suite runs decides what it is answering about.
464        // `_guard` owns the checkout for the length of this ref's run;
465        // dropping it removes the worktree before the next ref's begins.
466        let (where_, _guard) =
467            crate::pushed_tree::where_to_run(settings, &r.local_oid, &root, gate);
468        let roots: Vec<PathBuf> = roots
469            .iter()
470            .map(|rt| {
471                rt.strip_prefix(&root)
472                    .map(|rel| where_.join(rel))
473                    .unwrap_or_else(|_| rt.clone())
474            })
475            .collect();
476        match each_root(
477            settings,
478            &roots,
479            None,
480            &["test", "--workspace", "--all-features"],
481            "Rust changed but cargo is not installed.",
482        ) {
483            Err(outcome) => return outcome,
484            Ok(true) => ran_any = true,
485            Ok(false) => {
486                fail("Rust tests failed. Push aborted.");
487                return Outcome::Failed;
488            }
489        }
490    }
491    if ran_any {
492        ok(settings, "Rust tests passed");
493    }
494    Outcome::Passed
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn recognises_rust_paths() {
503        assert!(is_rust_path("src/main.rs"));
504        assert!(is_rust_path("Cargo.toml"));
505        assert!(is_rust_path("crates/a/Cargo.lock"));
506        assert!(is_rust_path("rustfmt.toml"));
507        assert!(!is_rust_path("README.md"));
508        assert!(!is_rust_path("src/main.rsx"));
509        // A file merely CONTAINING the name is not the manifest.
510        assert!(!is_rust_path("docs/Cargo.toml.md"));
511        assert!(!is_rust_path("vendor/NotCargo.toml"));
512    }
513
514    #[test]
515    fn finds_the_nearest_manifest_not_the_repo_root() {
516        let tmp = std::env::temp_dir().join("amont-cargo-roots");
517        let _ = std::fs::remove_dir_all(&tmp);
518        let nested = tmp.join("services/engine");
519        std::fs::create_dir_all(nested.join("src")).unwrap();
520        std::fs::write(nested.join("Cargo.toml"), "[package]\n").unwrap();
521        let root = tmp.to_string_lossy().into_owned();
522
523        let got = cargo_roots(&root, ["services/engine/src/main.rs"].into_iter());
524        assert_eq!(got, vec![nested.clone()], "should find the nested manifest");
525
526        // A Rust file with no manifest anywhere above it is not a cargo project.
527        std::fs::create_dir_all(tmp.join("scripts")).unwrap();
528        let none = cargo_roots(&root, ["scripts/loose.rs"].into_iter());
529        assert!(none.is_empty(), "no manifest above it: {none:?}");
530        let _ = std::fs::remove_dir_all(&tmp);
531    }
532
533    #[test]
534    fn several_files_in_one_crate_yield_one_root() {
535        let tmp = std::env::temp_dir().join("amont-cargo-dedupe");
536        let _ = std::fs::remove_dir_all(&tmp);
537        std::fs::create_dir_all(tmp.join("src")).unwrap();
538        std::fs::write(tmp.join("Cargo.toml"), "[package]\n").unwrap();
539        let root = tmp.to_string_lossy().into_owned();
540        let got = cargo_roots(&root, ["src/a.rs", "src/b.rs", "Cargo.toml"].into_iter());
541        assert_eq!(got.len(), 1, "one cargo invocation, not three: {got:?}");
542        let _ = std::fs::remove_dir_all(&tmp);
543    }
544
545    #[test]
546    fn non_rust_files_select_nothing() {
547        let got = cargo_roots("/tmp", ["README.md", "a.py"].into_iter());
548        assert!(got.is_empty());
549    }
550
551    #[test]
552    fn reads_the_channel_out_of_the_toml() {
553        let text = "# pinned\n[toolchain]\nchannel = \"1.94.1\" # why\ncomponents = [\"clippy\"]\n";
554        assert_eq!(parse_toolchain_toml(text).as_deref(), Some("1.94.1"));
555        assert_eq!(
556            parse_toolchain_toml("[toolchain]\nchannel = 'stable'\n").as_deref(),
557            Some("stable")
558        );
559        assert_eq!(parse_toolchain_toml("[toolchain]\ncomponents = []\n"), None);
560        assert_eq!(parse_toolchain_toml("# channel = \"1.0\"\n"), None);
561    }
562
563    #[test]
564    fn only_a_version_is_held_against_cargo() {
565        assert_eq!(version_pin("1.94.1"), Some("1.94.1"));
566        assert_eq!(version_pin("1.94"), Some("1.94"));
567        assert_eq!(version_pin("stable"), None);
568        assert_eq!(version_pin("nightly-2026-01-01"), None);
569        assert_eq!(version_pin("1"), None);
570        assert!(version_matches("1.94.1", "1.94.1"));
571        assert!(version_matches("1.94", "1.94.1"));
572        assert!(!version_matches("1.94.1", "1.98.0"));
573        assert!(!version_matches("1.9", "1.94.1"));
574    }
575
576    #[test]
577    fn the_nearest_pin_above_the_manifest_wins() {
578        let tmp = std::env::temp_dir().join("amont-toolchain-pin");
579        let _ = std::fs::remove_dir_all(&tmp);
580        let nested = tmp.join("services/engine");
581        std::fs::create_dir_all(&nested).unwrap();
582        std::fs::write(tmp.join("rust-toolchain"), "1.85.0\n").unwrap();
583        let (file, pin) = pinned_toolchain(&nested).unwrap();
584        assert_eq!((file, pin.as_str()), (tmp.join("rust-toolchain"), "1.85.0"));
585        std::fs::write(
586            nested.join("rust-toolchain.toml"),
587            "[toolchain]\nchannel = \"1.94.1\"\n",
588        )
589        .unwrap();
590        let (file, pin) = pinned_toolchain(&nested).unwrap();
591        assert_eq!(
592            (file, pin.as_str()),
593            (nested.join("rust-toolchain.toml"), "1.94.1")
594        );
595        let _ = std::fs::remove_dir_all(&tmp);
596    }
597}