Skip to main content

dev_prune/adapters/
uv.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// uv package manager adapter for Python projects.
5
6use 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
15/// Adapter for uv-based Python projects.
16pub struct Uv;
17
18/// Whether `.venv` was created by uv itself — uv stamps a `uv = <version>` key into
19/// `pyvenv.cfg`. A venv without the stamp was built by some other tool, and uv can make
20/// no claims about what is inside it.
21fn 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
33/// Every package name `uv.lock` pins, normalised.
34///
35/// `uv.lock` records the full transitive closure — including the project itself — so a
36/// plain name scan is enough; no dependency graph needed. `None` when no names could be
37/// read at all, in which case the caller skips the drift comparison rather than refusing
38/// on a lockfile format this scan does not understand.
39pub(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            // Only the first `name` in each `[[package]]` entry is the package's own.
60            in_package = false;
61        }
62    }
63    (!names.is_empty()).then_some(names)
64}
65
66/// Installed distributions in `.venv` that `uv.lock` does not record.
67///
68/// Those were installed ad hoc (`uv pip install …`) and `uv sync` after deletion would
69/// not bring them back — exactly what this tool promises never to lose.
70pub(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    /// Enforces the lockfile without writing it.
120    ///
121    /// `uv lock --locked` asserts that `uv.lock` is already up to date with
122    /// `pyproject.toml` and exits non-zero instead of rewriting it when it is not.
123    /// Plain `uv lock` is the writing form, for the case where no lockfile exists yet.
124    fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
125        let lockfile = path.join("uv.lock");
126
127        // Generating a lockfile from `pyproject.toml` only proves the *declared*
128        // dependencies resolve — it says nothing about what is actually installed in
129        // `.venv`. When the venv was not even created by uv, `uv sync` against that
130        // fresh lock could rebuild a different environment than the one deleted, so
131        // refuse instead of manufacturing proof.
132        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        // The environment can hold packages the lockfile never recorded — a
151        // `uv pip install foo` nobody wrote back. `uv.lock` pins the full transitive
152        // closure, so anything installed but absent from it is recoverable from
153        // nowhere, which is exactly what this tool promises never to delete.
154        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    /// The comparison `enforce_lockfile` refuses on, as data: distributions in `.venv`
188    /// that `uv.lock` does not pin.
189    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        // `uvloop` starts with "uv" but is not the uv stamp.
282        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}