dev-prune 1.3.1

Universal, lockfile-safe workspace pruner and background dependency cleaner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
// Copyright 2026 VKrishna04
// SPDX-License-Identifier: Apache-2.0

// Standard Python venv package manager adapter.
//
// Detects Python virtual environments by scanning the repo root for any
// directory containing a `pyvenv.cfg` file — the canonical marker for any
// Python virtual environment, regardless of what the folder is named
// (`.venv`, `venv`, `env`, `my_env`, `.env`, etc.).
//
// Priority: the `uv` adapter takes precedence when `uv.lock` is present.

use super::{BloatDir, EnforcePolicy, PackageManager, dir_size, run_command_with_timeout};
use anyhow::{Result, anyhow};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};

/// The canonical file inside every Python virtual environment.
const PYVENV_CFG: &str = "pyvenv.cfg";

/// Lockfiles owned by other Python managers.
///
/// A poetry, pipenv or pdm project often carries an exported `requirements.txt` as well —
/// usually stale. Rebuilding the environment from that export instead of the real
/// lockfile would silently install the wrong versions, so those projects are never
/// claimed here: poetry has its own adapter, and pipenv/pdm are left to their own tools.
const FOREIGN_PYTHON_LOCKFILES: [&str; 3] = ["poetry.lock", "Pipfile.lock", "pdm.lock"];

/// Distributions present in effectively every virtual environment without ever being
/// listed in a requirements file.
pub(super) const BASELINE_DISTRIBUTIONS: [&str; 4] =
    ["pip", "setuptools", "wheel", "pkg-resources"];

/// Adapter for standard Python venv projects.
pub struct Venv;

/// Scan the repo root for directories containing `pyvenv.cfg`.
///
/// This catches any venv folder name: `.venv`, `venv`, `env`, `my_env`, etc.
fn find_venv_dirs(path: &Path) -> Vec<std::path::PathBuf> {
    let mut found = Vec::new();

    let Ok(entries) = fs::read_dir(path) else {
        return found;
    };

    for entry in entries.flatten() {
        let entry_path = entry.path();
        if entry_path.is_dir() && entry_path.join(PYVENV_CFG).exists() {
            found.push(entry_path);
        }
    }

    found
}

/// Whether `pyproject.toml` declares a `[tool.poetry]` table.
///
/// A textual check rather than a TOML parse: the table header only ever appears at the
/// start of a line, and this adapter needs a yes/no, not the table's contents.
fn is_poetry_project(path: &Path) -> bool {
    fs::read_to_string(path.join("pyproject.toml"))
        .map(|c| {
            c.lines()
                .any(|l| l.trim_start().starts_with("[tool.poetry"))
        })
        .unwrap_or(false)
}

/// A package name as PEP 503 compares them: lowercased, with runs of `-`, `_` and `.`
/// collapsed to a single `-`, so `Foo_Bar` and `foo-bar` are the same package.
pub(super) fn normalize_package_name(name: &str) -> String {
    let mut out = String::with_capacity(name.len());
    let mut last_dash = false;
    for c in name.chars() {
        if c == '-' || c == '_' || c == '.' {
            if !last_dash {
                out.push('-');
            }
            last_dash = true;
        } else {
            out.push(c.to_ascii_lowercase());
            last_dash = false;
        }
    }
    out
}

/// The leading package name of a PEP 508 requirement string, normalised.
///
/// `requests[socks]==2.32.3 ; python_version < "3.9"` → `requests`. Returns `None` for
/// anything that does not begin with a name — URLs, local paths — because pip is the
/// only thing that can know what those install.
fn requirement_name(spec: &str) -> Option<String> {
    let name: String = spec
        .chars()
        .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
        .collect();
    // A name never starts with `.` — that spelling is a relative path (`./pkg`, `..`).
    if name.is_empty() || name.starts_with('.') || spec.contains("://") && !spec.contains(" @ ") {
        return None;
    }
    Some(normalize_package_name(&name))
}

