1use super::{BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier};
24use crate::declared::{Gap, RebuildCheck, on_path};
25use anyhow::Result;
26use std::fs;
27use std::path::{Path, PathBuf};
28
29pub struct Cargo;
31
32fn workspace_lockfile(path: &Path) -> Option<PathBuf> {
40 let mut dir = path;
41 loop {
42 let candidate = dir.join("Cargo.lock");
43 if candidate.exists() {
44 return Some(candidate);
45 }
46 if dir.join(".git").exists() {
48 return None;
49 }
50 dir = dir.parent()?;
51 }
52}
53
54impl PackageManager for Cargo {
55 fn name(&self) -> &'static str {
56 "cargo"
57 }
58
59 fn detect(&self, path: &Path) -> bool {
60 path.join("Cargo.toml").exists()
61 }
62
63 fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
64 let mut dirs = Vec::new();
65 let target_path = path.join("target");
66 if target_path.exists() {
67 dirs.push(BloatDir {
68 name: "target".to_string(),
69 path: target_path.clone(),
70 size_bytes: dir_size(&target_path),
71 shared_bytes: 0,
72 });
73 }
74 dirs
75 }
76
77 fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
85 if path.join("target").join("criterion").is_dir() {
90 crate::output::print_warning(&format!(
91 "{}: `target/criterion` holds benchmark history that a rebuild does not \
92 bring back — copy it first if the baselines matter.",
93 crate::output::clean_path(path)
94 ));
95 }
96 let lockfile = workspace_lockfile(path).unwrap_or_else(|| path.join("Cargo.lock"));
97 enforce_two_tier(
98 &lockfile,
99 "cargo",
100 &["metadata", "--locked", "--format-version", "1"],
101 &["generate-lockfile"],
102 path,
103 policy,
104 )
105 }
106
107 fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
108 println!("Rust target/ will regenerate on next cargo build");
109 Ok(())
110 }
111
112 fn lockfiles(&self) -> &'static [&'static str] {
113 &["Cargo.lock"]
114 }
115
116 fn opt_in(&self) -> bool {
117 true
118 }
119}
120
121pub(crate) struct CargoSubcommands;
123
124impl RebuildCheck for CargoSubcommands {
125 fn tools(&self) -> &'static [&'static str] {
126 &["cargo"]
127 }
128
129 fn gap(&self, repo_path: &Path, _tool: &str, args: &[&str]) -> Option<Gap> {
130 cargo_subcommand_gap(repo_path, args)
131 }
132}
133
134const CARGO_BUILTINS: &[&str] = &[
140 "add",
141 "b",
142 "bench",
143 "build",
144 "c",
145 "check",
146 "clean",
147 "clippy",
148 "config",
149 "d",
150 "doc",
151 "fetch",
152 "fix",
153 "fmt",
154 "generate-lockfile",
155 "help",
156 "info",
157 "init",
158 "install",
159 "locate-project",
160 "login",
161 "logout",
162 "metadata",
163 "miri",
164 "new",
165 "owner",
166 "package",
167 "pkgid",
168 "publish",
169 "r",
170 "read-manifest",
171 "remove",
172 "report",
173 "run",
174 "rustc",
175 "rustdoc",
176 "search",
177 "t",
178 "test",
179 "tree",
180 "uninstall",
181 "unpublish",
182 "update",
183 "vendor",
184 "verify-project",
185 "version",
186 "yank",
187];
188
189fn cargo_subcommand_gap(repo_path: &Path, args: &[&str]) -> Option<Gap> {
195 let mut i = 0;
196 while args.get(i).is_some_and(|arg| arg.starts_with('+')) {
198 i += 1;
199 }
200 let sub = *args.get(i)?;
201 if sub.starts_with('-') || CARGO_BUILTINS.contains(&sub) {
202 return None;
203 }
204 if on_path(&format!("cargo-{sub}")) {
207 return None;
208 }
209 if cargo_aliases_exist(repo_path) {
210 return None;
211 }
212 Some(Gap {
213 what: format!("`{sub}` is not a cargo subcommand and no `cargo-{sub}` is on this machine"),
214 fix: format!("Install whatever provides `cargo {sub}`, or fix the command."),
215 })
216}
217
218fn cargo_aliases_exist(repo_path: &Path) -> bool {
224 let mut candidates = vec![
225 repo_path.join(".cargo").join("config.toml"),
226 repo_path.join(".cargo").join("config"),
227 ];
228 let home = std::env::var_os("CARGO_HOME")
229 .map(PathBuf::from)
230 .or_else(|| dirs::home_dir().map(|dir| dir.join(".cargo")));
231 if let Some(home) = home {
232 candidates.push(home.join("config.toml"));
233 candidates.push(home.join("config"));
234 }
235 candidates.iter().any(|path| {
236 fs::read_to_string(path)
237 .map(|text| text.lines().any(|l| l.trim_start().starts_with("[alias")))
238 .unwrap_or(false)
239 })
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use std::fs;
246 use std::fs::File;
247 use tempfile::tempdir;
248
249 #[test]
250 fn cargo_is_opt_in() {
251 assert!(Cargo.opt_in());
254 }
255
256 #[test]
257 fn test_name() {
258 let adapter = Cargo;
259 assert_eq!(adapter.name(), "cargo");
260 }
261
262 #[test]
269 fn a_default_pass_never_rewrites_a_stale_lockfile() {
270 if !super::super::binary_available("cargo") {
271 return;
272 }
273 let dir = tempdir().unwrap();
274 fs::write(
275 dir.path().join("Cargo.toml"),
276 "[package]\nname = \"stale\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
277 [dependencies]\nserde = \"1\"\n",
278 )
279 .unwrap();
280 fs::create_dir(dir.path().join("src")).unwrap();
281 fs::write(dir.path().join("src").join("lib.rs"), "").unwrap();
282
283 let stale = "version = 3\n\n[[package]]\nname = \"stale\"\nversion = \"0.1.0\"\n";
286 fs::write(dir.path().join("Cargo.lock"), stale).unwrap();
287
288 let result = Cargo.enforce_lockfile(dir.path(), EnforcePolicy::default());
289
290 assert!(
291 result.is_err(),
292 "a lockfile that cannot resolve the manifest must not pass verification"
293 );
294 assert_eq!(
295 fs::read_to_string(dir.path().join("Cargo.lock")).unwrap(),
296 stale,
297 "the read-only verification rewrote Cargo.lock"
298 );
299 }
300
301 #[test]
302 fn test_detect_positive() {
303 let dir = tempdir().unwrap();
304 File::create(dir.path().join("Cargo.toml")).unwrap();
305
306 let adapter = Cargo;
307 assert!(adapter.detect(dir.path()));
308 }
309
310 #[test]
311 fn test_detect_negative() {
312 let dir = tempdir().unwrap();
313
314 let adapter = Cargo;
315 assert!(!adapter.detect(dir.path()));
316 }
317
318 #[test]
319 fn test_bloat_dirs_present() {
320 let dir = tempdir().unwrap();
321 fs::create_dir(dir.path().join("target")).unwrap();
322
323 let adapter = Cargo;
324 let dirs = adapter.bloat_dirs(dir.path());
325 assert_eq!(dirs.len(), 1);
326 assert_eq!(dirs[0].name, "target");
327 }
328
329 #[test]
330 fn test_bloat_dirs_absent() {
331 let dir = tempdir().unwrap();
332
333 let adapter = Cargo;
334 let dirs = adapter.bloat_dirs(dir.path());
335 assert!(dirs.is_empty());
336 }
337}