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