/// Every package name a requirements file pins, following `-r`/`-c` includes.
///
/// `None` means the file cannot be fully accounted for without running pip — an editable
/// install, a bare URL or path, or an include that cannot be read. The caller skips the
/// drift comparison in that case rather than guessing in either direction.
fn requirement_names(file: &Path, visited: &mut Vec<PathBuf>) -> Option<HashSet<String>> {
    // The depth cap breaks include cycles that the exact-path check misses (e.g. the
    // same file reached through differently-spelled relative paths).
    if visited.len() >= 8 || visited.iter().any(|p| p == file) {
        return None;
    }
    visited.push(file.to_path_buf());

    let content = fs::read_to_string(file).ok()?;
    let dir = file.parent()?;
    let mut names = HashSet::new();

    for raw in content.lines() {
        // pip treats `#` as a comment at line start or after whitespace — never inside
        // a URL fragment like `#egg=name`.
        let mut line = raw.trim();
        if let Some(idx) = line.find(" #") {
            line = &line[..idx];
        }
        let line = line.trim_end_matches('\\').trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        if let Some(included) = line
            .strip_prefix("-r ")
            .or_else(|| line.strip_prefix("--requirement "))
            .or_else(|| line.strip_prefix("-c "))
            .or_else(|| line.strip_prefix("--constraint "))
        {
            names.extend(requirement_names(&dir.join(included.trim()), visited)?);
            continue;
        }

        if line.starts_with('-') {
            // An editable install's package name only pip can compute. Every other
            // option (`--index-url`, `--hash`, …) names no package at all.
            if line.starts_with("-e") || line.starts_with("--editable") {
                return None;
            }
            continue;
        }

        // `name @ url` is a direct reference whose name is on the left of the `@`.
        let spec = line.split(" @ ").next().unwrap_or(line).trim();
        names.insert(requirement_name(spec)?);
    }

    Some(names)
}

/// Installed distributions and their declared dependencies, read from the
/// `*.dist-info` directories of a virtual environment's `site-packages`.
///
/// The map's keys are the installed package names; the values are the names in each
/// package's `Requires-Dist` metadata. `None` when no `site-packages` directory could
/// be found at all — an exotic layout is not evidence of anything.
pub(super) fn installed_distributions(venv: &Path) -> Option<HashMap<String, Vec<String>>> {
    let mut site_packages: Vec<PathBuf> = Vec::new();
    let windows_layout = venv.join("Lib").join("site-packages");
    if windows_layout.is_dir() {
        site_packages.push(windows_layout);
    }
    // POSIX layout: `lib/python3.X/site-packages`. `lib64` is usually a symlink to
    // `lib`; the HashMap deduplicates whatever both spellings yield.
    for lib in ["lib", "lib64"] {
        let Ok(entries) = fs::read_dir(venv.join(lib)) else {
            continue;
        };
        for entry in entries.flatten() {
            let sp = entry.path().join("site-packages");
            if sp.is_dir() {
                site_packages.push(sp);
            }
        }
    }
    if site_packages.is_empty() {
        return None;
    }

    let mut installed = HashMap::new();
    for sp in site_packages {
        let Ok(entries) = fs::read_dir(&sp) else {
            continue;
        };
        for entry in entries.flatten() {
            let file_name = entry.file_name().to_string_lossy().into_owned();
            let Some(stem) = file_name
                .strip_suffix(".dist-info")
                .or_else(|| file_name.strip_suffix(".egg-info"))
            else {
                continue;
            };
            // `{escaped_name}-{version}`: the escaping turns `-` into `_`, so the
            // name part never contains a hyphen — but setuptools may append `-pyX.Y`
            // to an egg-info (which a last-hyphen split read as part of the name),
            // and a legacy editable install writes a bare `{name}.egg-info` with no
            // version at all. Versions always start with a digit, so the name is
            // everything before the first `-<digit>`.
            let name = stem
                .match_indices('-')
                .find(|(i, _)| {
                    stem[i + 1..]
                        .chars()
                        .next()
                        .is_some_and(|c| c.is_ascii_digit())
                })
                .map(|(i, _)| &stem[..i])
                .unwrap_or(stem);
            installed.insert(
                normalize_package_name(name),
                declared_dependencies(&entry.path()),
            );
        }
    }
    Some(installed)
}

