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 "rust-toolchain.toml",
40 "rust-toolchain",
41];
42
43pub 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
58fn 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
90fn 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
116fn 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
141fn 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
158fn 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
170fn 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
187fn 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
195fn 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
222fn 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 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
289fn 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
307fn 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 if run_in_roots(settings, &roots, &argv, &["fmt", "--all", "--", "--check"]) {
347 ok(settings, "Rust formatting is clean");
348 return Outcome::Passed;
349 }
350
351 if fixing_enabled(settings) && run_in_roots(settings, &roots, &argv, &["fmt", "--all"]) {
358 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 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 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
433pub fn test(
442 settings: &crate::config::Settings,
443 refs: &[crate::pushrefs::PushRef],
444 gate: &str,
445) -> Outcome {
446 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 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 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 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}