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        self.restore_named(path, ".venv", None, timeout)
181    }
182
183    /// `uv sync --python 3.12` rebuilds on that interpreter and, unlike every other
184    /// route here, *downloads* it when the machine does not have it — which is why the
185    /// tag is passed straight through without an availability check first.
186    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    /// The interpreter `.venv` was built with, so a restore can rebuild on it.
200    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    /// The comparison `enforce_lockfile` refuses on, as data: distributions in `.venv`
209    /// that `uv.lock` does not pin.
210    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        // `uvloop` starts with "uv" but is not the uv stamp.
303        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}