1use 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
26pub const RUST_PATHS: &[&str] = &[
33 ".rs",
34 "Cargo.toml",
35 "Cargo.lock",
36 "rustfmt.toml",
37 "clippy.toml",
38];
39
40pub 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
55fn 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
87fn 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
116fn 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
138fn 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
151fn 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 if run_in_roots(&roots, &argv, &["fmt", "--all", "--", "--check"]) {
189 ok("Rust formatting is clean");
190 return Outcome::Passed;
191 }
192
193 if fixing_enabled() && run_in_roots(&roots, &argv, &["fmt", "--all"]) {
200 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 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 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
274pub fn test(refs: &[crate::pushrefs::PushRef]) -> Outcome {
283 let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]) else {
287 super::common::warn("cargo-test: git would not answer — the gate did NOT run");
288 return Outcome::Unavailable;
289 };
290 let zero = git::stdout(&["hash-object", "--stdin"])
291 .map(|h| "0".repeat(h.len()))
292 .unwrap_or_else(|| "0".repeat(40));
293 let mut ran_any = false;
294 for r in refs {
295 let changed = crate::pushrefs::changed_files_for(r, &zero);
296 let roots = cargo_roots(&root, changed.iter().map(String::as_str));
297 if roots.is_empty() {
298 continue;
299 }
300 let (where_, _guard) = crate::pushed_tree::where_to_run(&r.local_oid, &root);
304 let roots: Vec<PathBuf> = roots
305 .iter()
306 .map(|rt| {
307 rt.strip_prefix(&root)
308 .map(|rel| where_.join(rel))
309 .unwrap_or_else(|_| rt.clone())
310 })
311 .collect();
312 match each_root(
313 &roots,
314 None,
315 &["test", "--workspace", "--all-features"],
316 "Rust changed but cargo is not installed.",
317 ) {
318 None => return Outcome::Unavailable,
319 Some(true) => ran_any = true,
320 Some(false) => {
321 fail("Rust tests failed. Push aborted.");
322 return Outcome::Failed;
323 }
324 }
325 }
326 if ran_any {
327 ok("Rust tests passed");
328 }
329 Outcome::Passed
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn recognises_rust_paths() {
338 assert!(is_rust_path("src/main.rs"));
339 assert!(is_rust_path("Cargo.toml"));
340 assert!(is_rust_path("crates/a/Cargo.lock"));
341 assert!(is_rust_path("rustfmt.toml"));
342 assert!(!is_rust_path("README.md"));
343 assert!(!is_rust_path("src/main.rsx"));
344 assert!(!is_rust_path("docs/Cargo.toml.md"));
346 assert!(!is_rust_path("vendor/NotCargo.toml"));
347 }
348
349 #[test]
350 fn finds_the_nearest_manifest_not_the_repo_root() {
351 let tmp = std::env::temp_dir().join("amont-cargo-roots");
352 let _ = std::fs::remove_dir_all(&tmp);
353 let nested = tmp.join("services/engine");
354 std::fs::create_dir_all(nested.join("src")).unwrap();
355 std::fs::write(nested.join("Cargo.toml"), "[package]\n").unwrap();
356 let root = tmp.to_string_lossy().into_owned();
357
358 let got = cargo_roots(&root, ["services/engine/src/main.rs"].into_iter());
359 assert_eq!(got, vec![nested.clone()], "should find the nested manifest");
360
361 std::fs::create_dir_all(tmp.join("scripts")).unwrap();
363 let none = cargo_roots(&root, ["scripts/loose.rs"].into_iter());
364 assert!(none.is_empty(), "no manifest above it: {none:?}");
365 let _ = std::fs::remove_dir_all(&tmp);
366 }
367
368 #[test]
369 fn several_files_in_one_crate_yield_one_root() {
370 let tmp = std::env::temp_dir().join("amont-cargo-dedupe");
371 let _ = std::fs::remove_dir_all(&tmp);
372 std::fs::create_dir_all(tmp.join("src")).unwrap();
373 std::fs::write(tmp.join("Cargo.toml"), "[package]\n").unwrap();
374 let root = tmp.to_string_lossy().into_owned();
375 let got = cargo_roots(&root, ["src/a.rs", "src/b.rs", "Cargo.toml"].into_iter());
376 assert_eq!(got.len(), 1, "one cargo invocation, not three: {got:?}");
377 let _ = std::fs::remove_dir_all(&tmp);
378 }
379
380 #[test]
381 fn non_rust_files_select_nothing() {
382 let got = cargo_roots("/tmp", ["README.md", "a.py"].into_iter());
383 assert!(got.is_empty());
384 }
385}