1use super::venv::{BASELINE_DISTRIBUTIONS, installed_distributions, normalize_package_name};
7use super::{
8 BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
9};
10use anyhow::{Result, anyhow};
11use std::collections::HashSet;
12use std::fs;
13use std::path::Path;
14
15pub struct Uv;
17
18fn venv_is_uv_managed(path: &Path) -> bool {
22 fs::read_to_string(path.join(".venv").join("pyvenv.cfg"))
23 .map(|content| {
24 content.lines().any(|line| {
25 line.trim_start()
26 .strip_prefix("uv")
27 .is_some_and(|rest| rest.trim_start().starts_with('='))
28 })
29 })
30 .unwrap_or(false)
31}
32
33pub(super) fn lockfile_package_names(lockfile: &Path) -> Option<HashSet<String>> {
40 let content = fs::read_to_string(lockfile).ok()?;
41 let mut names = HashSet::new();
42 let mut in_package = false;
43 for raw in content.lines() {
44 let line = raw.trim();
45 if line.starts_with('[') {
46 in_package = line == "[[package]]";
47 continue;
48 }
49 if !in_package {
50 continue;
51 }
52 if let Some(rest) = line.strip_prefix("name")
53 && let Some(value) = rest.trim_start().strip_prefix('=')
54 {
55 let value = value.trim().trim_matches('"');
56 if !value.is_empty() {
57 names.insert(normalize_package_name(value));
58 }
59 in_package = false;
61 }
62 }
63 (!names.is_empty()).then_some(names)
64}
65
66pub(super) fn unlocked_packages(path: &Path, locked: &HashSet<String>) -> Vec<String> {
71 let Some(installed) = installed_distributions(&path.join(".venv")) else {
72 return Vec::new();
73 };
74 let mut extras: Vec<String> = installed
75 .keys()
76 .filter(|name| !locked.contains(*name) && !BASELINE_DISTRIBUTIONS.contains(&name.as_str()))
77 .cloned()
78 .collect();
79 extras.sort();
80 extras
81}
82
83impl PackageManager for Uv {
84 fn name(&self) -> &'static str {
85 "uv"
86 }
87
88 fn detect(&self, path: &Path) -> bool {
89 let uv_lock = path.join("uv.lock");
90 if uv_lock.exists() {
91 return true;
92 }
93
94 let pyproject = path.join("pyproject.toml");
95 if pyproject.exists()
96 && let Ok(content) = fs::read_to_string(&pyproject)
97 && content.contains("[tool.uv]")
98 {
99 return true;
100 }
101
102 false
103 }
104
105 fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
106 let mut dirs = Vec::new();
107 let venv_path = path.join(".venv");
108 if venv_path.exists() {
109 dirs.push(BloatDir {
110 name: ".venv".to_string(),
111 path: venv_path.clone(),
112 size_bytes: dir_size(&venv_path),
113 shared_bytes: 0,
114 });
115 }
116 dirs
117 }
118
119 fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
125 let lockfile = path.join("uv.lock");
126
127 if !lockfile.exists() && path.join(".venv").exists() && !venv_is_uv_managed(path) {
133 return Err(anyhow!(
134 "`pyproject.toml` declares `[tool.uv]` but there is no `uv.lock`, and \
135 `.venv` was not created by uv — a generated lockfile could not prove the \
136 environment's contents are recoverable. Rebuild the environment under uv \
137 first: `uv lock` then `uv sync`."
138 ));
139 }
140
141 enforce_two_tier(
142 &lockfile,
143 "uv",
144 &["lock", "--locked"],
145 &["lock"],
146 path,
147 policy,
148 )?;
149
150 if let Some(locked) = lockfile_package_names(&lockfile) {
155 let extras = unlocked_packages(path, &locked);
156 if !extras.is_empty() {
157 let shown = extras
158 .iter()
159 .take(10)
160 .cloned()
161 .collect::<Vec<_>>()
162 .join(", ");
163 let suffix = if extras.len() > 10 {
164 format!(", … and {} more", extras.len() - 10)
165 } else {
166 String::new()
167 };
168 return Err(anyhow!(
169 "`.venv` holds {} package(s) that uv.lock does not record \
170 ({shown}{suffix}). They were installed ad hoc and `uv sync` would \
171 not bring them back. Record them first: `uv add <package>`.",
172 extras.len()
173 ));
174 }
175 }
176 Ok(())
177 }
178
179 fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
180 run_command_with_timeout("uv", &["sync"], path, timeout)
181 }
182
183 fn lockfiles(&self) -> &'static [&'static str] {
184 &["uv.lock"]
185 }
186
187 fn drift(&self, path: &Path) -> Vec<super::DriftReport> {
190 let Some(locked) = lockfile_package_names(&path.join("uv.lock")) else {
191 return Vec::new();
192 };
193 let extras = unlocked_packages(path, &locked);
194 if extras.is_empty() {
195 return Vec::new();
196 }
197 vec![super::DriftReport {
198 directory: ".venv".to_string(),
199 unrecorded: extras,
200 record_command: "uv add <package>",
201 }]
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use std::fs::File;
209 use std::io::Write;
210 use tempfile::tempdir;
211
212 #[test]
213 fn test_name() {
214 let adapter = Uv;
215 assert_eq!(adapter.name(), "uv");
216 }
217
218 #[test]
219 fn test_detect_positive_lock() {
220 let dir = tempdir().unwrap();
221 File::create(dir.path().join("uv.lock")).unwrap();
222
223 let adapter = Uv;
224 assert!(adapter.detect(dir.path()));
225 }
226
227 #[test]
228 fn test_detect_positive_toml() {
229 let dir = tempdir().unwrap();
230 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
231 writeln!(file, "[tool.uv]").unwrap();
232
233 let adapter = Uv;
234 assert!(adapter.detect(dir.path()));
235 }
236
237 #[test]
238 fn test_detect_negative() {
239 let dir = tempdir().unwrap();
240
241 let adapter = Uv;
242 assert!(!adapter.detect(dir.path()));
243 }
244
245 #[test]
246 fn test_bloat_dirs_present() {
247 let dir = tempdir().unwrap();
248 fs::create_dir(dir.path().join(".venv")).unwrap();
249
250 let adapter = Uv;
251 let dirs = adapter.bloat_dirs(dir.path());
252 assert_eq!(dirs.len(), 1);
253 assert_eq!(dirs[0].name, ".venv");
254 }
255
256 #[test]
257 fn test_bloat_dirs_absent() {
258 let dir = tempdir().unwrap();
259
260 let adapter = Uv;
261 let dirs = adapter.bloat_dirs(dir.path());
262 assert!(dirs.is_empty());
263 }
264
265 #[test]
266 fn a_uv_stamped_pyvenv_cfg_marks_the_venv_as_uv_managed() {
267 let dir = tempdir().unwrap();
268 let venv = dir.path().join(".venv");
269 fs::create_dir(&venv).unwrap();
270 let mut cfg = File::create(venv.join("pyvenv.cfg")).unwrap();
271 writeln!(cfg, "home = /usr/bin").unwrap();
272 writeln!(cfg, "uv = 0.5.9").unwrap();
273 assert!(venv_is_uv_managed(dir.path()));
274 }
275
276 #[test]
277 fn a_pip_built_venv_is_not_mistaken_for_a_uv_one() {
278 let dir = tempdir().unwrap();
279 let venv = dir.path().join(".venv");
280 fs::create_dir(&venv).unwrap();
281 let mut cfg = File::create(venv.join("pyvenv.cfg")).unwrap();
283 writeln!(cfg, "home = /usr/bin").unwrap();
284 writeln!(cfg, "uvloop = 1.0").unwrap();
285 assert!(!venv_is_uv_managed(dir.path()));
286 }
287
288 #[test]
289 fn lockfile_names_come_from_package_entries_only() {
290 let dir = tempdir().unwrap();
291 let lock = dir.path().join("uv.lock");
292 fs::write(
293 &lock,
294 "version = 1\n\n[[package]]\nname = \"Requests\"\nversion = \"2.32.3\"\n\n\
295 [package.metadata]\nname = \"not-a-package\"\n\n[[package]]\nname = \"my-proj\"\n",
296 )
297 .unwrap();
298 let names = lockfile_package_names(&lock).unwrap();
299 assert!(names.contains("requests"));
300 assert!(names.contains("my-proj"));
301 assert!(!names.contains("not-a-package"));
302 assert_eq!(names.len(), 2);
303 }
304
305 #[test]
306 fn an_ad_hoc_install_missing_from_the_lockfile_is_flagged() {
307 let dir = tempdir().unwrap();
308 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
309 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
310 fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
311 fs::create_dir_all(sp.join("pip-24.0.dist-info")).unwrap();
312
313 let locked: HashSet<String> = ["requests".to_string()].into();
314 assert_eq!(unlocked_packages(dir.path(), &locked), vec!["sneaky-pkg"]);
315 }
316
317 #[test]
318 fn a_foreign_venv_next_to_tool_uv_without_a_lock_is_refused() {
319 let dir = tempdir().unwrap();
320 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
321 writeln!(file, "[tool.uv]").unwrap();
322 let venv = dir.path().join(".venv");
323 fs::create_dir(&venv).unwrap();
324 File::create(venv.join("pyvenv.cfg")).unwrap();
325
326 let err = Uv
327 .enforce_lockfile(dir.path(), EnforcePolicy::default())
328 .unwrap_err();
329 assert!(err.to_string().contains("not created by uv"));
330 }
331
332 #[test]
333 fn drift_reports_the_ad_hoc_install_as_data() {
334 let dir = tempdir().unwrap();
335 fs::write(
336 dir.path().join("uv.lock"),
337 "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
338 )
339 .unwrap();
340 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
341 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
342 fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
343
344 let reports = Uv.drift(dir.path());
345 assert_eq!(reports.len(), 1);
346 assert_eq!(reports[0].directory, ".venv");
347 assert_eq!(reports[0].unrecorded, vec!["sneaky-pkg"]);
348 assert_eq!(reports[0].record_command, "uv add <package>");
349 }
350
351 #[test]
352 fn drift_is_silent_when_the_lockfile_records_everything() {
353 let dir = tempdir().unwrap();
354 fs::write(
355 dir.path().join("uv.lock"),
356 "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
357 )
358 .unwrap();
359 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
360 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
361
362 assert!(Uv.drift(dir.path()).is_empty());
363 }
364}