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