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(
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
156fn 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 if run_in_roots(settings, &roots, &argv, &["fmt", "--all", "--", "--check"]) {
195 ok(settings, "Rust formatting is clean");
196 return Outcome::Passed;
197 }
198
199 if fixing_enabled(settings) && run_in_roots(settings, &roots, &argv, &["fmt", "--all"]) {
206 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 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 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
281pub fn test(
290 settings: &crate::config::Settings,
291 refs: &[crate::pushrefs::PushRef],
292 gate: &str,
293) -> Outcome {
294 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 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 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 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}