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(roots: &[PathBuf], argv: &[String], args: &[&str]) -> bool {
140    let extra: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
141    let mut all_ok = true;
142    for dir in roots {
143        let d = dir.to_string_lossy().into_owned();
144        if !run_tool(&d, argv, &extra) {
145            all_ok = false;
146        }
147    }
148    all_ok
149}
150
151/// Shared shape: find the manifest roots, verify the component if there is one
152/// to verify, run the command in each, report once.
153///
154/// `component` is `Some` only for rustfmt/clippy; `None` means a built-in
155/// subcommand where cargo's own presence is the whole requirement.
156fn each_root(
157    roots: &[PathBuf],
158    component: Option<&str>,
159    args: &[&str],
160    missing: &str,
161) -> Option<bool> {
162    let argv = cargo_for(roots, component, missing)?;
163    Some(run_in_roots(roots, &argv, args))
164}
165
166pub fn fmt(_args: &[std::ffi::OsString]) -> Outcome {
167    let files = staged_files(EXTS);
168    if files.is_empty() {
169        return Outcome::Passed;
170    }
171    let root = repo_root();
172    let roots = cargo_roots(&root, files.iter().map(String::as_str));
173    if roots.is_empty() {
174        return Outcome::Passed;
175    }
176    const MISSING: &str =
177        "Rust staged but rustfmt is not installed. `rustup component add rustfmt`.";
178    let Some(argv) = cargo_for(&roots, Some("fmt"), MISSING) else {
179        return Outcome::Unavailable;
180    };
181
182    // `--all -- --check` per the project convention. It inspects the working
183    // TREE rather than the index — and that is now correct, because the
184    // pre-commit stage holds the unstaged changes aside for the duration, so
185    // the tree IS the staged content. This comment used to call that "the same
186    // trade-off cargo fmt gives everyone", which was true of the observation
187    // and wrong about the conclusion: see `staged_only`.
188    if run_in_roots(&roots, &argv, &["fmt", "--all", "--", "--check"]) {
189        ok("Rust formatting is clean");
190        return Outcome::Passed;
191    }
192
193    // The registry has always declared `Fix::Rewrite` for this check, and
194    // `amont list --json` reported `"fix":"rewrite"` — which `agents_md`
195    // explicitly tells agents to trust — while no fixing code existed
196    // anywhere. Only prettier and the manifest's externals ever called
197    // `restage`. Rather than downgrade the declaration, the fixing is now
198    // real.
199    if fixing_enabled() && run_in_roots(&roots, &argv, &["fmt", "--all"]) {
200        // The non-obvious guard: `cargo fmt --all` formats the WHOLE
201        // workspace, not just the staged files — but `restage` is handed the
202        // staged `.rs` list, so nothing the author did not stage is staged
203        // here. The formatter's other edits stay in the working tree, exactly
204        // as an unrelated unstaged change would.
205        match restage(&files) {
206            Restaged::Staged => {
207                ok("Rust reformatted and re-staged");
208                return Outcome::Fixed;
209            }
210            Restaged::Failed(stuck) => {
211                fail(&format!(
212                    "cargo fmt rewrote these files but {} failed — the index still holds the \
213                     UNFORMATTED content: {}",
214                    hl("git add"),
215                    stuck.join(", ")
216                ));
217                return Outcome::Failed;
218            }
219            // Nothing staged differed, so whatever `--check` objected to was
220            // outside the staged set. Fall through and report it.
221            Restaged::Nothing => {}
222        }
223    }
224
225    fail(&format!("Unformatted Rust. Run {}.", hl("cargo fmt --all")));
226    Outcome::Failed
227}
228
229pub fn clippy(_args: &[std::ffi::OsString]) -> Outcome {
230    // `staged_files` matches by suffix, which would also accept
231    // `vendor/NotCargo.toml`. `is_rust_path` compares the basename, so let it
232    // be the only filter rather than keeping two that disagree.
233    let files: Vec<String> = staged_files(&[])
234        .into_iter()
235        .filter(|f| is_rust_path(f))
236        .collect();
237    if files.is_empty() {
238        return Outcome::Passed;
239    }
240    let root = repo_root();
241    let roots = cargo_roots(&root, files.iter().map(String::as_str));
242    if roots.is_empty() {
243        return Outcome::Passed;
244    }
245    match each_root(
246        &roots,
247        Some("clippy"),
248        &[
249            "clippy",
250            "--workspace",
251            "--all-targets",
252            "--all-features",
253            "--",
254            "-D",
255            "warnings",
256        ],
257        "Rust staged but clippy is not installed. `rustup component add clippy`.",
258    ) {
259        None => Outcome::Unavailable,
260        Some(true) => {
261            ok("Clippy passed");
262            Outcome::Passed
263        }
264        Some(false) => {
265            fail(&format!(
266                "Clippy warnings. Fix them or run {}.",
267                hl("cargo clippy --fix")
268            ));
269            Outcome::Failed
270        }
271    }
272}
273
274/// pre-push. Mirrors `run-tests-js`: the range that is actually being pushed
275/// decides whether the suite runs, so a docs-only push costs nothing.
276///
277/// PER REF, not once for the whole push: `git push origin a b` carries two
278/// tips, and a single worktree checked out to the first one would run the
279/// second ref's tests against the first ref's tree — a real failure in the
280/// untested branch reported as a pass because nothing actually ran against
281/// it. Each ref that touches Rust gets its own worktree and its own verdict.
282pub fn test(refs: &[crate::pushrefs::PushRef]) -> Outcome {
283    let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
284        return Outcome::Passed;
285    };
286    let zero = git::stdout(&["hash-object", "--stdin"])
287        .map(|h| "0".repeat(h.len()))
288        .unwrap_or_else(|| "0".repeat(40));
289    let mut ran_any = false;
290    for r in refs {
291        let changed = crate::pushrefs::changed_files_for(r, &zero);
292        let roots = cargo_roots(&root, changed.iter().map(String::as_str));
293        if roots.is_empty() {
294            continue;
295        }
296        // Where THIS ref's suite runs decides what it is answering about.
297        // `_guard` owns the checkout for the length of this ref's run;
298        // dropping it removes the worktree before the next ref's begins.
299        let (where_, _guard) = crate::pushed_tree::where_to_run(&r.local_oid, &root);
300        let roots: Vec<PathBuf> = roots
301            .iter()
302            .map(|rt| {
303                rt.strip_prefix(&root)
304                    .map(|rel| where_.join(rel))
305                    .unwrap_or_else(|_| rt.clone())
306            })
307            .collect();
308        match each_root(
309            &roots,
310            None,
311            &["test", "--workspace", "--all-features"],
312            "Rust changed but cargo is not installed.",
313        ) {
314            None => return Outcome::Unavailable,
315            Some(true) => ran_any = true,
316            Some(false) => {
317                fail("Rust tests failed. Push aborted.");
318                return Outcome::Failed;
319            }
320        }
321    }
322    if ran_any {
323        ok("Rust tests passed");
324    }
325    Outcome::Passed
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn recognises_rust_paths() {
334        assert!(is_rust_path("src/main.rs"));
335        assert!(is_rust_path("Cargo.toml"));
336        assert!(is_rust_path("crates/a/Cargo.lock"));
337        assert!(is_rust_path("rustfmt.toml"));
338        assert!(!is_rust_path("README.md"));
339        assert!(!is_rust_path("src/main.rsx"));
340        // A file merely CONTAINING the name is not the manifest.
341        assert!(!is_rust_path("docs/Cargo.toml.md"));
342        assert!(!is_rust_path("vendor/NotCargo.toml"));
343    }
344
345    #[test]
346    fn finds_the_nearest_manifest_not_the_repo_root() {
347        let tmp = std::env::temp_dir().join("amont-cargo-roots");
348        let _ = std::fs::remove_dir_all(&tmp);
349        let nested = tmp.join("services/engine");
350        std::fs::create_dir_all(nested.join("src")).unwrap();
351        std::fs::write(nested.join("Cargo.toml"), "[package]\n").unwrap();
352        let root = tmp.to_string_lossy().into_owned();
353
354        let got = cargo_roots(&root, ["services/engine/src/main.rs"].into_iter());
355        assert_eq!(got, vec![nested.clone()], "should find the nested manifest");
356
357        // A Rust file with no manifest anywhere above it is not a cargo project.
358        std::fs::create_dir_all(tmp.join("scripts")).unwrap();
359        let none = cargo_roots(&root, ["scripts/loose.rs"].into_iter());
360        assert!(none.is_empty(), "no manifest above it: {none:?}");
361        let _ = std::fs::remove_dir_all(&tmp);
362    }
363
364    #[test]
365    fn several_files_in_one_crate_yield_one_root() {
366        let tmp = std::env::temp_dir().join("amont-cargo-dedupe");
367        let _ = std::fs::remove_dir_all(&tmp);
368        std::fs::create_dir_all(tmp.join("src")).unwrap();
369        std::fs::write(tmp.join("Cargo.toml"), "[package]\n").unwrap();
370        let root = tmp.to_string_lossy().into_owned();
371        let got = cargo_roots(&root, ["src/a.rs", "src/b.rs", "Cargo.toml"].into_iter());
372        assert_eq!(got.len(), 1, "one cargo invocation, not three: {got:?}");
373        let _ = std::fs::remove_dir_all(&tmp);
374    }
375
376    #[test]
377    fn non_rust_files_select_nothing() {
378        let got = cargo_roots("/tmp", ["README.md", "a.py"].into_iter());
379        assert!(got.is_empty());
380    }
381}