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 anyhow::Result;
16use std::path::{Path, PathBuf};
17
18/// Adapter for Cargo-based Rust projects.
19pub struct Cargo;
20
21/// The `Cargo.lock` cargo itself would use for this project: the nearest one at or
22/// above `path`, stopping at the repository boundary.
23///
24/// A workspace member has no lockfile of its own — the workspace root's covers it.
25/// Treating the member as lockfile-less used to send enforcement down the
26/// `generate-lockfile` tier, which re-resolves the whole workspace and rewrites the
27/// *root* lockfile as a precondition for deleting one member's `target/`.
28fn workspace_lockfile(path: &Path) -> Option<PathBuf> {
29    let mut dir = path;
30    loop {
31        let candidate = dir.join("Cargo.lock");
32        if candidate.exists() {
33            return Some(candidate);
34        }
35        // Past the repository root, any lockfile found belongs to somebody else.
36        if dir.join(".git").exists() {
37            return None;
38        }
39        dir = dir.parent()?;
40    }
41}
42
43impl PackageManager for Cargo {
44    fn name(&self) -> &'static str {
45        "cargo"
46    }
47
48    fn detect(&self, path: &Path) -> bool {
49        path.join("Cargo.toml").exists()
50    }
51
52    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
53        let mut dirs = Vec::new();
54        let target_path = path.join("target");
55        if target_path.exists() {
56            dirs.push(BloatDir {
57                name: "target".to_string(),
58                path: target_path.clone(),
59                size_bytes: dir_size(&target_path),
60                shared_bytes: 0,
61            });
62        }
63        dirs
64    }
65
66    /// `cargo metadata --locked` resolves the graph and fails if `Cargo.lock` would need
67    /// updating — without ever writing to it. `generate-lockfile` re-resolves and
68    /// rewrites it, which is what a user with a stale lockfile wants and what `metadata
69    /// --locked` refuses to do for them; it is reached when there is no lockfile to
70    /// preserve, or when they opted in. `--offline` is deliberately never passed —
71    /// re-resolving against a stale local index is how you get a lockfile that does not
72    /// build.
73    fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
74        // Criterion keeps its benchmark history in `target/criterion`. It is the one
75        // thing under `target/` no build regenerates — the next `cargo bench` starts a
76        // fresh baseline with nothing to compare against. Still recoverable-by-rebuild
77        // in the sense that matters, so a warning, not a refusal.
78        if path.join("target").join("criterion").is_dir() {
79            crate::output::print_warning(&format!(
80                "{}: `target/criterion` holds benchmark history that a rebuild does not \
81                 bring back — copy it first if the baselines matter.",
82                crate::output::clean_path(path)
83            ));
84        }
85        let lockfile = workspace_lockfile(path).unwrap_or_else(|| path.join("Cargo.lock"));
86        enforce_two_tier(
87            &lockfile,
88            "cargo",
89            &["metadata", "--locked", "--format-version", "1"],
90            &["generate-lockfile"],
91            path,
92            policy,
93        )
94    }
95
96    fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
97        println!("Rust target/ will regenerate on next cargo build");
98        Ok(())
99    }
100
101    fn lockfiles(&self) -> &'static [&'static str] {
102        &["Cargo.lock"]
103    }
104
105    fn opt_in(&self) -> bool {
106        true
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::fs;
114    use std::fs::File;
115    use tempfile::tempdir;
116
117    #[test]
118    fn cargo_is_opt_in() {
119        // `target/` is compiler output: proving the crates resolve is not the same as
120        // getting the compiled artefacts back for free.
121        assert!(Cargo.opt_in());
122    }
123
124    #[test]
125    fn test_name() {
126        let adapter = Cargo;
127        assert_eq!(adapter.name(), "cargo");
128    }
129
130    /// The invariant the whole two-tier design exists for: a default pass may fail, but
131    /// it may not leave the lockfile different from how it found it.
132    ///
133    /// Uses a lockfile that does not list the manifest's dependency, which is the exact
134    /// state `--locked` is there to refuse. Skipped rather than failed when `cargo` is
135    /// absent, so the suite still runs somewhere without a Rust toolchain on `PATH`.
136    #[test]
137    fn a_default_pass_never_rewrites_a_stale_lockfile() {
138        if !super::super::binary_available("cargo") {
139            return;
140        }
141        let dir = tempdir().unwrap();
142        fs::write(
143            dir.path().join("Cargo.toml"),
144            "[package]\nname = \"stale\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
145             [dependencies]\nserde = \"1\"\n",
146        )
147        .unwrap();
148        fs::create_dir(dir.path().join("src")).unwrap();
149        fs::write(dir.path().join("src").join("lib.rs"), "").unwrap();
150
151        // A lockfile that knows only about the root package — `serde` is missing, so the
152        // graph cannot be resolved from it.
153        let stale = "version = 3\n\n[[package]]\nname = \"stale\"\nversion = \"0.1.0\"\n";
154        fs::write(dir.path().join("Cargo.lock"), stale).unwrap();
155
156        let result = Cargo.enforce_lockfile(dir.path(), EnforcePolicy::default());
157
158        assert!(
159            result.is_err(),
160            "a lockfile that cannot resolve the manifest must not pass verification"
161        );
162        assert_eq!(
163            fs::read_to_string(dir.path().join("Cargo.lock")).unwrap(),
164            stale,
165            "the read-only verification rewrote Cargo.lock"
166        );
167    }
168
169    #[test]
170    fn test_detect_positive() {
171        let dir = tempdir().unwrap();
172        File::create(dir.path().join("Cargo.toml")).unwrap();
173
174        let adapter = Cargo;
175        assert!(adapter.detect(dir.path()));
176    }
177
178    #[test]
179    fn test_detect_negative() {
180        let dir = tempdir().unwrap();
181
182        let adapter = Cargo;
183        assert!(!adapter.detect(dir.path()));
184    }
185
186    #[test]
187    fn test_bloat_dirs_present() {
188        let dir = tempdir().unwrap();
189        fs::create_dir(dir.path().join("target")).unwrap();
190
191        let adapter = Cargo;
192        let dirs = adapter.bloat_dirs(dir.path());
193        assert_eq!(dirs.len(), 1);
194        assert_eq!(dirs[0].name, "target");
195    }
196
197    #[test]
198    fn test_bloat_dirs_absent() {
199        let dir = tempdir().unwrap();
200
201        let adapter = Cargo;
202        let dirs = adapter.bloat_dirs(dir.path());
203        assert!(dirs.is_empty());
204    }
205}