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 crate::declared::{Gap, RebuildCheck, label_of, on_path, path_of, relative_parts};
11use anyhow::{Result, anyhow};
12use std::collections::HashSet;
13use std::fs;
14use std::path::Path;
15
16pub struct Uv;
18
19fn venv_is_uv_managed(path: &Path) -> bool {
23 fs::read_to_string(path.join(".venv").join("pyvenv.cfg"))
24 .map(|content| {
25 content.lines().any(|line| {
26 line.trim_start()
27 .strip_prefix("uv")
28 .is_some_and(|rest| rest.trim_start().starts_with('='))
29 })
30 })
31 .unwrap_or(false)
32}
33
34pub(super) fn lockfile_package_names(lockfile: &Path) -> Option<HashSet<String>> {
41 let content = fs::read_to_string(lockfile).ok()?;
42 let mut names = HashSet::new();
43 let mut in_package = false;
44 for raw in content.lines() {
45 let line = raw.trim();
46 if line.starts_with('[') {
47 in_package = line == "[[package]]";
48 continue;
49 }
50 if !in_package {
51 continue;
52 }
53 if let Some(rest) = line.strip_prefix("name")
54 && let Some(value) = rest.trim_start().strip_prefix('=')
55 {
56 let value = value.trim().trim_matches('"');
57 if !value.is_empty() {
58 names.insert(normalize_package_name(value));
59 }
60 in_package = false;
62 }
63 }
64 (!names.is_empty()).then_some(names)
65}
66
67pub(super) fn unlocked_packages(path: &Path, locked: &HashSet<String>) -> Vec<String> {
72 let Some(installed) = installed_distributions(&path.join(".venv")) else {
73 return Vec::new();
74 };
75 let mut extras: Vec<String> = installed
76 .keys()
77 .filter(|name| !locked.contains(*name) && !BASELINE_DISTRIBUTIONS.contains(&name.as_str()))
78 .cloned()
79 .collect();
80 extras.sort();
81 extras
82}
83
84impl PackageManager for Uv {
85 fn name(&self) -> &'static str {
86 "uv"
87 }
88
89 fn detect(&self, path: &Path) -> bool {
90 let uv_lock = path.join("uv.lock");
91 if uv_lock.exists() {
92 return true;
93 }
94
95 let pyproject = path.join("pyproject.toml");
96 if pyproject.exists()
97 && let Ok(content) = fs::read_to_string(&pyproject)
98 && content.contains("[tool.uv]")
99 {
100 return true;
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 self.restore_named(path, ".venv", None, timeout)
182 }
183
184 fn restore_named(
188 &self,
189 path: &Path,
190 _dir_name: &str,
191 runtime: Option<&str>,
192 timeout: std::time::Duration,
193 ) -> Result<()> {
194 match runtime.filter(|tag| super::is_valid_runtime_tag(tag)) {
195 Some(tag) => run_command_with_timeout("uv", &["sync", "--python", tag], path, timeout),
196 None => run_command_with_timeout("uv", &["sync"], path, timeout),
197 }
198 }
199
200 fn runtime_tag(&self, path: &Path, dir_name: &str) -> Option<String> {
202 super::venv_runtime_tag(&path.join(dir_name))
203 }
204
205 fn lockfiles(&self) -> &'static [&'static str] {
206 &["uv.lock"]
207 }
208
209 fn drift(&self, path: &Path) -> Vec<super::DriftReport> {
212 let Some(locked) = lockfile_package_names(&path.join("uv.lock")) else {
213 return Vec::new();
214 };
215 let extras = unlocked_packages(path, &locked);
216 if extras.is_empty() {
217 return Vec::new();
218 }
219 vec![super::DriftReport {
220 directory: ".venv".to_string(),
221 unrecorded: extras,
222 record_command: "uv add <package>",
223 }]
224 }
225}
226
227pub(crate) struct UvScripts;
229
230impl RebuildCheck for UvScripts {
231 fn tools(&self) -> &'static [&'static str] {
232 &["uv"]
233 }
234
235 fn gap(&self, repo_path: &Path, _tool: &str, args: &[&str]) -> Option<Gap> {
236 uv_script_gap(repo_path, args)
237 }
238}
239
240struct PyprojectLookup {
242 defined: bool,
244 from_dependency: bool,
246 in_tool_uv_scripts: bool,
248}
249
250fn uv_script_gap(repo_path: &Path, args: &[&str]) -> Option<Gap> {
252 let mut dir = None;
253 let mut saw_run = false;
254 let mut script = None;
255 let mut i = 0;
256 while i < args.len() {
257 let arg = args[i];
258 if arg == "--" {
259 return None;
260 }
261 if let Some(value) = arg
262 .strip_prefix("--directory=")
263 .or_else(|| arg.strip_prefix("--project="))
264 {
265 dir = Some(value.to_string());
266 } else if arg == "--directory" || arg == "--project" {
267 dir = Some((*args.get(i + 1)?).to_string());
268 i += 1;
269 } else if arg == "run" {
270 saw_run = true;
271 } else if arg.starts_with('-') {
272 return None;
273 } else if saw_run {
274 script = Some(arg.to_string());
275 break;
276 } else {
277 return None;
279 }
280 i += 1;
281 }
282 let script = script?;
283 if script.contains(['/', '\\']) || script.ends_with(".py") {
286 return None;
287 }
288 if on_path(&script) {
290 return None;
291 }
292 let parts = relative_parts(dir.as_deref())?;
293 let manifest = label_of(&parts, "pyproject.toml");
294 let content = fs::read_to_string(path_of(repo_path, &parts, "pyproject.toml")).ok()?;
295 let found = pyproject_lookup(&content, &script)?;
296 if found.defined || found.from_dependency {
297 return None;
298 }
299 if lockfile_records(&path_of(repo_path, &parts, "uv.lock"), &script) {
302 return None;
303 }
304 let fix = if found.in_tool_uv_scripts {
305 format!(
306 "`[tool.uv.scripts]` is not a table uv reads — it is silently ignored. Move \
307 `{script}` to `[project.scripts]` in `{manifest}`, or fix the command."
308 )
309 } else {
310 format!("Add `{script}` to `[project.scripts]` in `{manifest}`, or fix the command.")
311 };
312 Some(Gap {
313 what: format!("`{manifest}` defines no `{script}` entry point"),
314 fix,
315 })
316}
317
318fn pyproject_lookup(content: &str, wanted: &str) -> Option<PyprojectLookup> {
324 let mut found = PyprojectLookup {
325 defined: false,
326 from_dependency: false,
327 in_tool_uv_scripts: false,
328 };
329 let mut table = "";
330 for raw in content.lines() {
331 let line = raw.trim();
332 if line.is_empty() || line.starts_with('#') {
333 continue;
334 }
335 if line.starts_with('[') {
336 table = line;
337 continue;
338 }
339 if line.starts_with("project.scripts") {
341 return None;
342 }
343 if quoted_strings(line).any(|value| same_name(requirement_head(value), wanted)) {
347 found.from_dependency = true;
348 }
349 let Some((key, _)) = line.split_once('=') else {
350 continue;
351 };
352 let key = key.trim().trim_matches(['"', '\'']);
353 match table {
354 "[project]" if key == "scripts" => return None,
356 "[project.scripts]" | "[project.gui-scripts]" if same_name(key, wanted) => {
357 found.defined = true;
358 }
359 "[tool.uv.scripts]" if same_name(key, wanted) => found.in_tool_uv_scripts = true,
360 _ => {}
361 }
362 }
363 Some(found)
364}
365
366fn quoted_strings(line: &str) -> impl Iterator<Item = &str> {
368 line.split('"').skip(1).step_by(2)
369}
370
371fn requirement_head(raw: &str) -> &str {
373 raw.trim()
374 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'))
375 .next()
376 .unwrap_or("")
377}
378
379fn same_name(a: &str, b: &str) -> bool {
381 a.replace('_', "-")
382 .eq_ignore_ascii_case(&b.replace('_', "-"))
383}
384
385fn lockfile_records(lockfile: &Path, wanted: &str) -> bool {
387 let Ok(content) = fs::read_to_string(lockfile) else {
388 return false;
389 };
390 content.lines().any(|raw| {
391 raw.trim()
392 .strip_prefix("name")
393 .and_then(|rest| rest.trim_start().strip_prefix('='))
394 .is_some_and(|value| same_name(value.trim().trim_matches('"'), wanted))
395 })
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use std::fs::File;
402 use std::io::Write;
403 use tempfile::tempdir;
404
405 #[test]
406 fn test_name() {
407 let adapter = Uv;
408 assert_eq!(adapter.name(), "uv");
409 }
410
411 #[test]
412 fn test_detect_positive_lock() {
413 let dir = tempdir().unwrap();
414 File::create(dir.path().join("uv.lock")).unwrap();
415
416 let adapter = Uv;
417 assert!(adapter.detect(dir.path()));
418 }
419
420 #[test]
421 fn test_detect_positive_toml() {
422 let dir = tempdir().unwrap();
423 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
424 writeln!(file, "[tool.uv]").unwrap();
425
426 let adapter = Uv;
427 assert!(adapter.detect(dir.path()));
428 }
429
430 #[test]
431 fn test_detect_negative() {
432 let dir = tempdir().unwrap();
433
434 let adapter = Uv;
435 assert!(!adapter.detect(dir.path()));
436 }
437
438 #[test]
439 fn test_bloat_dirs_present() {
440 let dir = tempdir().unwrap();
441 fs::create_dir(dir.path().join(".venv")).unwrap();
442
443 let adapter = Uv;
444 let dirs = adapter.bloat_dirs(dir.path());
445 assert_eq!(dirs.len(), 1);
446 assert_eq!(dirs[0].name, ".venv");
447 }
448
449 #[test]
450 fn test_bloat_dirs_absent() {
451 let dir = tempdir().unwrap();
452
453 let adapter = Uv;
454 let dirs = adapter.bloat_dirs(dir.path());
455 assert!(dirs.is_empty());
456 }
457
458 #[test]
459 fn a_uv_stamped_pyvenv_cfg_marks_the_venv_as_uv_managed() {
460 let dir = tempdir().unwrap();
461 let venv = dir.path().join(".venv");
462 fs::create_dir(&venv).unwrap();
463 let mut cfg = File::create(venv.join("pyvenv.cfg")).unwrap();
464 writeln!(cfg, "home = /usr/bin").unwrap();
465 writeln!(cfg, "uv = 0.5.9").unwrap();
466 assert!(venv_is_uv_managed(dir.path()));
467 }
468
469 #[test]
470 fn a_pip_built_venv_is_not_mistaken_for_a_uv_one() {
471 let dir = tempdir().unwrap();
472 let venv = dir.path().join(".venv");
473 fs::create_dir(&venv).unwrap();
474 let mut cfg = File::create(venv.join("pyvenv.cfg")).unwrap();
476 writeln!(cfg, "home = /usr/bin").unwrap();
477 writeln!(cfg, "uvloop = 1.0").unwrap();
478 assert!(!venv_is_uv_managed(dir.path()));
479 }
480
481 #[test]
482 fn lockfile_names_come_from_package_entries_only() {
483 let dir = tempdir().unwrap();
484 let lock = dir.path().join("uv.lock");
485 fs::write(
486 &lock,
487 "version = 1\n\n[[package]]\nname = \"Requests\"\nversion = \"2.32.3\"\n\n\
488 [package.metadata]\nname = \"not-a-package\"\n\n[[package]]\nname = \"my-proj\"\n",
489 )
490 .unwrap();
491 let names = lockfile_package_names(&lock).unwrap();
492 assert!(names.contains("requests"));
493 assert!(names.contains("my-proj"));
494 assert!(!names.contains("not-a-package"));
495 assert_eq!(names.len(), 2);
496 }
497
498 #[test]
499 fn an_ad_hoc_install_missing_from_the_lockfile_is_flagged() {
500 let dir = tempdir().unwrap();
501 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
502 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
503 fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
504 fs::create_dir_all(sp.join("pip-24.0.dist-info")).unwrap();
505
506 let locked: HashSet<String> = ["requests".to_string()].into();
507 assert_eq!(unlocked_packages(dir.path(), &locked), vec!["sneaky-pkg"]);
508 }
509
510 #[test]
511 fn a_foreign_venv_next_to_tool_uv_without_a_lock_is_refused() {
512 let dir = tempdir().unwrap();
513 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
514 writeln!(file, "[tool.uv]").unwrap();
515 let venv = dir.path().join(".venv");
516 fs::create_dir(&venv).unwrap();
517 File::create(venv.join("pyvenv.cfg")).unwrap();
518
519 let err = Uv
520 .enforce_lockfile(dir.path(), EnforcePolicy::default())
521 .unwrap_err();
522 assert!(err.to_string().contains("not created by uv"));
523 }
524
525 #[test]
526 fn drift_reports_the_ad_hoc_install_as_data() {
527 let dir = tempdir().unwrap();
528 fs::write(
529 dir.path().join("uv.lock"),
530 "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
531 )
532 .unwrap();
533 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
534 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
535 fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
536
537 let reports = Uv.drift(dir.path());
538 assert_eq!(reports.len(), 1);
539 assert_eq!(reports[0].directory, ".venv");
540 assert_eq!(reports[0].unrecorded, vec!["sneaky-pkg"]);
541 assert_eq!(reports[0].record_command, "uv add <package>");
542 }
543
544 #[test]
545 fn drift_is_silent_when_the_lockfile_records_everything() {
546 let dir = tempdir().unwrap();
547 fs::write(
548 dir.path().join("uv.lock"),
549 "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
550 )
551 .unwrap();
552 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
553 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
554
555 assert!(Uv.drift(dir.path()).is_empty());
556 }
557}