Skip to main content

dev_prune/adapters/
venv.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Standard Python venv package manager adapter.
5//
6// Detects Python virtual environments by scanning the repo root for any
7// directory containing a `pyvenv.cfg` file — the canonical marker for any
8// Python virtual environment, regardless of what the folder is named
9// (`.venv`, `venv`, `env`, `my_env`, `.env`, etc.).
10//
11// Priority: the `uv` adapter takes precedence when `uv.lock` is present.
12
13use super::{BloatDir, EnforcePolicy, PackageManager, dir_size, run_command_with_timeout};
14use anyhow::{Result, anyhow};
15use std::collections::{HashMap, HashSet};
16use std::fs;
17use std::path::{Path, PathBuf};
18
19/// The canonical file inside every Python virtual environment.
20const PYVENV_CFG: &str = "pyvenv.cfg";
21
22/// Lockfiles owned by other Python managers.
23///
24/// A poetry, pipenv or pdm project often carries an exported `requirements.txt` as well —
25/// usually stale. Rebuilding the environment from that export instead of the real
26/// lockfile would silently install the wrong versions, so those projects are never
27/// claimed here: poetry has its own adapter, and pipenv/pdm are left to their own tools.
28const FOREIGN_PYTHON_LOCKFILES: [&str; 3] = ["poetry.lock", "Pipfile.lock", "pdm.lock"];
29
30/// Distributions present in effectively every virtual environment without ever being
31/// listed in a requirements file.
32pub(super) const BASELINE_DISTRIBUTIONS: [&str; 4] =
33    ["pip", "setuptools", "wheel", "pkg-resources"];
34
35/// Adapter for standard Python venv projects.
36pub struct Venv;
37
38/// Scan the repo root for directories containing `pyvenv.cfg`.
39///
40/// This catches any venv folder name: `.venv`, `venv`, `env`, `my_env`, etc.
41fn find_venv_dirs(path: &Path) -> Vec<std::path::PathBuf> {
42    let mut found = Vec::new();
43
44    let Ok(entries) = fs::read_dir(path) else {
45        return found;
46    };
47
48    for entry in entries.flatten() {
49        let entry_path = entry.path();
50        if entry_path.is_dir() && entry_path.join(PYVENV_CFG).exists() {
51            found.push(entry_path);
52        }
53    }
54
55    found
56}
57
58/// Whether `pyproject.toml` declares a `[tool.poetry]` table.
59///
60/// A textual check rather than a TOML parse: the table header only ever appears at the
61/// start of a line, and this adapter needs a yes/no, not the table's contents.
62fn is_poetry_project(path: &Path) -> bool {
63    fs::read_to_string(path.join("pyproject.toml"))
64        .map(|c| {
65            c.lines()
66                .any(|l| l.trim_start().starts_with("[tool.poetry"))
67        })
68        .unwrap_or(false)
69}
70
71/// A package name as PEP 503 compares them: lowercased, with runs of `-`, `_` and `.`
72/// collapsed to a single `-`, so `Foo_Bar` and `foo-bar` are the same package.
73pub(super) fn normalize_package_name(name: &str) -> String {
74    let mut out = String::with_capacity(name.len());
75    let mut last_dash = false;
76    for c in name.chars() {
77        if c == '-' || c == '_' || c == '.' {
78            if !last_dash {
79                out.push('-');
80            }
81            last_dash = true;
82        } else {
83            out.push(c.to_ascii_lowercase());
84            last_dash = false;
85        }
86    }
87    out
88}
89
90/// The leading package name of a PEP 508 requirement string, normalised.
91///
92/// `requests[socks]==2.32.3 ; python_version < "3.9"` → `requests`. Returns `None` for
93/// anything that does not begin with a name — URLs, local paths — because pip is the
94/// only thing that can know what those install.
95fn requirement_name(spec: &str) -> Option<String> {
96    let name: String = spec
97        .chars()
98        .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
99        .collect();
100    // A name never starts with `.` — that spelling is a relative path (`./pkg`, `..`).
101    if name.is_empty() || name.starts_with('.') || spec.contains("://") && !spec.contains(" @ ") {
102        return None;
103    }
104    Some(normalize_package_name(&name))
105}
106
107/// Every package name a requirements file pins, following `-r`/`-c` includes.
108///
109/// `None` means the file cannot be fully accounted for without running pip — an editable
110/// install, a bare URL or path, or an include that cannot be read. The caller skips the
111/// drift comparison in that case rather than guessing in either direction.
112pub(crate) fn requirement_names(
113    file: &Path,
114    visited: &mut Vec<PathBuf>,
115) -> Option<HashSet<String>> {
116    // The depth cap breaks include cycles that the exact-path check misses (e.g. the
117    // same file reached through differently-spelled relative paths).
118    if visited.len() >= 8 || visited.iter().any(|p| p == file) {
119        return None;
120    }
121    visited.push(file.to_path_buf());
122
123    let content = fs::read_to_string(file).ok()?;
124    let dir = file.parent()?;
125    let mut names = HashSet::new();
126
127    for raw in content.lines() {
128        // pip treats `#` as a comment at line start or after whitespace — never inside
129        // a URL fragment like `#egg=name`.
130        let mut line = raw.trim();
131        if let Some(idx) = line.find(" #") {
132            line = &line[..idx];
133        }
134        let line = line.trim_end_matches('\\').trim();
135        if line.is_empty() || line.starts_with('#') {
136            continue;
137        }
138
139        if let Some(included) = line
140            .strip_prefix("-r ")
141            .or_else(|| line.strip_prefix("--requirement "))
142            .or_else(|| line.strip_prefix("-c "))
143            .or_else(|| line.strip_prefix("--constraint "))
144        {
145            names.extend(requirement_names(&dir.join(included.trim()), visited)?);
146            continue;
147        }
148
149        if line.starts_with('-') {
150            // An editable install's package name only pip can compute. Every other
151            // option (`--index-url`, `--hash`, …) names no package at all.
152            if line.starts_with("-e") || line.starts_with("--editable") {
153                return None;
154            }
155            continue;
156        }
157
158        // `name @ url` is a direct reference whose name is on the left of the `@`.
159        let spec = line.split(" @ ").next().unwrap_or(line).trim();
160        names.insert(requirement_name(spec)?);
161    }
162
163    Some(names)
164}
165
166/// Installed distributions and their declared dependencies, read from the
167/// `*.dist-info` directories of a virtual environment's `site-packages`.
168///
169/// The map's keys are the installed package names; the values are the names in each
170/// package's `Requires-Dist` metadata. `None` when no `site-packages` directory could
171/// be found at all — an exotic layout is not evidence of anything.
172pub(super) fn installed_distributions(venv: &Path) -> Option<HashMap<String, Vec<String>>> {
173    let mut site_packages: Vec<PathBuf> = Vec::new();
174    let windows_layout = venv.join("Lib").join("site-packages");
175    if windows_layout.is_dir() {
176        site_packages.push(windows_layout);
177    }
178    // POSIX layout: `lib/python3.X/site-packages`. `lib64` is usually a symlink to
179    // `lib`; the HashMap deduplicates whatever both spellings yield.
180    for lib in ["lib", "lib64"] {
181        let Ok(entries) = fs::read_dir(venv.join(lib)) else {
182            continue;
183        };
184        for entry in entries.flatten() {
185            let sp = entry.path().join("site-packages");
186            if sp.is_dir() {
187                site_packages.push(sp);
188            }
189        }
190    }
191    if site_packages.is_empty() {
192        return None;
193    }
194
195    let mut installed = HashMap::new();
196    for sp in site_packages {
197        let Ok(entries) = fs::read_dir(&sp) else {
198            continue;
199        };
200        for entry in entries.flatten() {
201            let file_name = entry.file_name().to_string_lossy().into_owned();
202            let Some(stem) = file_name
203                .strip_suffix(".dist-info")
204                .or_else(|| file_name.strip_suffix(".egg-info"))
205            else {
206                continue;
207            };
208            // `{escaped_name}-{version}`: the escaping turns `-` into `_`, so the
209            // name part never contains a hyphen — but setuptools may append `-pyX.Y`
210            // to an egg-info (which a last-hyphen split read as part of the name),
211            // and a legacy editable install writes a bare `{name}.egg-info` with no
212            // version at all. Versions always start with a digit, so the name is
213            // everything before the first `-<digit>`.
214            let name = stem
215                .match_indices('-')
216                .find(|(i, _)| {
217                    stem[i + 1..]
218                        .chars()
219                        .next()
220                        .is_some_and(|c| c.is_ascii_digit())
221                })
222                .map(|(i, _)| &stem[..i])
223                .unwrap_or(stem);
224            installed.insert(
225                normalize_package_name(name),
226                declared_dependencies(&entry.path()),
227            );
228        }
229    }
230    Some(installed)
231}
232
233/// The package names in a dist-info directory's `Requires-Dist` metadata lines.
234///
235/// Extras-gated dependencies are included: if one is installed it is reachable from its
236/// parent, and this graph exists to prove reachability, not to plan an install.
237fn declared_dependencies(dist_info: &Path) -> Vec<String> {
238    let Ok(metadata) = fs::read_to_string(dist_info.join("METADATA")) else {
239        return Vec::new();
240    };
241    let mut deps = Vec::new();
242    for line in metadata.lines() {
243        // Headers end at the first blank line; the body is a README that could
244        // contain anything, including text that looks like a header.
245        if line.is_empty() {
246            break;
247        }
248        if let Some(spec) = line.strip_prefix("Requires-Dist:")
249            && let Some(name) = requirement_name(spec.trim())
250        {
251            deps.push(name);
252        }
253    }
254    deps
255}
256
257/// The `major.minor` of the Python a venv was built with, from its `pyvenv.cfg`.
258fn venv_python_version(venv: &Path) -> Option<(u64, u64)> {
259    let tag = super::venv_runtime_tag(venv)?;
260    let (major, minor) = tag.split_once('.')?;
261    Some((major.parse().ok()?, minor.parse().ok()?))
262}
263
264/// The `major.minor` of whatever `python` is on PATH — the interpreter a restore would
265/// rebuild with. `None` when there is none or it cannot say.
266fn path_python_version() -> Option<(u64, u64)> {
267    let output = crate::spawn::command(super::resolve_program("python"))
268        .arg("--version")
269        .stdin(std::process::Stdio::null())
270        .output()
271        .ok()?;
272    if !output.status.success() {
273        return None;
274    }
275    // Python 2 printed the version on stderr; 3.4+ prints it on stdout.
276    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
277    let text = if stdout.is_empty() {
278        String::from_utf8_lossy(&output.stderr).trim().to_string()
279    } else {
280        stdout
281    };
282    let version = text.split_whitespace().nth(1)?;
283    let mut parts = version.split('.');
284    Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
285}
286
287/// Say out loud, once per pass, anything that would make a restore rebuild something
288/// other than what was deleted. Warnings, not refusals: every one of these environments
289/// is still rebuildable, just not byte-for-byte.
290fn warn_about_restore_surprises(path: &Path, venvs: &[PathBuf]) {
291    if venvs.len() > 1 {
292        crate::output::print_warning(&format!(
293            "`{}` has {} virtual environments, all rebuilt from one requirements.txt. \
294             Each restores under its own recorded name; a plain `devp restore` with no \
295             record rebuilds only `.venv`.",
296            crate::output::clean_path(path),
297            venvs.len()
298        ));
299    } else if let Some(venv) = venvs.first() {
300        let name = venv.file_name().map(|n| n.to_string_lossy().into_owned());
301        if let Some(name) = name
302            && name != ".venv"
303        {
304            crate::output::print_info(&format!(
305                "The environment at `{}` is named `{name}` — `devp restore --last-run` \
306                     recreates that name, but a restore with no record creates `.venv`.",
307                crate::output::clean_path(venv)
308            ));
309        }
310    }
311
312    let on_path = path_python_version();
313    for venv in venvs {
314        if let (Some(built_with), Some(available)) = (venv_python_version(venv), on_path)
315            && built_with != available
316        {
317            crate::output::print_warning(&format!(
318                "`{}` was built with Python {}.{}, but `python` on PATH is {}.{} — a \
319                 restore would rebuild it on that interpreter instead, and pinned \
320                 wheels may not exist for it.",
321                crate::output::clean_path(venv),
322                built_with.0,
323                built_with.1,
324                available.0,
325                available.1
326            ));
327            // A warning that only names the problem leaves the reader to work out the
328            // fix, and the fix is one command. `uv venv` is offered first because it
329            // downloads the interpreter if the machine no longer has it; the launcher
330            // and `pythonX.Y` forms only work if it is already installed.
331            let dir = crate::output::clean_path(venv);
332            let (major, minor) = built_with;
333            #[cfg(windows)]
334            let native = format!("py -{major}.{minor} -m venv \"{dir}\"");
335            #[cfg(not(windows))]
336            let native = format!("python{major}.{minor} -m venv \"{dir}\"");
337            crate::output::print_info(&format!(
338                "  Rebuild on {major}.{minor}:  uv venv --python {major}.{minor} \"{dir}\"   (or `{native}`)"
339            ));
340        }
341    }
342}
343
344/// Installed packages that nothing in the requirements file accounts for.
345///
346/// A hand-written requirements file pins direct dependencies only; the environment
347/// legitimately holds their whole transitive closure. So the check walks the installed
348/// dependency graph from every pinned name and flags only what is *unreachable* — a
349/// `pip install` that was never written back, which `pip install -r` after deletion
350/// would not bring back.
351/// Whether a distribution name is this tool, under any spelling pip may hand back.
352///
353/// PEP 503 treats `dev-prune`, `dev_prune` and `Dev.Prune` as one project, and which
354/// spelling lands on disk depends on how the wheel was built rather than on anything the
355/// user did. `APP_NAME` is already in normalised form, so one side needs no conversion.
356pub(crate) fn is_dev_prune(name: &str) -> bool {
357    normalize_package_name(name) == crate::constants::APP_NAME
358}
359
360fn unrecorded_packages(
361    installed: &HashMap<String, Vec<String>>,
362    pinned: &HashSet<String>,
363) -> Vec<String> {
364    let mut reachable: HashSet<String> = HashSet::new();
365    let mut queue: Vec<String> = pinned.iter().cloned().collect();
366    queue.extend(BASELINE_DISTRIBUTIONS.iter().map(|s| (*s).to_string()));
367
368    while let Some(name) = queue.pop() {
369        if !reachable.insert(name.clone()) {
370            continue;
371        }
372        if let Some(deps) = installed.get(&name) {
373            queue.extend(deps.iter().cloned());
374        }
375    }
376
377    let mut extras: Vec<String> = installed
378        .keys()
379        .filter(|name| !reachable.contains(*name))
380        .cloned()
381        .collect();
382    extras.sort();
383    extras
384}
385
386impl PackageManager for Venv {
387    fn name(&self) -> &'static str {
388        "venv"
389    }
390
391    /// Detect a plain-venv project:
392    /// - `requirements.txt` must exist (otherwise it's probably not a managed venv project)
393    /// - At least one directory with `pyvenv.cfg` must exist in the repo root
394    /// - `uv.lock` must NOT exist (uv adapter takes priority)
395    ///
396    /// uv's precedence is also enforced centrally in `adapters::detect_adapters`, which
397    /// covers uv projects declared only through `[tool.uv]` in `pyproject.toml`.
398    fn detect(&self, path: &Path) -> bool {
399        let req_txt = path.join("requirements.txt");
400        let uv_lock = path.join("uv.lock");
401
402        if !req_txt.exists() || uv_lock.exists() {
403            return false;
404        }
405
406        // A poetry/pipenv/pdm project belongs to its own tool. Its requirements.txt is
407        // usually an export of the real lockfile — often stale — and rebuilding from it
408        // would quietly produce a different environment than the one deleted.
409        if FOREIGN_PYTHON_LOCKFILES
410            .iter()
411            .any(|f| path.join(f).exists())
412            || is_poetry_project(path)
413        {
414            return false;
415        }
416
417        !find_venv_dirs(path).is_empty()
418    }
419
420    /// Return all venv directories (any folder containing `pyvenv.cfg`) as bloat dirs.
421    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
422        find_venv_dirs(path)
423            .into_iter()
424            .map(|venv_path| {
425                let name = venv_path
426                    .file_name()
427                    .map(|n| n.to_string_lossy().to_string())
428                    .unwrap_or_else(|| venv_path.display().to_string());
429                let size = dir_size(&venv_path);
430                BloatDir {
431                    name,
432                    path: venv_path,
433                    size_bytes: size,
434                    shared_bytes: 0,
435                }
436            })
437            .collect()
438    }
439
440    /// Pure inspection: reads `requirements.txt` and runs nothing, so neither half of
441    /// [`EnforcePolicy`] applies.
442    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
443        let req_txt = path.join("requirements.txt");
444        if !req_txt.exists() {
445            return Err(anyhow!("requirements.txt missing"));
446        }
447        // An empty requirements.txt cannot rebuild the environment, so deleting the
448        // venv against it would be unrecoverable rather than merely inconvenient.
449        let has_requirements = fs::read_to_string(&req_txt)
450            .map(|c| {
451                c.lines()
452                    .any(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
453            })
454            .unwrap_or(false);
455        if !has_requirements {
456            return Err(anyhow!(
457                "requirements.txt at `{}` lists no packages — the virtual environment \
458                 could not be rebuilt after deletion. Populate it with `pip freeze > requirements.txt`.",
459                req_txt.display()
460            ));
461        }
462
463        let venvs = find_venv_dirs(path);
464        warn_about_restore_surprises(path, &venvs);
465
466        // The environment can hold packages the requirements file never recorded — a
467        // `pip install foo` nobody wrote back. Those are recoverable from nowhere, which
468        // is exactly what this tool promises never to delete. A file that cannot be
469        // fully parsed (editable installs, URLs, unreadable includes) skips the
470        // comparison rather than guessing in either direction.
471        if let Some(pinned) = requirement_names(&req_txt, &mut Vec::new()) {
472            for venv in venvs {
473                let Some(installed) = installed_distributions(&venv) else {
474                    continue;
475                };
476                let extras = unrecorded_packages(&installed, &pinned);
477                if extras.is_empty() {
478                    continue;
479                }
480                let shown = extras
481                    .iter()
482                    .take(10)
483                    .cloned()
484                    .collect::<Vec<_>>()
485                    .join(", ");
486                let suffix = if extras.len() > 10 {
487                    format!(", … and {} more", extras.len() - 10)
488                } else {
489                    String::new()
490                };
491                // The one unaccounted package this tool can name from the inside. It
492                // is nearly always the same accident -- `pip install dev-prune` typed
493                // with a project's environment active -- and the generic message sends
494                // the user to record a tool in their application's requirements file,
495                // which is the wrong repair for it. The refusal itself does not soften:
496                // an unrecorded package is an unrecorded package, whichever one it is.
497                if extras.len() == 1 && is_dev_prune(&extras[0]) {
498                    return Err(anyhow!(
499                        "`{}` holds {app}, which requirements.txt does not account for. \
500                         {app} is installed inside this project's virtual environment, \
501                         and a tool install belongs outside a project. Either remove it \
502                         — `pip uninstall {app}`, then `uv tool install {app}` — or \
503                         record it as a deliberate dev dependency with `pip freeze > \
504                         requirements.txt`. Either one makes the environment prunable. \
505                         Nothing was deleted.",
506                        venv.display(),
507                        app = crate::constants::APP_NAME
508                    ));
509                }
510                return Err(anyhow!(
511                    "`{}` holds {} package(s) that requirements.txt does not account for \
512                     ({shown}{suffix}). Deleting the environment would lose them with no \
513                     way back. Record them first: `pip freeze > requirements.txt`.",
514                    venv.display(),
515                    extras.len()
516                ));
517            }
518        }
519        Ok(())
520    }
521
522    /// Recreate the environment in `.venv` — the name used when nothing recorded the
523    /// original one. `devp restore --last-run` knows better and calls
524    /// [`PackageManager::restore_named`] with the folder name the prune deleted.
525    fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
526        self.restore_named(path, ".venv", None, timeout)
527    }
528
529    /// The interpreter this environment was built with, so a restore can rebuild on it.
530    fn runtime_tag(&self, path: &Path, dir_name: &str) -> Option<String> {
531        super::venv_runtime_tag(&path.join(dir_name))
532    }
533
534    /// Recreate the environment under the folder name it had before the prune, so
535    /// activate scripts and IDE interpreter paths keep pointing at something real.
536    fn restore_named(
537        &self,
538        path: &Path,
539        dir_name: &str,
540        runtime: Option<&str>,
541        timeout: std::time::Duration,
542    ) -> Result<()> {
543        // The recorded name comes from the registry file; a mangled entry must not be
544        // able to turn `python -m venv <name>` into a write outside the project.
545        let dir_name = if dir_name.is_empty()
546            || dir_name == "."
547            || dir_name == ".."
548            || dir_name.contains(['/', '\\'])
549        {
550            ".venv"
551        } else {
552            dir_name
553        };
554        // Rebuild on the interpreter the environment was *created* with when the prune
555        // recorded one and this machine still has it. A venv is a copy of one specific
556        // interpreter; rebuilding a 3.12 environment on 3.14 changes which wheels
557        // resolve, and the failure shows up later as an import error nobody connects
558        // back to a restore. `run_last_run` has already checked availability and cleared
559        // the tag if it was not there, so this only re-checks the single-project path.
560        let launcher = runtime
561            .filter(|tag| super::python_runtime_available(tag))
562            .and_then(super::python_launcher);
563        match launcher {
564            Some((program, prefix)) => {
565                let mut args: Vec<&str> = prefix.iter().map(String::as_str).collect();
566                args.extend_from_slice(&["-m", "venv", dir_name]);
567                run_command_with_timeout(&program, &args, path, timeout)?;
568            }
569            None => {
570                run_command_with_timeout("python", &["-m", "venv", dir_name], path, timeout)?;
571            }
572        }
573        // Absolute, because a relative program path is resolved against the parent
574        // process's working directory, not the `current_dir` handed to the child.
575        #[cfg(windows)]
576        let python = path.join(dir_name).join("Scripts").join("python.exe");
577        #[cfg(not(windows))]
578        let python = path.join(dir_name).join("bin").join("python");
579        run_command_with_timeout(
580            &python.to_string_lossy(),
581            &["-m", "pip", "install", "-r", "requirements.txt"],
582            path,
583            timeout,
584        )
585    }
586
587    /// Not a lockfile in the strict sense — `requirements.txt` pins whatever its author
588    /// pinned — but it is the file this adapter verifies and rebuilds from, which is what
589    /// the caller wants to be told about.
590    fn lockfiles(&self) -> &'static [&'static str] {
591        &["requirements.txt"]
592    }
593
594    /// The comparison `enforce_lockfile` refuses on, as data: per venv, the installed
595    /// distributions unreachable from anything `requirements.txt` pins.
596    fn drift(&self, path: &Path) -> Vec<super::DriftReport> {
597        let Some(pinned) = requirement_names(&path.join("requirements.txt"), &mut Vec::new())
598        else {
599            return Vec::new();
600        };
601        let mut reports = Vec::new();
602        for venv in find_venv_dirs(path) {
603            let Some(installed) = installed_distributions(&venv) else {
604                continue;
605            };
606            let extras = unrecorded_packages(&installed, &pinned);
607            if extras.is_empty() {
608                continue;
609            }
610            reports.push(super::DriftReport {
611                directory: venv
612                    .file_name()
613                    .map(|n| n.to_string_lossy().into_owned())
614                    .unwrap_or_else(|| venv.display().to_string()),
615                unrecorded: extras,
616                record_command: "pip freeze > requirements.txt",
617            });
618        }
619        reports
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use std::fs::{self, File};
627    use tempfile::tempdir;
628
629    fn make_venv(dir: &Path, name: &str) {
630        let venv = dir.join(name);
631        fs::create_dir(&venv).unwrap();
632        File::create(venv.join(PYVENV_CFG)).unwrap();
633    }
634
635    #[test]
636    fn test_name() {
637        assert_eq!(Venv.name(), "venv");
638    }
639
640    #[test]
641    fn test_detect_positive_dot_venv() {
642        let dir = tempdir().unwrap();
643        File::create(dir.path().join("requirements.txt")).unwrap();
644        make_venv(dir.path(), ".venv");
645        assert!(Venv.detect(dir.path()));
646    }
647
648    #[test]
649    fn test_detect_positive_venv() {
650        let dir = tempdir().unwrap();
651        File::create(dir.path().join("requirements.txt")).unwrap();
652        make_venv(dir.path(), "venv");
653        assert!(Venv.detect(dir.path()));
654    }
655
656    #[test]
657    fn test_detect_positive_custom_name() {
658        let dir = tempdir().unwrap();
659        File::create(dir.path().join("requirements.txt")).unwrap();
660        make_venv(dir.path(), "my_env");
661        assert!(Venv.detect(dir.path()));
662    }
663
664    #[test]
665    fn test_detect_positive_env() {
666        let dir = tempdir().unwrap();
667        File::create(dir.path().join("requirements.txt")).unwrap();
668        make_venv(dir.path(), "env");
669        assert!(Venv.detect(dir.path()));
670    }
671
672    #[test]
673    fn test_detect_negative_no_req() {
674        let dir = tempdir().unwrap();
675        make_venv(dir.path(), ".venv");
676        assert!(!Venv.detect(dir.path()));
677    }
678
679    #[test]
680    fn test_detect_negative_no_env() {
681        let dir = tempdir().unwrap();
682        File::create(dir.path().join("requirements.txt")).unwrap();
683        // A plain directory without pyvenv.cfg — not a venv
684        fs::create_dir(dir.path().join("not_a_venv")).unwrap();
685        assert!(!Venv.detect(dir.path()));
686    }
687
688    #[test]
689    fn test_detect_negative_uv_lock() {
690        let dir = tempdir().unwrap();
691        File::create(dir.path().join("requirements.txt")).unwrap();
692        File::create(dir.path().join("uv.lock")).unwrap();
693        make_venv(dir.path(), ".venv");
694        assert!(!Venv.detect(dir.path()));
695    }
696
697    #[test]
698    fn test_bloat_dirs_present() {
699        let dir = tempdir().unwrap();
700        make_venv(dir.path(), ".venv");
701        make_venv(dir.path(), "my_env");
702        let dirs = Venv.bloat_dirs(dir.path());
703        assert_eq!(dirs.len(), 2);
704        let names: Vec<&str> = dirs.iter().map(|d| d.name.as_str()).collect();
705        assert!(names.contains(&".venv"));
706        assert!(names.contains(&"my_env"));
707    }
708
709    #[test]
710    fn test_bloat_dirs_absent() {
711        let dir = tempdir().unwrap();
712        let dirs = Venv.bloat_dirs(dir.path());
713        assert!(dirs.is_empty());
714    }
715
716    #[test]
717    fn test_bloat_dirs_ignores_non_venv_dirs() {
718        let dir = tempdir().unwrap();
719        // A dir without pyvenv.cfg should NOT be returned
720        fs::create_dir(dir.path().join("src")).unwrap();
721        make_venv(dir.path(), ".venv");
722        let dirs = Venv.bloat_dirs(dir.path());
723        assert_eq!(dirs.len(), 1);
724        assert_eq!(dirs[0].name, ".venv");
725    }
726
727    /// A `<name>-<version>.dist-info` under the venv's `site-packages`, the same
728    /// metadata pip writes. The `Lib/` spelling is Windows' layout, which
729    /// `installed_distributions` reads on every OS — so the tests can build it anywhere.
730    fn install_package(root: &Path, venv: &str, name: &str, requires: &[&str]) {
731        let dist_info = root
732            .join(venv)
733            .join("Lib")
734            .join("site-packages")
735            .join(format!("{name}-1.0.0.dist-info"));
736        fs::create_dir_all(&dist_info).unwrap();
737        let mut metadata = format!("Metadata-Version: 2.1\nName: {name}\nVersion: 1.0.0\n");
738        for dep in requires {
739            metadata.push_str(&format!("Requires-Dist: {dep}\n"));
740        }
741        fs::write(dist_info.join("METADATA"), metadata).unwrap();
742    }
743
744    #[test]
745    fn enforce_refuses_when_requirements_lists_nothing() {
746        let dir = tempdir().unwrap();
747        fs::write(dir.path().join("requirements.txt"), "# nothing pinned\n\n").unwrap();
748        make_venv(dir.path(), ".venv");
749
750        let err = Venv
751            .enforce_lockfile(dir.path(), EnforcePolicy::default())
752            .unwrap_err();
753        assert!(err.to_string().contains("lists no packages"));
754    }
755
756    #[test]
757    fn enforce_refuses_a_package_the_requirements_never_recorded() {
758        // `pip install requests` that nobody wrote back: recoverable from nowhere,
759        // so deleting the environment must be refused, naming the package.
760        let dir = tempdir().unwrap();
761        fs::write(dir.path().join("requirements.txt"), "flask==3.0.0\n").unwrap();
762        make_venv(dir.path(), ".venv");
763        install_package(dir.path(), ".venv", "flask", &[]);
764        install_package(dir.path(), ".venv", "requests", &[]);
765
766        let err = Venv
767            .enforce_lockfile(dir.path(), EnforcePolicy::default())
768            .unwrap_err()
769            .to_string();
770        assert!(
771            err.contains("requests"),
772            "names the unrecorded package: {err}"
773        );
774        assert!(
775            !err.contains("flask"),
776            "must not blame the pinned one: {err}"
777        );
778        assert!(err.contains("pip freeze"), "says how to record it: {err}");
779    }
780
781    #[test]
782    fn enforce_accepts_transitive_dependencies_of_pinned_packages() {
783        // requirements.txt pins direct dependencies only; the environment legitimately
784        // holds their whole closure. Reachable packages are not drift.
785        let dir = tempdir().unwrap();
786        fs::write(dir.path().join("requirements.txt"), "requests==2.32.3\n").unwrap();
787        make_venv(dir.path(), ".venv");
788        install_package(
789            dir.path(),
790            ".venv",
791            "requests",
792            &["urllib3 (>=1.21.1)", "charset-normalizer"],
793        );
794        install_package(dir.path(), ".venv", "urllib3", &[]);
795        // Installed under the `_` spelling; PEP 503 normalization must still match.
796        install_package(dir.path(), ".venv", "charset_normalizer", &[]);
797
798        assert!(
799            Venv.enforce_lockfile(dir.path(), EnforcePolicy::default())
800                .is_ok()
801        );
802    }
803
804    #[test]
805    fn enforce_skips_the_comparison_when_requirements_cannot_be_parsed() {
806        // An editable install's name only pip can compute. Guessing in either
807        // direction is wrong, so an unparseable file skips the drift check.
808        let dir = tempdir().unwrap();
809        fs::write(
810            dir.path().join("requirements.txt"),
811            "-e ./local-package\nflask==3.0.0\n",
812        )
813        .unwrap();
814        make_venv(dir.path(), ".venv");
815        install_package(dir.path(), ".venv", "left-behind", &[]);
816
817        assert!(
818            Venv.enforce_lockfile(dir.path(), EnforcePolicy::default())
819                .is_ok()
820        );
821    }
822
823    #[test]
824    fn drift_names_the_venv_and_the_unrecorded_packages() {
825        let dir = tempdir().unwrap();
826        fs::write(dir.path().join("requirements.txt"), "requests==2.32.3\n").unwrap();
827        make_venv(dir.path(), ".venv");
828        install_package(dir.path(), ".venv", "requests", &[]);
829        install_package(dir.path(), ".venv", "sneaky-pkg", &[]);
830
831        let reports = Venv.drift(dir.path());
832        assert_eq!(reports.len(), 1);
833        assert_eq!(reports[0].directory, ".venv");
834        assert_eq!(reports[0].unrecorded, vec!["sneaky-pkg"]);
835        assert_eq!(reports[0].record_command, "pip freeze > requirements.txt");
836    }
837
838    /// A dependency of a pinned package is reachable from the requirements file, so it
839    /// is recorded in the only sense that matters: `pip install -r` brings it back.
840    #[test]
841    fn drift_does_not_flag_transitive_dependencies_of_pinned_packages() {
842        let dir = tempdir().unwrap();
843        fs::write(dir.path().join("requirements.txt"), "requests==2.32.3\n").unwrap();
844        make_venv(dir.path(), ".venv");
845        install_package(dir.path(), ".venv", "requests", &["urllib3"]);
846        install_package(dir.path(), ".venv", "urllib3", &[]);
847
848        assert!(Venv.drift(dir.path()).is_empty());
849    }
850
851    #[test]
852    fn enforce_names_dev_prune_as_the_situation_it_actually_is() {
853        // The generic message would send somebody to record a tool in their
854        // application's requirements file, which is the wrong repair for the accident
855        // that produced it. The refusal is unchanged; only the advice is.
856        let dir = tempdir().unwrap();
857        fs::write(dir.path().join("requirements.txt"), "flask==3.0.0\n").unwrap();
858        make_venv(dir.path(), ".venv");
859        install_package(dir.path(), ".venv", "flask", &[]);
860        // pip escapes the name into the dist-info directory, so this is what is on disk.
861        install_package(dir.path(), ".venv", "dev_prune", &[]);
862
863        let err = Venv
864            .enforce_lockfile(dir.path(), EnforcePolicy::default())
865            .unwrap_err()
866            .to_string();
867        assert!(
868            err.contains("pip uninstall dev-prune"),
869            "offers the removal: {err}"
870        );
871        assert!(
872            err.contains("uv tool install dev-prune"),
873            "offers the tool install: {err}"
874        );
875        assert!(err.contains("pip freeze"), "offers recording it: {err}");
876        assert!(
877            err.contains("Nothing was deleted"),
878            "is still a refusal: {err}"
879        );
880    }
881
882    #[test]
883    fn enforce_accepts_dev_prune_when_requirements_records_it() {
884        // The odd but legitimate case: a project that really does depend on the tool,
885        // in its own requirements file, on purpose. Recorded is recorded — there is
886        // nothing special about this package once somebody has written it down.
887        let dir = tempdir().unwrap();
888        fs::write(dir.path().join("requirements.txt"), "dev-prune==1.7.0\n").unwrap();
889        make_venv(dir.path(), ".venv");
890        install_package(dir.path(), ".venv", "dev_prune", &[]);
891
892        assert!(
893            Venv.enforce_lockfile(dir.path(), EnforcePolicy::default())
894                .is_ok()
895        );
896    }
897
898    #[test]
899    fn enforce_keeps_the_generic_message_when_dev_prune_is_not_alone() {
900        // Naming one of two strays and saying how to fix only that one would leave the
901        // user re-running the pass to be refused a second time for a package the first
902        // message never mentioned.
903        let dir = tempdir().unwrap();
904        fs::write(dir.path().join("requirements.txt"), "flask==3.0.0\n").unwrap();
905        make_venv(dir.path(), ".venv");
906        install_package(dir.path(), ".venv", "flask", &[]);
907        install_package(dir.path(), ".venv", "dev_prune", &[]);
908        install_package(dir.path(), ".venv", "requests", &[]);
909
910        let err = Venv
911            .enforce_lockfile(dir.path(), EnforcePolicy::default())
912            .unwrap_err()
913            .to_string();
914        assert!(err.contains("requests"), "names the other stray: {err}");
915        assert!(err.contains("2 package(s)"), "counts both: {err}");
916    }
917
918    #[test]
919    fn is_dev_prune_accepts_every_spelling_pip_may_write() {
920        assert!(is_dev_prune("dev-prune"));
921        assert!(is_dev_prune("dev_prune"));
922        assert!(is_dev_prune("Dev-Prune"));
923        assert!(!is_dev_prune("dev-pruner"));
924        assert!(!is_dev_prune("prune"));
925    }
926}