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