dev_prune/adapters/
venv.rs1use super::{BloatDir, EnforcePolicy, PackageManager, dir_size, run_command};
14use anyhow::{Result, anyhow};
15use std::fs;
16use std::path::Path;
17
18const PYVENV_CFG: &str = "pyvenv.cfg";
20
21pub struct Venv;
23
24fn 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 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 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 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 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 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 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 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 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}