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 {
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 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 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 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}