Skip to main content

dev_prune/adapters/
venv.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Standard Python venv package manager adapter.
5//
6// Detects Python virtual environments by scanning the repo root for any
7// directory containing a `pyvenv.cfg` file — the canonical marker for any
8// Python virtual environment, regardless of what the folder is named
9// (`.venv`, `venv`, `env`, `my_env`, `.env`, etc.).
10//
11// Priority: the `uv` adapter takes precedence when `uv.lock` is present.
12
13use super::{BloatDir, EnforcePolicy, PackageManager, dir_size, run_command};
14use anyhow::{Result, anyhow};
15use std::fs;
16use std::path::Path;
17
18/// The canonical file inside every Python virtual environment.
19const PYVENV_CFG: &str = "pyvenv.cfg";
20
21/// Adapter for standard Python venv projects.
22pub struct Venv;
23
24/// Scan the repo root for directories containing `pyvenv.cfg`.
25///
26/// This catches any venv folder name: `.venv`, `venv`, `env`, `my_env`, etc.
27fn find_venv_dirs(path: &Path) -> Vec<std::path::PathBuf> {
28    let mut found = Vec::new();
29
30    let Ok(entries) = fs::read_dir(path) else {
31        return found;
32    };
33
34    for entry in entries.flatten() {
35        let entry_path = entry.path();
36        if entry_path.is_dir() && entry_path.join(PYVENV_CFG).exists() {
37            found.push(entry_path);
38        }
39    }
40
41    found
42}
43
44impl PackageManager for Venv {
45    fn name(&self) -> &'static str {
46        "venv"
47    }
48
49    /// Detect a plain-venv project:
50    /// - `requirements.txt` must exist (otherwise it's probably not a managed venv project)
51    /// - At least one directory with `pyvenv.cfg` must exist in the repo root
52    /// - `uv.lock` must NOT exist (uv adapter takes priority)
53    ///
54    /// uv's precedence is also enforced centrally in `adapters::detect_adapters`, which
55    /// covers uv projects declared only through `[tool.uv]` in `pyproject.toml`.
56    fn detect(&self, path: &Path) -> bool {
57        let req_txt = path.join("requirements.txt");
58        let uv_lock = path.join("uv.lock");
59
60        if !req_txt.exists() || uv_lock.exists() {
61            return false;
62        }
63
64        !find_venv_dirs(path).is_empty()
65    }
66
67    /// Return all venv directories (any folder containing `pyvenv.cfg`) as bloat dirs.
68    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
69        find_venv_dirs(path)
70            .into_iter()
71            .map(|venv_path| {
72                let name = venv_path
73                    .file_name()
74                    .map(|n| n.to_string_lossy().to_string())
75                    .unwrap_or_else(|| venv_path.display().to_string());
76                let size = dir_size(&venv_path);
77                BloatDir {
78                    name,
79                    path: venv_path,
80                    size_bytes: size,
81                }
82            })
83            .collect()
84    }
85
86    /// Pure inspection: reads `requirements.txt` and runs nothing, so neither half of
87    /// [`EnforcePolicy`] applies.
88    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
89        let req_txt = path.join("requirements.txt");
90        if !req_txt.exists() {
91            return Err(anyhow!("requirements.txt missing"));
92        }
93        // An empty requirements.txt cannot rebuild the environment, so deleting the
94        // venv against it would be unrecoverable rather than merely inconvenient.
95        let has_requirements = fs::read_to_string(&req_txt)
96            .map(|c| {
97                c.lines()
98                    .any(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
99            })
100            .unwrap_or(false);
101        if !has_requirements {
102            return Err(anyhow!(
103                "requirements.txt at `{}` lists no packages — the virtual environment \
104                 could not be rebuilt after deletion. Populate it with `pip freeze > requirements.txt`.",
105                req_txt.display()
106            ));
107        }
108        Ok(())
109    }
110
111    /// Recreate the environment in `.venv`.
112    ///
113    /// Note: the adapter prunes any directory containing `pyvenv.cfg`, whatever it is
114    /// called, but restore always recreates `.venv` — the original folder name is not
115    /// recorded anywhere once the directory is gone.
116    fn restore(&self, path: &Path) -> Result<()> {
117        #[cfg(windows)]
118        {
119            run_command("python", &["-m", "venv", ".venv"], path)?;
120            run_command(
121                ".venv\\Scripts\\python.exe",
122                &["-m", "pip", "install", "-r", "requirements.txt"],
123                path,
124            )
125        }
126        #[cfg(not(windows))]
127        {
128            run_command("python", &["-m", "venv", ".venv"], path)?;
129            run_command(
130                ".venv/bin/python",
131                &["-m", "pip", "install", "-r", "requirements.txt"],
132                path,
133            )
134        }
135    }
136
137    /// Not a lockfile in the strict sense — `requirements.txt` pins whatever its author
138    /// pinned — but it is the file this adapter verifies and rebuilds from, which is what
139    /// the caller wants to be told about.
140    fn lockfiles(&self) -> &'static [&'static str] {
141        &["requirements.txt"]
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::fs::{self, File};
149    use tempfile::tempdir;
150
151    fn make_venv(dir: &Path, name: &str) {
152        let venv = dir.join(name);
153        fs::create_dir(&venv).unwrap();
154        File::create(venv.join(PYVENV_CFG)).unwrap();
155    }
156
157    #[test]
158    fn test_name() {
159        assert_eq!(Venv.name(), "venv");
160    }
161
162    #[test]
163    fn test_detect_positive_dot_venv() {
164        let dir = tempdir().unwrap();
165        File::create(dir.path().join("requirements.txt")).unwrap();
166        make_venv(dir.path(), ".venv");
167        assert!(Venv.detect(dir.path()));
168    }
169
170    #[test]
171    fn test_detect_positive_venv() {
172        let dir = tempdir().unwrap();
173        File::create(dir.path().join("requirements.txt")).unwrap();
174        make_venv(dir.path(), "venv");
175        assert!(Venv.detect(dir.path()));
176    }
177
178    #[test]
179    fn test_detect_positive_custom_name() {
180        let dir = tempdir().unwrap();
181        File::create(dir.path().join("requirements.txt")).unwrap();
182        make_venv(dir.path(), "my_env");
183        assert!(Venv.detect(dir.path()));
184    }
185
186    #[test]
187    fn test_detect_positive_env() {
188        let dir = tempdir().unwrap();
189        File::create(dir.path().join("requirements.txt")).unwrap();
190        make_venv(dir.path(), "env");
191        assert!(Venv.detect(dir.path()));
192    }
193
194    #[test]
195    fn test_detect_negative_no_req() {
196        let dir = tempdir().unwrap();
197        make_venv(dir.path(), ".venv");
198        assert!(!Venv.detect(dir.path()));
199    }
200
201    #[test]
202    fn test_detect_negative_no_env() {
203        let dir = tempdir().unwrap();
204        File::create(dir.path().join("requirements.txt")).unwrap();
205        // A plain directory without pyvenv.cfg — not a venv
206        fs::create_dir(dir.path().join("not_a_venv")).unwrap();
207        assert!(!Venv.detect(dir.path()));
208    }
209
210    #[test]
211    fn test_detect_negative_uv_lock() {
212        let dir = tempdir().unwrap();
213        File::create(dir.path().join("requirements.txt")).unwrap();
214        File::create(dir.path().join("uv.lock")).unwrap();
215        make_venv(dir.path(), ".venv");
216        assert!(!Venv.detect(dir.path()));
217    }
218
219    #[test]
220    fn test_bloat_dirs_present() {
221        let dir = tempdir().unwrap();
222        make_venv(dir.path(), ".venv");
223        make_venv(dir.path(), "my_env");
224        let dirs = Venv.bloat_dirs(dir.path());
225        assert_eq!(dirs.len(), 2);
226        let names: Vec<&str> = dirs.iter().map(|d| d.name.as_str()).collect();
227        assert!(names.contains(&".venv"));
228        assert!(names.contains(&"my_env"));
229    }
230
231    #[test]
232    fn test_bloat_dirs_absent() {
233        let dir = tempdir().unwrap();
234        let dirs = Venv.bloat_dirs(dir.path());
235        assert!(dirs.is_empty());
236    }
237
238    #[test]
239    fn test_bloat_dirs_ignores_non_venv_dirs() {
240        let dir = tempdir().unwrap();
241        // A dir without pyvenv.cfg should NOT be returned
242        fs::create_dir(dir.path().join("src")).unwrap();
243        make_venv(dir.path(), ".venv");
244        let dirs = Venv.bloat_dirs(dir.path());
245        assert_eq!(dirs.len(), 1);
246        assert_eq!(dirs[0].name, ".venv");
247    }
248}