/// The package names in a dist-info directory's `Requires-Dist` metadata lines.
///
/// Extras-gated dependencies are included: if one is installed it is reachable from its
/// parent, and this graph exists to prove reachability, not to plan an install.
fn declared_dependencies(dist_info: &Path) -> Vec<String> {
    let Ok(metadata) = fs::read_to_string(dist_info.join("METADATA")) else {
        return Vec::new();
    };
    let mut deps = Vec::new();
    for line in metadata.lines() {
        // Headers end at the first blank line; the body is a README that could
        // contain anything, including text that looks like a header.
        if line.is_empty() {
            break;
        }
        if let Some(spec) = line.strip_prefix("Requires-Dist:")
            && let Some(name) = requirement_name(spec.trim())
        {
            deps.push(name);
        }
    }
    deps
}

/// The `major.minor` of the Python a venv was built with, from its `pyvenv.cfg`.
fn venv_python_version(venv: &Path) -> Option<(u64, u64)> {
    let cfg = fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
    for line in cfg.lines() {
        let Some((key, value)) = line.split_once('=') else {
            continue;
        };
        if matches!(key.trim(), "version" | "version_info") {
            let mut parts = value.trim().split('.');
            return Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?));
        }
    }
    None
}

/// The `major.minor` of whatever `python` is on PATH — the interpreter a restore would
/// rebuild with. `None` when there is none or it cannot say.
fn path_python_version() -> Option<(u64, u64)> {
    let output = crate::spawn::command(super::resolve_program("python"))
        .arg("--version")
        .stdin(std::process::Stdio::null())
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    // Python 2 printed the version on stderr; 3.4+ prints it on stdout.
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let text = if stdout.is_empty() {
        String::from_utf8_lossy(&output.stderr).trim().to_string()
    } else {
        stdout
    };
    let version = text.split_whitespace().nth(1)?;
    let mut parts = version.split('.');
    Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
}

/// Say out loud, once per pass, anything that would make a restore rebuild something
/// other than what was deleted. Warnings, not refusals: every one of these environments
/// is still rebuildable, just not byte-for-byte.
fn warn_about_restore_surprises(path: &Path, venvs: &[PathBuf]) {
    if venvs.len() > 1 {
        crate::output::print_warning(&format!(
            "`{}` has {} virtual environments, all rebuilt from one requirements.txt. \
             Each restores under its own recorded name; a plain `devp restore` with no \
             record rebuilds only `.venv`.",
            path.display(),
            venvs.len()
        ));
    } else if let Some(venv) = venvs.first() {
        let name = venv.file_name().map(|n| n.to_string_lossy().into_owned());
        if let Some(name) = name
            && name != ".venv"
        {
            crate::output::print_info(&format!(
                "The environment at `{}` is named `{name}` — `devp restore --last-run` \
                     recreates that name, but a restore with no record creates `.venv`.",
                venv.display()
            ));
        }
    }

    let on_path = path_python_version();
    for venv in venvs {
        if let (Some(built_with), Some(available)) = (venv_python_version(venv), on_path)
            && built_with != available
        {
            crate::output::print_warning(&format!(
                "`{}` was built with Python {}.{}, but `python` on PATH is {}.{} — a \
                     restore would rebuild it on that interpreter instead, and pinned \
                     wheels may not exist for it.",
                venv.display(),
                built_with.0,
                built_with.1,
                available.0,
                available.1
            ));
        }
    }
}

