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 self.restore_named(path, ".venv", None, timeout)
181 }
182
183 fn restore_named(
187 &self,
188 path: &Path,
189 _dir_name: &str,
190 runtime: Option<&str>,
191 timeout: std::time::Duration,
192 ) -> Result<()> {
193 match runtime.filter(|tag| super::is_valid_runtime_tag(tag)) {
194 Some(tag) => run_command_with_timeout("uv", &["sync", "--python", tag], path, timeout),
195 None => run_command_with_timeout("uv", &["sync"], path, timeout),
196 }
197 }
198
199 fn runtime_tag(&self, path: &Path, dir_name: &str) -> Option<String> {
201 super::venv_runtime_tag(&path.join(dir_name))
202 }
203
204 fn lockfiles(&self) -> &'static [&'static str] {
205 &["uv.lock"]
206 }
207
208 fn drift(&self, path: &Path) -> Vec<super::DriftReport> {
211 let Some(locked) = lockfile_package_names(&path.join("uv.lock")) else {
212 return Vec::new();
213 };
214 let extras = unlocked_packages(path, &locked);
215 if extras.is_empty() {
216 return Vec::new();
217 }
218 vec![super::DriftReport {
219 directory: ".venv".to_string(),
220 unrecorded: extras,
221 record_command: "uv add <package>",
222 }]
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use std::fs::File;
230 use std::io::Write;
231 use tempfile::tempdir;
232
233 #[test]
234 fn test_name() {
235 let adapter = Uv;
236 assert_eq!(adapter.name(), "uv");
237 }
238
239 #[test]
240 fn test_detect_positive_lock() {
241 let dir = tempdir().unwrap();
242 File::create(dir.path().join("uv.lock")).unwrap();
243
244 let adapter = Uv;
245 assert!(adapter.detect(dir.path()));
246 }
247
248 #[test]
249 fn test_detect_positive_toml() {
250 let dir = tempdir().unwrap();
251 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
252 writeln!(file, "[tool.uv]").unwrap();
253
254 let adapter = Uv;
255 assert!(adapter.detect(dir.path()));
256 }
257
258 #[test]
259 fn test_detect_negative() {
260 let dir = tempdir().unwrap();
261
262 let adapter = Uv;
263 assert!(!adapter.detect(dir.path()));
264 }
265
266 #[test]
267 fn test_bloat_dirs_present() {
268 let dir = tempdir().unwrap();
269 fs::create_dir(dir.path().join(".venv")).unwrap();
270
271 let adapter = Uv;
272 let dirs = adapter.bloat_dirs(dir.path());
273 assert_eq!(dirs.len(), 1);
274 assert_eq!(dirs[0].name, ".venv");
275 }
276
277 #[test]
278 fn test_bloat_dirs_absent() {
279 let dir = tempdir().unwrap();
280
281 let adapter = Uv;
282 let dirs = adapter.bloat_dirs(dir.path());
283 assert!(dirs.is_empty());
284 }
285
286 #[test]
287 fn a_uv_stamped_pyvenv_cfg_marks_the_venv_as_uv_managed() {
288 let dir = tempdir().unwrap();
289 let venv = dir.path().join(".venv");
290 fs::create_dir(&venv).unwrap();
291 let mut cfg = File::create(venv.join("pyvenv.cfg")).unwrap();
292 writeln!(cfg, "home = /usr/bin").unwrap();
293 writeln!(cfg, "uv = 0.5.9").unwrap();
294 assert!(venv_is_uv_managed(dir.path()));
295 }
296
297 #[test]
298 fn a_pip_built_venv_is_not_mistaken_for_a_uv_one() {
299 let dir = tempdir().unwrap();
300 let venv = dir.path().join(".venv");
301 fs::create_dir(&venv).unwrap();
302 let mut cfg = File::create(venv.join("pyvenv.cfg")).unwrap();
304 writeln!(cfg, "home = /usr/bin").unwrap();
305 writeln!(cfg, "uvloop = 1.0").unwrap();
306 assert!(!venv_is_uv_managed(dir.path()));
307 }
308
309 #[test]
310 fn lockfile_names_come_from_package_entries_only() {
311 let dir = tempdir().unwrap();
312 let lock = dir.path().join("uv.lock");
313 fs::write(
314 &lock,
315 "version = 1\n\n[[package]]\nname = \"Requests\"\nversion = \"2.32.3\"\n\n\
316 [package.metadata]\nname = \"not-a-package\"\n\n[[package]]\nname = \"my-proj\"\n",
317 )
318 .unwrap();
319 let names = lockfile_package_names(&lock).unwrap();
320 assert!(names.contains("requests"));
321 assert!(names.contains("my-proj"));
322 assert!(!names.contains("not-a-package"));
323 assert_eq!(names.len(), 2);
324 }
325
326 #[test]
327 fn an_ad_hoc_install_missing_from_the_lockfile_is_flagged() {
328 let dir = tempdir().unwrap();
329 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
330 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
331 fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
332 fs::create_dir_all(sp.join("pip-24.0.dist-info")).unwrap();
333
334 let locked: HashSet<String> = ["requests".to_string()].into();
335 assert_eq!(unlocked_packages(dir.path(), &locked), vec!["sneaky-pkg"]);
336 }
337
338 #[test]
339 fn a_foreign_venv_next_to_tool_uv_without_a_lock_is_refused() {
340 let dir = tempdir().unwrap();
341 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
342 writeln!(file, "[tool.uv]").unwrap();
343 let venv = dir.path().join(".venv");
344 fs::create_dir(&venv).unwrap();
345 File::create(venv.join("pyvenv.cfg")).unwrap();
346
347 let err = Uv
348 .enforce_lockfile(dir.path(), EnforcePolicy::default())
349 .unwrap_err();
350 assert!(err.to_string().contains("not created by uv"));
351 }
352
353 #[test]
354 fn drift_reports_the_ad_hoc_install_as_data() {
355 let dir = tempdir().unwrap();
356 fs::write(
357 dir.path().join("uv.lock"),
358 "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
359 )
360 .unwrap();
361 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
362 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
363 fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
364
365 let reports = Uv.drift(dir.path());
366 assert_eq!(reports.len(), 1);
367 assert_eq!(reports[0].directory, ".venv");
368 assert_eq!(reports[0].unrecorded, vec!["sneaky-pkg"]);
369 assert_eq!(reports[0].record_command, "uv add <package>");
370 }
371
372 #[test]
373 fn drift_is_silent_when_the_lockfile_records_everything() {
374 let dir = tempdir().unwrap();
375 fs::write(
376 dir.path().join("uv.lock"),
377 "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
378 )
379 .unwrap();
380 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
381 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
382
383 assert!(Uv.drift(dir.path()).is_empty());
384 }
385}