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.
39fn 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                // Only the first `name` in each `[[package]]` entry is the package's own.
59                in_package = false;
60            }
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.
70fn 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    /// Enforces the lockfile without writing it.
121    ///
122    /// `uv lock --locked` asserts that `uv.lock` is already up to date with
123    /// `pyproject.toml` and exits non-zero instead of rewriting it when it is not.
124    /// Plain `uv lock` is the writing form, for the case where no lockfile exists yet.
125    fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
126        let lockfile = path.join("uv.lock");
127
128        // Generating a lockfile from `pyproject.toml` only proves the *declared*
129        // dependencies resolve — it says nothing about what is actually installed in
130        // `.venv`. When the venv was not even created by uv, `uv sync` against that
131        // fresh lock could rebuild a different environment than the one deleted, so
132        // refuse instead of manufacturing proof.
133        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        // The environment can hold packages the lockfile never recorded — a
152        // `uv pip install foo` nobody wrote back. `uv.lock` pins the full transitive
153        // closure, so anything installed but absent from it is recoverable from
154        // nowhere, which is exactly what this tool promises never to delete.
155        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    /// The comparison `enforce_lockfile` refuses on, as data: distributions in `.venv`
189    /// that `uv.lock` does not pin.
190    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        // `uvloop` starts with "uv" but is not the uv stamp.
283        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}