/// Installed packages that nothing in the requirements file accounts for.
///
/// A hand-written requirements file pins direct dependencies only; the environment
/// legitimately holds their whole transitive closure. So the check walks the installed
/// dependency graph from every pinned name and flags only what is *unreachable* — a
/// `pip install` that was never written back, which `pip install -r` after deletion
/// would not bring back.
fn unrecorded_packages(
    installed: &HashMap<String, Vec<String>>,
    pinned: &HashSet<String>,
) -> Vec<String> {
    let mut reachable: HashSet<String> = HashSet::new();
    let mut queue: Vec<String> = pinned.iter().cloned().collect();
    queue.extend(BASELINE_DISTRIBUTIONS.iter().map(|s| (*s).to_string()));

    while let Some(name) = queue.pop() {
        if !reachable.insert(name.clone()) {
            continue;
        }
        if let Some(deps) = installed.get(&name) {
            queue.extend(deps.iter().cloned());
        }
    }

    let mut extras: Vec<String> = installed
        .keys()
        .filter(|name| !reachable.contains(*name))
        .cloned()
        .collect();
    extras.sort();
    extras
}

impl PackageManager for Venv {
    fn name(&self) -> &'static str {
        "venv"
    }

    /// Detect a plain-venv project:
    /// - `requirements.txt` must exist (otherwise it's probably not a managed venv project)
    /// - At least one directory with `pyvenv.cfg` must exist in the repo root
    /// - `uv.lock` must NOT exist (uv adapter takes priority)
    ///
    /// uv's precedence is also enforced centrally in `adapters::detect_adapters`, which
    /// covers uv projects declared only through `[tool.uv]` in `pyproject.toml`.
    fn detect(&self, path: &Path) -> bool {
        let req_txt = path.join("requirements.txt");
        let uv_lock = path.join("uv.lock");

        if !req_txt.exists() || uv_lock.exists() {
            return false;
        }

        // A poetry/pipenv/pdm project belongs to its own tool. Its requirements.txt is
        // usually an export of the real lockfile — often stale — and rebuilding from it
        // would quietly produce a different environment than the one deleted.
        if FOREIGN_PYTHON_LOCKFILES
            .iter()
            .any(|f| path.join(f).exists())
            || is_poetry_project(path)
        {
            return false;
        }

        !find_venv_dirs(path).is_empty()
    }

    /// Return all venv directories (any folder containing `pyvenv.cfg`) as bloat dirs.
    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
        find_venv_dirs(path)
            .into_iter()
            .map(|venv_path| {
                let name = venv_path
                    .file_name()
                    .map(|n| n.to_string_lossy().to_string())
                    .unwrap_or_else(|| venv_path.display().to_string());
                let size = dir_size(&venv_path);
                BloatDir {
                    name,
                    path: venv_path,
                    size_bytes: size,
                    shared_bytes: 0,
                }
            })
            .collect()
    }

    /// Pure inspection: reads `requirements.txt` and runs nothing, so neither half of
    /// [`EnforcePolicy`] applies.
    fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
        let req_txt = path.join("requirements.txt");
        if !req_txt.exists() {
            return Err(anyhow!("requirements.txt missing"));
        }
        // An empty requirements.txt cannot rebuild the environment, so deleting the
        // venv against it would be unrecoverable rather than merely inconvenient.
        let has_requirements = fs::read_to_string(&req_txt)
            .map(|c| {
                c.lines()
                    .any(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
            })
            .unwrap_or(false);
        if !has_requirements {
            return Err(anyhow!(
                "requirements.txt at `{}` lists no packages — the virtual environment \
                 could not be rebuilt after deletion. Populate it with `pip freeze > requirements.txt`.",
                req_txt.display()
            ));
        }

        let venvs = find_venv_dirs(path);
        warn_about_restore_surprises(path, &venvs);

        // The environment can hold packages the requirements file never recorded — a
        // `pip install foo` nobody wrote back. Those are recoverable from nowhere, which
        // is exactly what this tool promises never to delete. A file that cannot be
        // fully parsed (editable installs, URLs, unreadable includes) skips the
        // comparison rather than guessing in either direction.
        if let Some(pinned) = requirement_names(&req_txt, &mut Vec::new()) {
            for venv in venvs {
                let Some(installed) = installed_distributions(&venv) else {
                    continue;
                };
                let extras = unrecorded_packages(&installed, &pinned);
                if extras.is_empty() {
                    continue;
                }
                let shown = extras
                    .iter()
                    .take(10)
                    .cloned()
                    .collect::<Vec<_>>()
                    .join(", ");
                let suffix = if extras.len() > 10 {
                    format!(", … and {} more", extras.len() - 10)
                } else {
                    String::new()
                };
                return Err(anyhow!(
                    "`{}` holds {} package(s) that requirements.txt does not account for \
                     ({shown}{suffix}). Deleting the environment would lose them with no \
                     way back. Record them first: `pip freeze > requirements.txt`.",
                    venv.display(),
                    extras.len()
                ));
            }
        }
        Ok(())
    }

    /// Recreate the environment in `.venv` — the name used when nothing recorded the
    /// original one. `devp restore --last-run` knows better and calls
    /// [`PackageManager::restore_named`] with the folder name the prune deleted.
    fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
        self.restore_named(path, ".venv", timeout)
    }

    /// Recreate the environment under the folder name it had before the prune, so
    /// activate scripts and IDE interpreter paths keep pointing at something real.
    fn restore_named(
        &self,
        path: &Path,
        dir_name: &str,
        timeout: std::time::Duration,
    ) -> Result<()> {
        // The recorded name comes from the registry file; a mangled entry must not be
        // able to turn `python -m venv <name>` into a write outside the project.
        let dir_name = if dir_name.is_empty()
            || dir_name == "."
            || dir_name == ".."
            || dir_name.contains(['/', '\\'])
        {
            ".venv"
        } else {
            dir_name
        };
        run_command_with_timeout("python", &["-m", "venv", dir_name], path, timeout)?;
        // Absolute, because a relative program path is resolved against the parent
        // process's working directory, not the `current_dir` handed to the child.
        #[cfg(windows)]
        let python = path.join(dir_name).join("Scripts").join("python.exe");
        #[cfg(not(windows))]
        let python = path.join(dir_name).join("bin").join("python");
        run_command_with_timeout(
            &python.to_string_lossy(),
            &["-m", "pip", "install", "-r", "requirements.txt"],
            path,
            timeout,
        )
    }

    /// Not a lockfile in the strict sense — `requirements.txt` pins whatever its author
    /// pinned — but it is the file this adapter verifies and rebuilds from, which is what
    /// the caller wants to be told about.
    fn lockfiles(&self) -> &'static [&'static str] {
        &["requirements.txt"]
    }

    /// The comparison `enforce_lockfile` refuses on, as data: per venv, the installed
    /// distributions unreachable from anything `requirements.txt` pins.
    fn drift(&self, path: &Path) -> Vec<super::DriftReport> {
        let Some(pinned) = requirement_names(&path.join("requirements.txt"), &mut Vec::new())
        else {
            return Vec::new();
        };
        let mut reports = Vec::new();
        for venv in find_venv_dirs(path) {
            let Some(installed) = installed_distributions(&venv) else {
                continue;
            };
            let extras = unrecorded_packages(&installed, &pinned);
            if extras.is_empty() {
                continue;
            }
            reports.push(super::DriftReport {
                directory: venv
                    .file_name()
                    .map(|n| n.to_string_lossy().into_owned())
                    .unwrap_or_else(|| venv.display().to_string()),
                unrecorded: extras,
                record_command: "pip freeze > requirements.txt",
            });
        }
        reports
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs::{self, File};
    use tempfile::tempdir;

    fn make_venv(dir: &Path, name: &str) {
        let venv = dir.join(name);
        fs::create_dir(&venv).unwrap();
        File::create(venv.join(PYVENV_CFG)).unwrap();
    }

    #[test]
    fn test_name() {
        assert_eq!(Venv.name(), "venv");
    }

    #[test]
    fn test_detect_positive_dot_venv() {
        let dir = tempdir().unwrap();
        File::create(dir.path().join("requirements.txt")).unwrap();
        make_venv(dir.path(), ".venv");
        assert!(Venv.detect(dir.path()));
    }

    #[test]
    fn test_detect_positive_venv() {
        let dir = tempdir().unwrap();
        File::create(dir.path().join("requirements.txt")).unwrap();
        make_venv(dir.path(), "venv");
        assert!(Venv.detect(dir.path()));
    }

    #[test]
    fn test_detect_positive_custom_name() {
        let dir = tempdir().unwrap();
        File::create(dir.path().join("requirements.txt")).unwrap();
        make_venv(dir.path(), "my_env");
        assert!(Venv.detect(dir.path()));
    }

    #[test]
    fn test_detect_positive_env() {
        let dir = tempdir().unwrap();
        File::create(dir.path().join("requirements.txt")).unwrap();
        make_venv(dir.path(), "env");
        assert!(Venv.detect(dir.path()));
    }

    #[test]
    fn test_detect_negative_no_req() {
        let dir = tempdir().unwrap();
        make_venv(dir.path(), ".venv");
        assert!(!Venv.detect(dir.path()));
    }

    #[test]
    fn test_detect_negative_no_env() {
        let dir = tempdir().unwrap();
        File::create(dir.path().join("requirements.txt")).unwrap();
        // A plain directory without pyvenv.cfg — not a venv
        fs::create_dir(dir.path().join("not_a_venv")).unwrap();
        assert!(!Venv.detect(dir.path()));
    }

    #[test]
    fn test_detect_negative_uv_lock() {
        let dir = tempdir().unwrap();
        File::create(dir.path().join("requirements.txt")).unwrap();
        File::create(dir.path().join("uv.lock")).unwrap();
        make_venv(dir.path(), ".venv");
        assert!(!Venv.detect(dir.path()));
    }

    #[test]
    fn test_bloat_dirs_present() {
        let dir = tempdir().unwrap();
        make_venv(dir.path(), ".venv");
        make_venv(dir.path(), "my_env");
        let dirs = Venv.bloat_dirs(dir.path());
        assert_eq!(dirs.len(), 2);
        let names: Vec<&str> = dirs.iter().map(|d| d.name.as_str()).collect();
        assert!(names.contains(&".venv"));
        assert!(names.contains(&"my_env"));
    }

    #[test]
    fn test_bloat_dirs_absent() {
        let dir = tempdir().unwrap();
        let dirs = Venv.bloat_dirs(dir.path());
        assert!(dirs.is_empty());
    }

    #[test]
    fn test_bloat_dirs_ignores_non_venv_dirs() {
        let dir = tempdir().unwrap();
        // A dir without pyvenv.cfg should NOT be returned
        fs::create_dir(dir.path().join("src")).unwrap();
        make_venv(dir.path(), ".venv");
        let dirs = Venv.bloat_dirs(dir.path());
        assert_eq!(dirs.len(), 1);
        assert_eq!(dirs[0].name, ".venv");
    }

    /// A `<name>-<version>.dist-info` under the venv's `site-packages`, the same
    /// metadata pip writes. The `Lib/` spelling is Windows' layout, which
    /// `installed_distributions` reads on every OS — so the tests can build it anywhere.
    fn install_package(root: &Path, venv: &str, name: &str, requires: &[&str]) {
        let dist_info = root
            .join(venv)
            .join("Lib")
            .join("site-packages")
            .join(format!("{name}-1.0.0.dist-info"));
        fs::create_dir_all(&dist_info).unwrap();
        let mut metadata = format!("Metadata-Version: 2.1\nName: {name}\nVersion: 1.0.0\n");
        for dep in requires {
            metadata.push_str(&format!("Requires-Dist: {dep}\n"));
        }
        fs::write(dist_info.join("METADATA"), metadata).unwrap();
    }

    #[test]
    fn enforce_refuses_when_requirements_lists_nothing() {
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("requirements.txt"), "# nothing pinned\n\n").unwrap();
        make_venv(dir.path(), ".venv");

        let err = Venv
            .enforce_lockfile(dir.path(), EnforcePolicy::default())
            .unwrap_err();
        assert!(err.to_string().contains("lists no packages"));
    }

    #[test]
    fn enforce_refuses_a_package_the_requirements_never_recorded() {
        // `pip install requests` that nobody wrote back: recoverable from nowhere,
        // so deleting the environment must be refused, naming the package.
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("requirements.txt"), "flask==3.0.0\n").unwrap();
        make_venv(dir.path(), ".venv");
        install_package(dir.path(), ".venv", "flask", &[]);
        install_package(dir.path(), ".venv", "requests", &[]);

        let err = Venv
            .enforce_lockfile(dir.path(), EnforcePolicy::default())
            .unwrap_err()
            .to_string();
        assert!(
            err.contains("requests"),
            "names the unrecorded package: {err}"
        );
        assert!(
            !err.contains("flask"),
            "must not blame the pinned one: {err}"
        );
        assert!(err.contains("pip freeze"), "says how to record it: {err}");
    }

    #[test]
    fn enforce_accepts_transitive_dependencies_of_pinned_packages() {
        // requirements.txt pins direct dependencies only; the environment legitimately
        // holds their whole closure. Reachable packages are not drift.
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("requirements.txt"), "requests==2.32.3\n").unwrap();
        make_venv(dir.path(), ".venv");
        install_package(
            dir.path(),
            ".venv",
            "requests",
            &["urllib3 (>=1.21.1)", "charset-normalizer"],
        );
        install_package(dir.path(), ".venv", "urllib3", &[]);
        // Installed under the `_` spelling; PEP 503 normalization must still match.
        install_package(dir.path(), ".venv", "charset_normalizer", &[]);

        assert!(
            Venv.enforce_lockfile(dir.path(), EnforcePolicy::default())
                .is_ok()
        );
    }

    #[test]
    fn enforce_skips_the_comparison_when_requirements_cannot_be_parsed() {
        // An editable install's name only pip can compute. Guessing in either
        // direction is wrong, so an unparseable file skips the drift check.
        let dir = tempdir().unwrap();
        fs::write(
            dir.path().join("requirements.txt"),
            "-e ./local-package\nflask==3.0.0\n",
        )
        .unwrap();
        make_venv(dir.path(), ".venv");
        install_package(dir.path(), ".venv", "left-behind", &[]);

        assert!(
            Venv.enforce_lockfile(dir.path(), EnforcePolicy::default())
                .is_ok()
        );
    }

    #[test]
    fn drift_names_the_venv_and_the_unrecorded_packages() {
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("requirements.txt"), "requests==2.32.3\n").unwrap();
        make_venv(dir.path(), ".venv");
        install_package(dir.path(), ".venv", "requests", &[]);
        install_package(dir.path(), ".venv", "sneaky-pkg", &[]);

        let reports = Venv.drift(dir.path());
        assert_eq!(reports.len(), 1);
        assert_eq!(reports[0].directory, ".venv");
        assert_eq!(reports[0].unrecorded, vec!["sneaky-pkg"]);
        assert_eq!(reports[0].record_command, "pip freeze > requirements.txt");
    }

    /// A dependency of a pinned package is reachable from the requirements file, so it
    /// is recorded in the only sense that matters: `pip install -r` brings it back.
    #[test]
    fn drift_does_not_flag_transitive_dependencies_of_pinned_packages() {
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("requirements.txt"), "requests==2.32.3\n").unwrap();
        make_venv(dir.path(), ".venv");
        install_package(dir.path(), ".venv", "requests", &["urllib3"]);
        install_package(dir.path(), ".venv", "urllib3", &[]);

        assert!(Venv.drift(dir.path()).is_empty());
    }
}