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
14use 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
20/// Adapter for Cargo-based Rust projects.
21pub struct Cargo;
22
23/// The `Cargo.lock` cargo itself would use for this project: the nearest one at or
24/// above `path`, stopping at the repository boundary.
25///
26/// A workspace member has no lockfile of its own — the workspace root's covers it.
27/// Treating the member as lockfile-less used to send enforcement down the
28/// `generate-lockfile` tier, which re-resolves the whole workspace and rewrites the
29/// *root* lockfile as a precondition for deleting one member's `target/`.
30fn 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        // Past the repository root, any lockfile found belongs to somebody else.
38        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    /// `cargo metadata --locked` resolves the graph and fails if `Cargo.lock` would need
69    /// updating — without ever writing to it. `generate-lockfile` re-resolves and
70    /// rewrites it, which is what a user with a stale lockfile wants and what `metadata
71    /// --locked` refuses to do for them; it is reached when there is no lockfile to
72    /// preserve, or when they opted in. `--offline` is deliberately never passed —
73    /// re-resolving against a stale local index is how you get a lockfile that does not
74    /// build.
75    fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
76        // Criterion keeps its benchmark history in `target/criterion`. It is the one
77        // thing under `target/` no build regenerates — the next `cargo bench` starts a
78        // fresh baseline with nothing to compare against. Still recoverable-by-rebuild
79        // in the sense that matters, so a warning, not a refusal.
80        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
112/// The rebuild check for `cargo` declarations.
113pub(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
125/// Cargo's own subcommands, which never need a plugin behind them.
126///
127/// Only used to *stop* asking: a name on this list is accepted immediately. A name that
128/// is not on it goes on to the `PATH` and alias checks rather than being refused, so a
129/// list that falls behind cargo makes this slower, never wrong.
130const 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
180/// A `cargo` subcommand nothing on this machine provides.
181///
182/// Deliberately not an attempt to enumerate cargo plugins — there is no list of those.
183/// It only rules out the names that are definitively absent: not one of cargo's own, no
184/// `cargo-<name>` program on `PATH`, and no `[alias]` table anywhere cargo reads one.
185fn cargo_subcommand_gap(repo_path: &Path, args: &[&str]) -> Option<Gap> {
186    let mut i = 0;
187    // `cargo +nightly build` picks a toolchain before naming the subcommand.
188    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    // Asking `PATH` the same question cargo itself asks when it meets a name it does not
196    // know. An installed plugin is found here.
197    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
209/// Whether any config cargo would read declares an `[alias]` table.
210///
211/// Its mere presence is enough to stop asking. An alias can name anything, and reading
212/// one machine's table to decide whether a committed declaration is honoured is exactly
213/// the kind of answer this module would rather not give.
214fn 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        // `target/` is compiler output: proving the crates resolve is not the same as
243        // getting the compiled artefacts back for free.
244        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    /// The invariant the whole two-tier design exists for: a default pass may fail, but
254    /// it may not leave the lockfile different from how it found it.
255    ///
256    /// Uses a lockfile that does not list the manifest's dependency, which is the exact
257    /// state `--locked` is there to refuse. Skipped rather than failed when `cargo` is
258    /// absent, so the suite still runs somewhere without a Rust toolchain on `PATH`.
259    #[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        // A lockfile that knows only about the root package — `serde` is missing, so the
275        // graph cannot be resolved from it.
276        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}