Skip to main content

dev_prune/adapters/
cargo_adapter.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Cargo/Rust package manager adapter.
5//
6// Opt-in (`devp config set enable_cargo true`), and it is worth being clear about why,
7// because `cargo metadata --locked` genuinely does prove the dependency graph resolves
8// from `Cargo.lock`. What it does not prove is that anything comes back *cheaply*:
9// `target/` holds compiler output, and the only way to get it back is to rebuild it.
10// That puts cargo in the same class as gradle, maven and swift rather than with
11// `node_modules` and `.venv`, so it waits for the longer `build_idle_days` window and
12// nobody finds it deleted without having asked.
13//
14// Deliberately whole-directory, not surgical. cargo-sweep can trim `target/` down to
15// the artifacts the current toolchain still uses, and in a repository someone builds
16// every day that is the better tool: the warm cache survives. This adapter fires on
17// repositories idle past `build_idle_days`, where every incremental artifact is
18// already cold, and a partial `target/` would make its reported size a lie: the pass
19// says it freed N GiB and left an unbounded remainder nothing tracks. Delete all of it
20// or none of it; the two tools divide the problem by repository temperature, they do
21// not compete.
22
23use 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
29/// Adapter for Cargo-based Rust projects.
30pub struct Cargo;
31
32/// The `Cargo.lock` cargo itself would use for this project: the nearest one at or
33/// above `path`, stopping at the repository boundary.
34///
35/// A workspace member has no lockfile of its own — the workspace root's covers it.
36/// Treating the member as lockfile-less used to send enforcement down the
37/// `generate-lockfile` tier, which re-resolves the whole workspace and rewrites the
38/// *root* lockfile as a precondition for deleting one member's `target/`.
39fn 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        // Past the repository root, any lockfile found belongs to somebody else.
47        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    /// `cargo metadata --locked` resolves the graph and fails if `Cargo.lock` would need
78    /// updating — without ever writing to it. `generate-lockfile` re-resolves and
79    /// rewrites it, which is what a user with a stale lockfile wants and what `metadata
80    /// --locked` refuses to do for them; it is reached when there is no lockfile to
81    /// preserve, or when they opted in. `--offline` is deliberately never passed —
82    /// re-resolving against a stale local index is how you get a lockfile that does not
83    /// build.
84    fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
85        // Criterion keeps its benchmark history in `target/criterion`. It is the one
86        // thing under `target/` no build regenerates — the next `cargo bench` starts a
87        // fresh baseline with nothing to compare against. Still recoverable-by-rebuild
88        // in the sense that matters, so a warning, not a refusal.
89        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
121/// The rebuild check for `cargo` declarations.
122pub(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
134/// Cargo's own subcommands, which never need a plugin behind them.
135///
136/// Only used to *stop* asking: a name on this list is accepted immediately. A name that
137/// is not on it goes on to the `PATH` and alias checks rather than being refused, so a
138/// list that falls behind cargo makes this slower, never wrong.
139const 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
189/// A `cargo` subcommand nothing on this machine provides.
190///
191/// Deliberately not an attempt to enumerate cargo plugins — there is no list of those.
192/// It only rules out the names that are definitively absent: not one of cargo's own, no
193/// `cargo-<name>` program on `PATH`, and no `[alias]` table anywhere cargo reads one.
194fn cargo_subcommand_gap(repo_path: &Path, args: &[&str]) -> Option<Gap> {
195    let mut i = 0;
196    // `cargo +nightly build` picks a toolchain before naming the subcommand.
197    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    // Asking `PATH` the same question cargo itself asks when it meets a name it does not
205    // know. An installed plugin is found here.
206    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
218/// Whether any config cargo would read declares an `[alias]` table.
219///
220/// Its mere presence is enough to stop asking. An alias can name anything, and reading
221/// one machine's table to decide whether a committed declaration is honoured is exactly
222/// the kind of answer this module would rather not give.
223fn 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        // `target/` is compiler output: proving the crates resolve is not the same as
252        // getting the compiled artefacts back for free.
253        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    /// The invariant the whole two-tier design exists for: a default pass may fail, but
263    /// it may not leave the lockfile different from how it found it.
264    ///
265    /// Uses a lockfile that does not list the manifest's dependency, which is the exact
266    /// state `--locked` is there to refuse. Skipped rather than failed when `cargo` is
267    /// absent, so the suite still runs somewhere without a Rust toolchain on `PATH`.
268    #[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        // A lockfile that knows only about the root package — `serde` is missing, so the
284        // graph cannot be resolved from it.
285        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}