Skip to main content

dev_prune/adapters/
mod.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Package manager adapter trait and registry.
5//
6// This module defines the [`PackageManager`] trait that all ecosystem adapters
7// must implement. It also provides helper functions for adapter detection and
8// directory size calculation.
9//
10// ## Adding a New Adapter
11//
12// 1. Create a new file in `src/adapters/` (e.g., `maven.rs`)
13// 2. Implement the [`PackageManager`] trait
14// 3. Register it in [`get_all_adapters()`]
15// 4. Add tests
16//
17// See [../../docs/ADDING_ADAPTERS.md] for a detailed guide.
18
19pub mod bun;
20pub mod bundler;
21pub mod cargo_adapter;
22pub mod cmake_build;
23pub mod cocoapods;
24pub mod composer;
25pub mod dart;
26pub mod go;
27pub mod gradle;
28pub mod maven;
29pub mod mix;
30pub mod mix_build;
31pub mod npm;
32pub mod pdm;
33pub mod pipenv;
34pub mod pnpm;
35pub mod poetry;
36pub mod swift;
37pub mod terraform;
38pub mod uv;
39pub mod vcpkg;
40pub mod venv;
41pub mod yarn;
42
43use std::collections::HashMap;
44use std::fmt;
45use std::path::{Path, PathBuf};
46use std::sync::{Mutex, OnceLock};
47
48use anyhow::{Context as _, Result};
49use walkdir::WalkDir;
50
51/// Information about a bloat directory that can be pruned.
52#[derive(Debug, Clone)]
53pub struct BloatDir {
54    /// Human-readable name (e.g., "node_modules").
55    pub name: String,
56    /// Full path to the bloat directory.
57    pub path: PathBuf,
58    /// Bytes that deleting this directory actually gives back to the disk.
59    pub size_bytes: u64,
60    /// Bytes reachable through hardlinks from outside this directory — pnpm's and
61    /// bun's store links. Deleting the directory does not free these; the store
62    /// keeps them. Zero for managers that copy instead of link.
63    pub shared_bytes: u64,
64}
65
66impl fmt::Display for BloatDir {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(f, "{} ({})", self.name, self.path.display())
69    }
70}
71
72/// Packages sitting in a manager's environment directory that its lockfile does not
73/// record — installs a post-prune restore would not bring back.
74#[derive(Debug, Clone)]
75pub struct DriftReport {
76    /// The environment directory that drifted (e.g. `.venv`, `node_modules`).
77    pub directory: String,
78    /// The unrecorded package names, sorted.
79    pub unrecorded: Vec<String>,
80    /// The command that writes them into the lockfile.
81    pub record_command: &'static str,
82}
83
84/// The core trait that every package manager adapter must implement.
85///
86/// Each adapter is responsible for:
87/// - **Detecting** whether it applies to a given project directory
88/// - **Listing** the bloat directories it manages
89/// - **Enforcing** lockfile consistency before deletion
90/// - **Restoring** dependencies from lockfiles
91pub trait PackageManager: Send + Sync {
92    /// Human-readable name for this adapter (e.g., "npm", "pnpm", "uv").
93    fn name(&self) -> &'static str;
94
95    /// Check if this adapter applies to the given project directory.
96    ///
97    /// Typically checks for the presence of a specific lockfile or config file.
98    fn detect(&self, project_path: &Path) -> bool;
99
100    /// List all bloat directories this adapter manages in the given project.
101    ///
102    /// Only returns directories that actually exist on disk.
103    fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
104
105    /// Prove the lockfile can rebuild what is about to be deleted.
106    ///
107    /// This is a **safety-critical** method. It MUST succeed before any bloat
108    /// directory is deleted. If this fails, deletion for this adapter is aborted.
109    ///
110    /// See [`EnforcePolicy`] for the one rule every adapter follows.
111    fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
112
113    /// Restore dependencies from the lockfile (for `dev-prune restore`).
114    ///
115    /// `timeout` is threaded explicitly for the same reason [`EnforcePolicy`] is: the
116    /// restore path used to burn the compiled-in default regardless of
117    /// `command_timeout_secs`, and a full `npm ci` on a large tree needs the raised
118    /// timeout far more often than a verify does.
119    fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
120
121    /// [`PackageManager::restore`], told the name the pruned directory had.
122    ///
123    /// Most managers have exactly one possible directory name and ignore this. venv does
124    /// not: it prunes any folder carrying a `pyvenv.cfg` — `venv`, `env`, `my_env` — and
125    /// without the recorded name it would rebuild the environment as `.venv`, leaving
126    /// every activate script, IDE interpreter path and Makefile pointing at nothing.
127    /// `runtime` is the interpreter tag recorded when the directory was deleted (see
128    /// [`crate::config::PrunedDir::runtime`]). `None` means nothing was recorded, or the
129    /// caller has decided this machine cannot honour it; either way the manager should
130    /// fall back to whatever it would have used before.
131    fn restore_named(
132        &self,
133        project_path: &Path,
134        dir_name: &str,
135        runtime: Option<&str>,
136        timeout: std::time::Duration,
137    ) -> Result<()> {
138        let _ = (dir_name, runtime);
139        self.restore(project_path, timeout)
140    }
141
142    /// The language runtime a bloat directory is built against, recorded at prune time.
143    ///
144    /// Only the Python managers answer this. A `node_modules` is rebuilt by the same
145    /// `npm ci` whichever Node is installed, and cargo and go pin their toolchains in
146    /// files that are already in the repository — but a virtual environment is a
147    /// *copy* of one specific interpreter, and rebuilding it on a different one silently
148    /// changes which wheels resolve.
149    ///
150    /// `dir_name` is the directory about to be deleted, relative to `project_path`.
151    fn runtime_tag(&self, project_path: &Path, dir_name: &str) -> Option<String> {
152        let _ = (project_path, dir_name);
153        None
154    }
155
156    /// The file this manager rebuilds its bloat directory from.
157    ///
158    /// Two callers. Conflict resolution breaks ties between managers that share a bloat
159    /// directory — npm, pnpm, yarn and bun all own the same `node_modules` — by comparing
160    /// these files' timestamps. `devp doctor` names them, because a missing one is the
161    /// most common reason a project is not pruneable.
162    ///
163    /// More than one entry means the manager accepts any of them (bun's binary and text
164    /// lockfiles). An empty slice means the manager has no single file to point at.
165    fn lockfiles(&self) -> &'static [&'static str] {
166        &[]
167    }
168
169    /// Installed-but-unrecorded packages, as data instead of a refusal.
170    ///
171    /// The same comparison [`PackageManager::enforce_lockfile`] refuses a prune on,
172    /// surfaced early so `devp status --drift` can point at the problem before a prune
173    /// is ever attempted. Runs nothing and writes nothing. An empty answer means
174    /// "nothing detected", not "proven clean" — most managers have no cheap way to
175    /// compare and say nothing here.
176    fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
177        let _ = project_path;
178        Vec::new()
179    }
180
181    /// Whether this adapter is inert until the user enables it in settings.
182    ///
183    /// Adapters whose directory is compiler output answer `true` — cargo, gradle,
184    /// maven, swift, dart, mix_build, vcpkg and cmake_build. Theirs come back by
185    /// recompiling the project, which costs far
186    /// more than a dependency reinstall, so nobody should find them deleted without
187    /// having asked. The engine also holds them to the longer `build_idle_days` idle
188    /// window.
189    ///
190    /// The test is what it costs to get the directory back, not whether a lockfile
191    /// exists: cargo has as good a lockfile as npm does, and `target/` still has to be
192    /// rebuilt from source.
193    fn opt_in(&self) -> bool {
194        false
195    }
196}
197
198/// Adapters that all manage `node_modules` and therefore cannot coexist.
199const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
200
201/// Bookkeeping files that each JavaScript manager writes into `node_modules` when it
202/// installs. Finding one identifies the manager that actually produced the tree on
203/// disk, which is stronger evidence than a lockfile's timestamp.
204///
205/// pnpm and yarn are checked before npm: a project migrated away from npm can still
206/// carry npm's `.package-lock.json` inside a tree the new manager rebuilt around it.
207/// Bun has no marker we rely on, so a bun conflict falls through to the later rules.
208const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
209    ("pnpm", &[".pnpm", ".modules.yaml"]),
210    ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
211    ("npm", &[".package-lock.json"]),
212];
213
214/// Returns all registered package manager adapters.
215///
216/// To add a new adapter, create your struct and add it to this list.
217pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
218    vec![
219        Box::new(npm::Npm),
220        Box::new(pnpm::Pnpm),
221        Box::new(yarn::Yarn),
222        Box::new(bun::Bun),
223        Box::new(uv::Uv),
224        Box::new(poetry::Poetry),
225        Box::new(pdm::Pdm),
226        Box::new(pipenv::Pipenv),
227        Box::new(venv::Venv),
228        Box::new(cargo_adapter::Cargo),
229        Box::new(go::Go),
230        Box::new(composer::Composer),
231        Box::new(bundler::Bundler),
232        Box::new(cocoapods::CocoaPods),
233        Box::new(mix::Mix),
234        Box::new(mix_build::MixBuild),
235        Box::new(gradle::Gradle),
236        Box::new(maven::Maven),
237        Box::new(swift::Swift),
238        Box::new(terraform::Terraform),
239        Box::new(dart::Dart),
240        Box::new(vcpkg::Vcpkg),
241        Box::new(cmake_build::CmakeBuild),
242    ]
243}
244
245/// The names of the opt-in adapters the user has switched on, resolved once per
246/// process from the registry settings.
247///
248/// Resolved here rather than threaded through every caller because `detect_adapters`
249/// is the single funnel every command discovers projects through — gating detection
250/// makes a disabled adapter invisible everywhere at once (status, stats, run, doctor),
251/// instead of visible in one view and inert in another.
252fn opt_in_enabled() -> &'static [String] {
253    static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
254    ENABLED.get_or_init(|| {
255        crate::config::Registry::load()
256            .map(|r| {
257                let mut names = Vec::new();
258                if r.settings.enable_cargo {
259                    names.push("cargo".to_string());
260                }
261                if r.settings.enable_gradle {
262                    names.push("gradle".to_string());
263                }
264                if r.settings.enable_maven {
265                    names.push("maven".to_string());
266                }
267                if r.settings.enable_swift {
268                    names.push("swift".to_string());
269                }
270                if r.settings.enable_dart {
271                    names.push("dart".to_string());
272                }
273                if r.settings.enable_mix_build {
274                    names.push("mix_build".to_string());
275                }
276                if r.settings.enable_vcpkg {
277                    names.push("vcpkg".to_string());
278                }
279                if r.settings.enable_cmake_build {
280                    names.push("cmake_build".to_string());
281                }
282                names
283            })
284            .unwrap_or_default()
285    })
286}
287
288/// The adapters switched off by name in `disabled_adapters`, resolved once per process.
289///
290/// The mirror image of [`opt_in_enabled`], and read at the same single funnel for the
291/// same reason: an adapter someone has turned off should not appear in `status`, be
292/// counted by `stats`, or be probed for by `doctor` — "off" that still shows up
293/// everywhere is not off.
294fn user_disabled() -> &'static [String] {
295    static DISABLED: OnceLock<Vec<String>> = OnceLock::new();
296    DISABLED.get_or_init(|| {
297        crate::config::Registry::load()
298            .map(|r| {
299                r.settings
300                    .disabled_adapters
301                    .iter()
302                    .map(|n| n.trim().to_ascii_lowercase())
303                    .filter(|n| !n.is_empty())
304                    .collect()
305            })
306            .unwrap_or_default()
307    })
308}
309
310/// Whether `name` is a real adapter name, for validating what the user typed.
311pub fn is_adapter_name(name: &str) -> bool {
312    get_all_adapters().iter().any(|a| a.name() == name)
313}
314
315/// The adapters, grouped by the language they belong to.
316///
317/// A flat list of twenty names is a wall: the question a user actually has is "leave
318/// Python alone" or "only Rust waits longer", and neither is expressible one checkbox
319/// at a time. Order is the order the groups are shown in, which is roughly how common
320/// they are rather than alphabetical — the four JavaScript managers are what most
321/// people came for.
322///
323/// The one invariant, enforced by [`every_adapter_is_grouped_exactly_once`]: every
324/// registered adapter appears here exactly once, and nothing appears here that is not
325/// registered. A new adapter that is not added to a group would silently vanish from
326/// the picker, which is the one place a user goes to find it.
327pub const ADAPTER_GROUPS: &[(&str, &[&str])] = &[
328    ("JavaScript", &["npm", "pnpm", "yarn", "bun"]),
329    ("Python", &["uv", "poetry", "pdm", "pipenv", "venv"]),
330    ("Rust", &["cargo"]),
331    ("Go", &["go"]),
332    ("JVM", &["gradle", "maven"]),
333    ("PHP", &["composer"]),
334    ("Ruby", &["bundler"]),
335    ("Swift & Objective-C", &["swift", "cocoapods"]),
336    ("Elixir", &["mix", "mix_build"]),
337    ("Infrastructure", &["terraform"]),
338    ("Dart & Flutter", &["dart"]),
339    ("C & C++", &["vcpkg", "cmake_build"]),
340];
341
342/// The language group `name` belongs to, or `"Other"` if it somehow belongs to none.
343///
344/// The fallback exists so a missing entry degrades to a visible oddity in the picker
345/// rather than an adapter that cannot be reached at all; the test is what actually
346/// keeps [`ADAPTER_GROUPS`] complete.
347pub fn adapter_group(name: &str) -> &'static str {
348    ADAPTER_GROUPS
349        .iter()
350        .find(|(_, names)| names.contains(&name))
351        .map(|(group, _)| *group)
352        .unwrap_or("Other")
353}
354
355/// Every adapter name, in registry order, for error messages and pickers.
356pub fn all_adapter_names() -> Vec<&'static str> {
357    get_all_adapters().iter().map(|a| a.name()).collect()
358}
359
360/// The adapters that need their own `enable_*` switch as well as not being disabled.
361///
362/// Two switches govern these, and a picker that ticks one without saying so leaves the
363/// user watching nothing happen.
364pub fn opt_in_adapter_names() -> Vec<&'static str> {
365    get_all_adapters()
366        .iter()
367        .filter(|a| a.opt_in())
368        .map(|a| a.name())
369        .collect()
370}
371
372/// Detect which adapters apply to a given project directory.
373///
374/// Several adapters detecting at once is normal and supported — a directory holding
375/// `package-lock.json`, `uv.lock` and `Cargo.toml` legitimately has three managers,
376/// each owning a different bloat directory. Adapters that would fight over the *same*
377/// directory are reduced to one first; see [`resolve_conflicts`].
378pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
379    detect_adapters_with(project_path, opt_in_enabled(), user_disabled())
380}
381
382/// Every package manager that claims this directory, whatever the user has switched off.
383///
384/// [`detect_adapters`] answers "what would a prune pass touch here", which is the right
385/// question everywhere a prune pass is involved and the wrong one for `devp caches`: an
386/// opt-in adapter that is off, or one named in `disabled_adapters`, still means the
387/// project uses that manager and still means its download cache is what puts the project
388/// back. Counting with the filtered detector would report a cargo cache as used by no
389/// repository on a machine full of Rust, because `enable_cargo` happens to be off — and
390/// that is the one answer that would get a cache cleared.
391pub fn detect_all_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
392    let opt_in: Vec<String> = opt_in_adapter_names()
393        .into_iter()
394        .map(str::to_string)
395        .collect();
396    detect_adapters_with(project_path, &opt_in, &[])
397}
398
399/// The body of [`detect_adapters`], with the two user-configured lists passed in.
400///
401/// Split out so the tests can state which opt-in adapters are on instead of inheriting
402/// whatever the machine running them has configured. `opt_in_enabled` and
403/// `user_disabled` read the real registry through a process-wide `OnceLock`, so a test
404/// calling `detect_adapters` directly asserted against the developer's own settings and
405/// passed or failed depending on whether they had ever run the config wizard.
406fn detect_adapters_with(
407    project_path: &Path,
408    opt_in: &[String],
409    disabled: &[String],
410) -> Vec<Box<dyn PackageManager>> {
411    let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
412        .into_iter()
413        .filter(|adapter| !adapter.opt_in() || opt_in.iter().any(|n| n == adapter.name()))
414        .filter(|adapter| !disabled.iter().any(|n| n == adapter.name()))
415        .filter(|adapter| adapter.detect(project_path))
416        .collect();
417    resolve_conflicts(project_path, &mut detected);
418    detected
419}
420
421/// Reduce every set of adapters that shares a bloat directory down to a single owner.
422fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
423    resolve_js_conflict(project_path, detected);
424    resolve_python_conflict(project_path, detected);
425}
426
427/// Reduce several JavaScript managers claiming the same `node_modules` down to one.
428///
429/// A directory carrying more than one JS lockfile is usually a half-finished migration
430/// or a stray file nobody deleted. Running the wrong manager's `enforce_lockfile` would
431/// rewrite a lockfile the project does not use, so pick deliberately, strongest signal
432/// first:
433///
434/// 1. The `packageManager` field of `package.json` — the maintainers said so outright.
435/// 2. The bookkeeping files inside `node_modules` — whoever built the tree we are about
436///    to delete is the manager whose lockfile has to be able to rebuild it.
437/// 3. The most recently written lockfile — a last resort when nothing else distinguishes
438///    them.
439fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
440    if detected
441        .iter()
442        .filter(|a| JS_MANAGERS.contains(&a.name()))
443        .count()
444        < 2
445    {
446        return;
447    }
448
449    let winner = declared_package_manager(project_path)
450        .filter(|name| detected.iter().any(|a| a.name() == name))
451        .or_else(|| installed_manager(project_path, detected))
452        .or_else(|| newest_lockfile_owner(project_path, detected));
453
454    let Some(winner) = winner else { return };
455    detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
456}
457
458/// Give one lockfile-backed Python manager sole ownership of the environment directory.
459///
460/// uv, poetry, pdm and pipenv all point at the same in-project `.venv`, and so does the
461/// plain-venv adapter. Running the wrong one's `enforce_lockfile` would sync a lockfile
462/// the project does not use, so pick deliberately:
463///
464/// 1. Any of the four displaces `venv`. They have real lockfiles and rebuild the
465///    environment exactly; `requirements.txt` cannot promise that, so it is the
466///    fallback for projects none of them recognises.
467/// 2. Between themselves — usually a half-finished migration — the one whose lockfile
468///    is actually on disk built the tree we are about to delete. With several or none
469///    present, the tie goes to whichever comes first in [`get_all_adapters()`].
470const PYTHON_ENV_MANAGERS: [(&str, &str); 4] = [
471    ("uv", "uv.lock"),
472    ("poetry", "poetry.lock"),
473    ("pdm", "pdm.lock"),
474    ("pipenv", "Pipfile.lock"),
475];
476
477fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
478    let claimants: Vec<(&str, &str)> = PYTHON_ENV_MANAGERS
479        .iter()
480        .copied()
481        .filter(|(name, _)| detected.iter().any(|a| a.name() == *name))
482        .collect();
483    let Some(&(first, _)) = claimants.first() else {
484        return;
485    };
486    detected.retain(|a| a.name() != "venv");
487    if claimants.len() < 2 {
488        return;
489    }
490    let winner = claimants
491        .iter()
492        .find(|(_, lockfile)| project_path.join(lockfile).exists())
493        .map_or(first, |(name, _)| *name);
494    detected.retain(|a| {
495        a.name() == winner
496            || !PYTHON_ENV_MANAGERS
497                .iter()
498                .any(|(name, _)| *name == a.name())
499    });
500}
501
502/// The manager that actually installed the `node_modules` tree currently on disk.
503fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
504    let node_modules = project_path.join("node_modules");
505    if !node_modules.is_dir() {
506        return None;
507    }
508
509    JS_INSTALL_MARKERS
510        .iter()
511        .find(|(name, markers)| {
512            detected.iter().any(|a| a.name() == *name)
513                && markers.iter().any(|m| node_modules.join(m).exists())
514        })
515        .map(|(name, _)| (*name).to_string())
516}
517
518/// Read the Corepack `packageManager` field (e.g. `"pnpm@9.1.0"`) from `package.json`.
519fn declared_package_manager(project_path: &Path) -> Option<String> {
520    let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
521    let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
522    let declared = json.get("packageManager")?.as_str()?;
523    let name = declared.split('@').next().unwrap_or_default();
524    JS_MANAGERS
525        .iter()
526        .find(|m| **m == name)
527        .map(|m| (*m).to_string())
528}
529
530/// The detected JS manager whose lockfile has the most recent modification time.
531fn newest_lockfile_owner(
532    project_path: &Path,
533    detected: &[Box<dyn PackageManager>],
534) -> Option<String> {
535    detected
536        .iter()
537        .filter(|a| JS_MANAGERS.contains(&a.name()))
538        .filter_map(|a| {
539            let newest = a
540                .lockfiles()
541                .iter()
542                .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
543                .max()?;
544            Some((newest, a.name().to_string()))
545        })
546        // Ties keep the earlier adapter in `get_all_adapters()` order, so the choice is
547        // deterministic when two lockfiles share a timestamp.
548        .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
549            match best {
550                Some(b) if b.0 >= cur.0 => Some(b),
551                _ => Some(cur),
552            }
553        })
554        .map(|(_, name)| name)
555}
556
557/// Calculate the total size of a directory recursively (in bytes).
558pub fn dir_size(path: &Path) -> u64 {
559    if !path.exists() {
560        return 0;
561    }
562    WalkDir::new(path)
563        .follow_links(false)
564        .into_iter()
565        .flatten()
566        .filter_map(|entry| entry.metadata().ok())
567        .filter(|meta| meta.is_file())
568        .map(|meta| meta.len())
569        .sum()
570}
571
572/// A directory's size split by what deleting it would actually free.
573#[derive(Debug, Clone, Copy, Default)]
574pub struct DirSizeBreakdown {
575    /// Bytes `remove_dir_all` gives back to the disk.
576    pub freed_bytes: u64,
577    /// Bytes that survive the deletion because a hardlink outside the directory —
578    /// for pnpm and bun, the global store — still points at them.
579    pub shared_bytes: u64,
580}
581
582/// [`dir_size`], but hardlink-aware.
583///
584/// pnpm and bun do not copy packages into `node_modules`; they hardlink them from a
585/// machine-wide store, so summing file sizes counts bytes the store keeps after the
586/// delete and promises space a prune cannot deliver. Here a physical file is counted
587/// once no matter how many names it has inside the tree, and counts as freed only
588/// when every one of its links is inside the tree. A store that fell back to copying
589/// — a different volume, a filesystem without hardlinks — leaves the link count at
590/// one, so copied installs still count in full. A file whose link count cannot be
591/// read is counted as freed, which errs toward the plain [`dir_size`] figure.
592pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
593    let mut out = DirSizeBreakdown::default();
594    if !path.exists() {
595        return out;
596    }
597    // (volume, file id) → (bytes, links on disk, links seen inside this walk)
598    let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
599    for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
600        let Ok(meta) = entry.metadata() else { continue };
601        if !meta.is_file() {
602            continue;
603        }
604        match file_link_identity(entry.path(), &meta) {
605            Some((dev, ino, nlink)) if nlink > 1 => {
606                linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
607            }
608            _ => out.freed_bytes += meta.len(),
609        }
610    }
611    for (bytes, nlink, seen) in linked.into_values() {
612        if seen >= nlink {
613            out.freed_bytes += bytes;
614        } else {
615            out.shared_bytes += bytes;
616        }
617    }
618    out
619}
620
621/// (volume, file id, hardlink count) for one file, where the platform can say.
622#[cfg(unix)]
623fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
624    use std::os::unix::fs::MetadataExt as _;
625    Some((meta.dev(), meta.ino(), meta.nlink()))
626}
627
628/// Windows keeps the link count behind an opened handle, not in the directory entry
629/// (std exposes it only on an unstable feature), so this costs one metadata-only open
630/// per file. Only the adapters that actually hardlink — pnpm and bun — pay it.
631#[cfg(windows)]
632fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
633    use std::os::windows::fs::OpenOptionsExt as _;
634    use std::os::windows::io::AsRawHandle as _;
635    use windows_sys::Win32::Storage::FileSystem::{
636        BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
637    };
638
639    // access_mode(0) asks for attribute access only, so a file another process holds
640    // open without read sharing — an antivirus scan, an editor — does not fail here.
641    let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
642    let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
643    // SAFETY: `file` keeps the handle open for the whole call, and `info` is a
644    // plain-data out-parameter the API fills before returning.
645    if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
646        return None;
647    }
648    Some((
649        u64::from(info.dwVolumeSerialNumber),
650        (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
651        u64::from(info.nNumberOfLinks),
652    ))
653}
654
655#[cfg(not(any(unix, windows)))]
656fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
657    None
658}
659
660/// Resolve a program name into something `Command::new` can actually spawn.
661///
662/// On Windows the JS package managers (`npm`, `pnpm`, `yarn`, `bun`) are shipped as
663/// `.cmd` shims. `CreateProcess` only ever appends `.exe`, so `Command::new("npm")`
664/// fails with `NotFound` even when npm is installed and on `PATH`. Search `PATH`
665/// ourselves for the shim extensions and hand back the full path.
666///
667/// Names that already contain a path separator (e.g. `.venv\Scripts\python.exe`)
668/// are returned unchanged, as are all names on non-Windows platforms.
669pub fn resolve_program(program: &str) -> String {
670    #[cfg(windows)]
671    {
672        if Path::new(program).components().count() > 1 {
673            return program.to_string();
674        }
675        let Some(path_var) = std::env::var_os("PATH") else {
676            return program.to_string();
677        };
678        for dir in std::env::split_paths(&path_var) {
679            for ext in ["exe", "cmd", "bat"] {
680                let candidate = dir.join(format!("{program}.{ext}"));
681                if candidate.is_file() {
682                    return candidate.to_string_lossy().into_owned();
683                }
684            }
685        }
686    }
687    program.to_string()
688}
689
690/// Check whether a package manager binary is present and runnable.
691///
692/// Answers are cached for the life of the process. Every adapter asks this before it
693/// enforces a lockfile, so a monorepo with ten projects on the same manager otherwise
694/// pays for ten `npm --version` process spawns — around half a second each on Windows —
695/// to learn the same fact ten times. A run is short-lived, so nothing installed or
696/// removed mid-run can be missed for long.
697pub fn binary_available(program: &str) -> bool {
698    static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
699    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
700
701    // Held across the probe on purpose: two threads asking about the same missing
702    // binary should spawn one process, not two. Nothing else takes this lock.
703    let mut guard = match cache.lock() {
704        Ok(g) => g,
705        // A poisoned lock only means some other thread panicked mid-probe; the answer
706        // is still worth having, so fall back to probing without the cache.
707        Err(_) => return probe_binary(program),
708    };
709    if let Some(known) = guard.get(program) {
710        return *known;
711    }
712    let available = probe_binary(program);
713    guard.insert(program.to_string(), available);
714    available
715}
716
717/// Programs that do not answer `--version`, and what to ask them instead.
718///
719/// `go --version` is not a typo for `go version`: the Go toolchain parses everything
720/// after `go` as a subcommand, rejects the flag with `flag provided but not defined:
721/// -version` and exits 2. A probe reading that as "not installed" is wrong on every
722/// machine with Go on it, and wrong in a way that silently weakens things — the Go
723/// adapter skips `go mod download` verification when it believes `go` is absent.
724const VERSION_PROBE_ARGS: [(&str, &[&str]); 1] = [("go", &["version"])];
725
726/// The arguments that make `program` print its version and exit `0`.
727fn version_probe_args(program: &str) -> &'static [&'static str] {
728    VERSION_PROBE_ARGS
729        .iter()
730        .find(|(name, _)| *name == program)
731        .map_or(&["--version"], |(_, args)| *args)
732}
733
734/// The actual version-probe spawn behind [`binary_available`].
735fn probe_binary(program: &str) -> bool {
736    crate::spawn::command(resolve_program(program))
737        .args(version_probe_args(program))
738        .stdin(std::process::Stdio::null())
739        .output()
740        .map(|o| o.status.success())
741        .unwrap_or(false)
742}
743
744/// The exit status and drained pipes of a finished command.
745struct CommandOutput {
746    status: std::process::ExitStatus,
747    stdout: String,
748    stderr: String,
749}
750
751/// Spawn a command, drain both of its pipes and wait for it, bounded by `timeout`.
752///
753/// Shared by the two public wrappers below. `devp caches` needs a command's *output* —
754/// `npm config get cache` answers a question rather than performing an action — and a
755/// second copy of the draining and polling below would be a second place for the
756/// deadlock it exists to prevent to come back.
757fn spawn_capture(
758    program: &str,
759    args: &[&str],
760    cwd: &Path,
761    timeout: std::time::Duration,
762) -> Result<CommandOutput> {
763    use std::io::Read;
764    use std::process::Stdio;
765    use std::thread;
766    use std::time::Instant;
767
768    let resolved = resolve_program(program);
769    let mut child = crate::spawn::command(&resolved)
770        .args(args)
771        .current_dir(cwd)
772        .stdin(Stdio::null())
773        .stdout(Stdio::piped())
774        .stderr(Stdio::piped())
775        .spawn()
776        .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
777
778    // Drain both pipes on their own threads. A package manager easily emits more
779    // than the ~64 KiB OS pipe buffer; if nobody reads it the child blocks on write
780    // and never exits, which would turn every large install into a timeout kill.
781    let mut stdout_pipe = child.stdout.take();
782    let mut stderr_pipe = child.stderr.take();
783    let stdout_reader = thread::spawn(move || {
784        let mut buf = Vec::new();
785        if let Some(pipe) = stdout_pipe.as_mut() {
786            let _ = pipe.read_to_end(&mut buf);
787        }
788        buf
789    });
790    let stderr_reader = thread::spawn(move || {
791        let mut buf = Vec::new();
792        if let Some(pipe) = stderr_pipe.as_mut() {
793            let _ = pipe.read_to_end(&mut buf);
794        }
795        buf
796    });
797
798    let start = Instant::now();
799    let status = loop {
800        match child.try_wait()? {
801            Some(status) => break status,
802            None => {
803                if start.elapsed() >= timeout {
804                    let _ = child.kill();
805                    let _ = child.wait();
806                    anyhow::bail!(
807                        "Command timed out after {}s: {} {}\n\
808                         To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
809                        timeout.as_secs(),
810                        program,
811                        args.join(" ")
812                    );
813                }
814                thread::sleep(std::time::Duration::from_millis(100));
815            }
816        }
817    };
818
819    let stderr = stderr_reader
820        .join()
821        .map(|b| String::from_utf8_lossy(&b).into_owned())
822        .unwrap_or_default();
823    let stdout = stdout_reader
824        .join()
825        .map(|b| String::from_utf8_lossy(&b).into_owned())
826        .unwrap_or_default();
827
828    Ok(CommandOutput {
829        status,
830        stdout,
831        stderr,
832    })
833}
834
835/// Helper: run a command with a configurable timeout.
836pub fn run_command_with_timeout(
837    program: &str,
838    args: &[&str],
839    cwd: &Path,
840    timeout: std::time::Duration,
841) -> Result<()> {
842    let out = spawn_capture(program, args, cwd, timeout)?;
843    if out.status.success() {
844        Ok(())
845    } else {
846        anyhow::bail!(
847            "{} {} failed (exit code {:?}):\n{}",
848            program,
849            args.join(" "),
850            out.status.code(),
851            crate::output::condense_tool_output(
852                &out.stderr,
853                crate::constants::TOOL_OUTPUT_MAX_LINES
854            )
855        )
856    }
857}
858
859/// A command's outcome and both of its streams, for a caller that needs the failure
860/// text rather than an error built from it.
861pub struct CapturedCommand {
862    /// Whether it exited zero.
863    pub ok: bool,
864    /// Everything it wrote to stdout.
865    pub stdout: String,
866    /// Everything it wrote to stderr.
867    pub stderr: String,
868}
869
870/// Run a command and hand back what it printed, whether or not it worked.
871///
872/// `devp caches containers` needs the difference between "docker is not installed" and
873/// "docker is installed and its daemon is not running", and the second only exists in
874/// what the failed command wrote to stderr. Every other caller wants
875/// [`capture_command_with_timeout`], which turns a non-zero exit into an error.
876///
877/// `Err` here is narrower than it looks: the process could not be spawned at all, or it
878/// outlived `timeout`. A command that ran and failed is `Ok` with `ok: false`.
879pub fn capture_allowing_failure(
880    program: &str,
881    args: &[&str],
882    cwd: &Path,
883    timeout: std::time::Duration,
884) -> Result<CapturedCommand> {
885    let out = spawn_capture(program, args, cwd, timeout)?;
886    Ok(CapturedCommand {
887        ok: out.status.success(),
888        stdout: out.stdout,
889        stderr: out.stderr,
890    })
891}
892
893/// Run a command and hand back what it printed on stdout, bounded by `timeout`.
894///
895/// For commands that answer a question instead of doing work. A non-zero exit is an
896/// error like anywhere else, so a caller never mistakes an error message on stderr for
897/// the answer it asked for.
898pub fn capture_command_with_timeout(
899    program: &str,
900    args: &[&str],
901    cwd: &Path,
902    timeout: std::time::Duration,
903) -> Result<String> {
904    let out = spawn_capture(program, args, cwd, timeout)?;
905    if out.status.success() {
906        Ok(out.stdout)
907    } else {
908        anyhow::bail!(
909            "{} {} failed (exit code {:?}):\n{}",
910            program,
911            args.join(" "),
912            out.status.code(),
913            crate::output::condense_tool_output(
914                &out.stderr,
915                crate::constants::TOOL_OUTPUT_MAX_LINES
916            )
917        )
918    }
919}
920
921/// Helper: attempt a command but return `true`/`false` instead of `Err`.
922pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
923    crate::spawn::command(resolve_program(program))
924        .args(args)
925        .current_dir(cwd)
926        .stdin(std::process::Stdio::null())
927        .output()
928        .map(|o| o.status.success())
929        .unwrap_or(false)
930}
931
932/// How much newer the manifest must be than the lockfile before
933/// [`refuse_if_manifest_newer`] calls it drift.
934///
935/// A clone or checkout writes both files within moments of each other, in whichever
936/// order the tree walk happens to visit them — a strict comparison would refuse half of
937/// all fresh clones. A hand edit that never got a lockfile sync is separated by minutes
938/// or days, which a minute of tolerance still catches.
939const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
940
941/// When the package manager is missing, a lockfile is only proof if the manifest has
942/// not been edited since it was written.
943///
944/// With the binary present the verify command answers this properly; without it, mtimes
945/// are the only signal there is. The manifest is inferred from the lockfile's file
946/// name; an unrecognised name changes nothing.
947fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
948    let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
949        Some("Cargo.lock") => "Cargo.toml",
950        Some("package-lock.json")
951        | Some("yarn.lock")
952        | Some("pnpm-lock.yaml")
953        | Some("bun.lockb")
954        | Some("bun.lock") => "package.json",
955        Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
956        Some("go.sum") => "go.mod",
957        Some("composer.lock") => "composer.json",
958        Some("Gemfile.lock") => "Gemfile",
959        Some("Pipfile.lock") => "Pipfile",
960        _ => return Ok(()),
961    };
962    let manifest = cwd.join(manifest_name);
963    let (Ok(manifest_meta), Ok(lock_meta)) =
964        (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
965    else {
966        return Ok(());
967    };
968    if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
969        && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
970    {
971        anyhow::bail!(
972            "`{program}` is not available, and `{manifest_name}` has been edited more \
973                 recently than `{}` — the lockfile may no longer record the current \
974                 dependencies, and without `{program}` that cannot be verified. Install \
975                 {program} and run its lockfile sync, then prune again.",
976            lockfile.display()
977        );
978    }
979    Ok(())
980}
981
982/// The lockfile-freshness proof for managers that have no read-only check of their own.
983///
984/// CocoaPods, Mix and SwiftPM all rebuild from a lockfile, and not one of them offers a
985/// command that compares the lockfile to the manifest without also resolving over the
986/// network — `pod install`, `mix deps.get` and `swift package resolve` all *fix* the
987/// drift rather than reporting it, which is a write in the middle of a delete pass. The
988/// timestamps are the only offline evidence there is, so they are the evidence used: a
989/// manifest edited after its lockfile means the lockfile may no longer describe the
990/// dependency set, and a directory only a stale lockfile can rebuild is not recoverable
991/// in the sense this tool promises.
992pub fn refuse_if_manifest_stale(
993    manifest: &Path,
994    lockfile: &Path,
995    sync_command: &str,
996) -> Result<()> {
997    let (Ok(manifest_meta), Ok(lock_meta)) =
998        (std::fs::metadata(manifest), std::fs::metadata(lockfile))
999    else {
1000        return Ok(());
1001    };
1002    if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
1003        && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
1004    {
1005        anyhow::bail!(
1006            "`{}` has been edited more recently than `{}` — the lockfile may no longer \
1007             record the current dependencies. Run `{sync_command}` and prune again.",
1008            manifest.display(),
1009            lockfile.display()
1010        );
1011    }
1012    Ok(())
1013}
1014
1015/// Two-tier lockfile enforcement with configurable timeout.
1016pub fn lock_sync_or_verify_with_timeout(
1017    lockfile: &Path,
1018    program: &str,
1019    sync_args: &[&str],
1020    cwd: &Path,
1021    timeout: std::time::Duration,
1022) -> Result<()> {
1023    let lockfile_exists = lockfile.exists();
1024
1025    if !binary_available(program) {
1026        if lockfile_exists {
1027            refuse_if_manifest_newer(lockfile, program, cwd)?;
1028            return Ok(());
1029        } else {
1030            anyhow::bail!(
1031                "`{program}` is not available and no lockfile was found at `{}`. \
1032                 Cannot safely delete dependencies — install {program} first, \
1033                 or commit a lockfile.",
1034                lockfile.display()
1035            );
1036        }
1037    }
1038
1039    // Binary is available — run the sync with timeout.
1040    run_command_with_timeout(program, sync_args, cwd, timeout)
1041}
1042
1043/// What an adapter is allowed to do while enforcing a lockfile on this pass.
1044///
1045/// The two things that used to be hardcoded per adapter, and were wrong in both places:
1046/// every adapter burned the compiled-in timeout regardless of `command_timeout_secs`,
1047/// and only cargo and go consulted `allow_manifest_rewrite`.
1048#[derive(Debug, Clone, Copy)]
1049pub struct EnforcePolicy {
1050    /// Whether a sync command that writes files Git tracks may run anyway.
1051    ///
1052    /// The user's `allow_manifest_rewrite`. Off by default: a prune pass can come from
1053    /// the scheduler, and a background process that leaves a dirty working tree behind
1054    /// is a surprise no matter which file it wrote.
1055    pub allow_rewrite: bool,
1056    /// Ceiling on any one package-manager command — the user's `command_timeout_secs`.
1057    pub timeout: std::time::Duration,
1058}
1059
1060impl Default for EnforcePolicy {
1061    fn default() -> Self {
1062        Self {
1063            allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
1064            timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1065        }
1066    }
1067}
1068
1069impl EnforcePolicy {
1070    /// A policy from the user's own settings.
1071    pub fn from_settings(settings: &crate::config::Settings) -> Self {
1072        Self {
1073            allow_rewrite: settings.allow_manifest_rewrite,
1074            timeout: std::time::Duration::from_secs(settings.command_timeout_secs),
1075        }
1076    }
1077}
1078
1079/// The one rule every adapter enforces, given the manager's two spellings of the check.
1080///
1081/// - lockfile present → `verify_args`, which resolves the graph against the lockfile and
1082///   **fails** rather than writing when the two have drifted apart
1083/// - lockfile absent → `write_args`, because `restore` needs a lockfile to exist at all
1084///   and there is nothing there to preserve
1085/// - `allow_rewrite` → `write_args` either way: the informed opt-in, for the user who
1086///   would rather have a stale lockfile refreshed than have the prune refused
1087///
1088/// This used to be the cargo/go rule only. Every other adapter ran its writing sync
1089/// unconditionally — `npm install --package-lock-only`, `pnpm install --lockfile-only`,
1090/// `uv lock` and `yarn install --mode update-lockfile` all rewrite a lockfile Git tracks
1091/// when it has drifted from the manifest. That is a smaller edit than `go mod tidy`
1092/// makes, but it is still an unattended pass modifying a tracked file, and it made
1093/// `allow_manifest_rewrite` mean two different things depending on the ecosystem.
1094pub fn enforce_two_tier(
1095    lockfile: &Path,
1096    program: &str,
1097    verify_args: &[&str],
1098    write_args: &[&str],
1099    cwd: &Path,
1100    policy: EnforcePolicy,
1101) -> Result<()> {
1102    if policy.allow_rewrite {
1103        return lock_sync_or_verify_with_timeout(
1104            lockfile,
1105            program,
1106            write_args,
1107            cwd,
1108            policy.timeout,
1109        );
1110    }
1111    lock_verify_or_generate(
1112        lockfile,
1113        program,
1114        verify_args,
1115        write_args,
1116        cwd,
1117        policy.timeout,
1118    )
1119}
1120
1121/// Lockfile enforcement for ecosystems whose "sync" command rewrites source manifests.
1122///
1123/// `cargo generate-lockfile` re-resolves every dependency and overwrites `Cargo.lock`;
1124/// `go mod tidy` edits both `go.mod` and `go.sum` and can drop requirements. Running
1125/// either as a precondition for deletion would silently modify tracked source files,
1126/// which contradicts the lockfile-safety guarantee. So:
1127///
1128/// - lockfile present → run the read-only `verify_args` (never writes)
1129/// - lockfile absent  → run `generate_args`, since a lockfile must exist for `restore`
1130pub fn lock_verify_or_generate(
1131    lockfile: &Path,
1132    program: &str,
1133    verify_args: &[&str],
1134    generate_args: &[&str],
1135    cwd: &Path,
1136    timeout: std::time::Duration,
1137) -> Result<()> {
1138    let lockfile_exists = lockfile.exists();
1139
1140    if !binary_available(program) {
1141        if lockfile_exists {
1142            refuse_if_manifest_newer(lockfile, program, cwd)?;
1143            return Ok(());
1144        }
1145        anyhow::bail!(
1146            "`{program}` is not available and no lockfile was found at `{}`. \
1147             Cannot safely delete dependencies — install {program} first, \
1148             or commit a lockfile.",
1149            lockfile.display()
1150        );
1151    }
1152
1153    if lockfile_exists {
1154        run_command_with_timeout(program, verify_args, cwd, timeout)
1155    } else {
1156        run_command_with_timeout(program, generate_args, cwd, timeout)
1157    }
1158}
1159
1160/// Two-tier lockfile enforcement using default timeout.
1161pub fn lock_sync_or_verify(
1162    lockfile: &Path,
1163    program: &str,
1164    sync_args: &[&str],
1165    cwd: &Path,
1166) -> Result<()> {
1167    lock_sync_or_verify_with_timeout(
1168        lockfile,
1169        program,
1170        sync_args,
1171        cwd,
1172        std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
1173    )
1174}
1175
1176/// Adapters with no binary worth probing before a restore: venv rebuilds through
1177/// whichever `python` the user has, and the build-tool adapters restore by the project's
1178/// next compile rather than by a command dev-prune runs.
1179/// Filename every virtual environment carries, and the only reliable record of which
1180/// interpreter built it.
1181const PYVENV_CFG: &str = "pyvenv.cfg";
1182
1183/// The `major.minor` a virtual environment was built with, as `"3.12"`.
1184///
1185/// Read from the environment's own `pyvenv.cfg`, which CPython writes at creation time
1186/// and never updates — which is exactly what makes it a record of the *original*
1187/// interpreter rather than of whatever is on `PATH` now. `None` when the directory is
1188/// not a virtual environment, or is one written by something that omitted the key.
1189pub(crate) fn venv_runtime_tag(venv: &Path) -> Option<String> {
1190    let cfg = std::fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
1191    for line in cfg.lines() {
1192        let Some((key, value)) = line.split_once('=') else {
1193            continue;
1194        };
1195        if matches!(key.trim(), "version" | "version_info") {
1196            let mut parts = value.trim().split('.');
1197            let major: u64 = parts.next()?.parse().ok()?;
1198            let minor: u64 = parts.next()?.parse().ok()?;
1199            return Some(format!("{major}.{minor}"));
1200        }
1201    }
1202    None
1203}
1204
1205/// A runtime tag is spliced into a command line, so it has to be proved to be a version
1206/// number before it gets there. The registry is a file on disk; a hand-edited or
1207/// corrupted entry must not be able to turn a restore into `python --version; rm -rf /`.
1208pub(crate) fn is_valid_runtime_tag(tag: &str) -> bool {
1209    let mut parts = tag.split('.');
1210    let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
1211        return false;
1212    };
1213    !major.is_empty()
1214        && !minor.is_empty()
1215        && major.len() <= 2
1216        && minor.len() <= 3
1217        && major.bytes().all(|b| b.is_ascii_digit())
1218        && minor.bytes().all(|b| b.is_ascii_digit())
1219}
1220
1221/// How to invoke one specific Python `major.minor`: the program, and the arguments that
1222/// must come before anything else.
1223///
1224/// Windows ships the `py` launcher, which knows about every interpreter the machine has
1225/// registered and takes the version as a flag. Everywhere else the convention is a
1226/// separate `python3.12` binary on `PATH`. Returns `None` for a tag that is not a plain
1227/// version number.
1228pub(crate) fn python_launcher(tag: &str) -> Option<(String, Vec<String>)> {
1229    if !is_valid_runtime_tag(tag) {
1230        return None;
1231    }
1232    #[cfg(windows)]
1233    {
1234        Some(("py".to_string(), vec![format!("-{tag}")]))
1235    }
1236    #[cfg(not(windows))]
1237    {
1238        Some((format!("python{tag}"), Vec::new()))
1239    }
1240}
1241
1242/// The absolute path of one specific Python `major.minor`, asked of the interpreter
1243/// itself.
1244///
1245/// The launcher form is enough to *run* an interpreter, but not to name one to a tool
1246/// that wants a path — `poetry env use` is the case in hand, and `poetry env use py` is
1247/// not a thing. Returns `None` when that version is not installed, which makes this an
1248/// availability check as well.
1249pub(crate) fn python_executable(tag: &str) -> Option<String> {
1250    let (program, prefix) = python_launcher(tag)?;
1251    let out = crate::spawn::command(resolve_program(&program))
1252        .args(&prefix)
1253        .args(["-c", "import sys; print(sys.executable)"])
1254        .stdin(std::process::Stdio::null())
1255        .stderr(std::process::Stdio::null())
1256        .output()
1257        .ok()?;
1258    if !out.status.success() {
1259        return None;
1260    }
1261    let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
1262    (!path.is_empty()).then_some(path)
1263}
1264
1265/// Whether this machine can actually run that interpreter.
1266///
1267/// Asked before a restore commits to it, because the recorded version is a fact about
1268/// the machine the prune ran on, and the restore may well be happening somewhere else.
1269pub(crate) fn python_runtime_available(tag: &str) -> bool {
1270    let Some((program, prefix)) = python_launcher(tag) else {
1271        return false;
1272    };
1273    crate::spawn::command(resolve_program(&program))
1274        .args(&prefix)
1275        .arg("--version")
1276        .stdin(std::process::Stdio::null())
1277        .stdout(std::process::Stdio::null())
1278        .stderr(std::process::Stdio::null())
1279        .status()
1280        .is_ok_and(|s| s.success())
1281}
1282
1283const NO_RESTORE_BINARY: [&str; 7] = [
1284    "venv",
1285    "gradle",
1286    "maven",
1287    "mix_build",
1288    "swift",
1289    "vcpkg",
1290    "cmake_build",
1291];
1292
1293/// Adapters whose executable is not called what the adapter is called.
1294const ADAPTER_BINARIES: [(&str, &str); 2] = [("bundler", "bundle"), ("cocoapods", "pod")];
1295
1296/// The executable that restores for a given adapter.
1297pub fn adapter_binary(adapter: &str) -> &str {
1298    ADAPTER_BINARIES
1299        .iter()
1300        .find(|(name, _)| *name == adapter)
1301        .map_or(adapter, |(_, binary)| *binary)
1302}
1303
1304/// Where to get each package manager, for the one report that has to say so.
1305///
1306/// `devp doctor` naming a missing manager without saying how to get it is a finding the
1307/// reader has to go and research; every other finding it prints carries its own repair.
1308const INSTALL_HINTS: [(&str, &str); 16] = [
1309    ("npm", "ships with Node.js — https://nodejs.org"),
1310    (
1311        "pnpm",
1312        "`npm install -g pnpm` — https://pnpm.io/installation",
1313    ),
1314    (
1315        "yarn",
1316        "`corepack enable` — https://yarnpkg.com/getting-started/install",
1317    ),
1318    ("bun", "https://bun.sh/docs/installation"),
1319    (
1320        "uv",
1321        "https://docs.astral.sh/uv/getting-started/installation/",
1322    ),
1323    ("poetry", "https://python-poetry.org/docs/#installation"),
1324    (
1325        "pdm",
1326        "`uv tool install pdm` — https://pdm-project.org/en/latest/#installation",
1327    ),
1328    (
1329        "pipenv",
1330        "`uv tool install pipenv` — https://pipenv.pypa.io/en/latest/installation.html",
1331    ),
1332    ("cargo", "ships with Rust — https://rustup.rs"),
1333    ("go", "https://go.dev/dl/"),
1334    ("composer", "https://getcomposer.org/download/"),
1335    ("bundler", "`gem install bundler` — https://bundler.io"),
1336    (
1337        "cocoapods",
1338        "`gem install cocoapods` — https://cocoapods.org",
1339    ),
1340    (
1341        "mix",
1342        "ships with Elixir — https://elixir-lang.org/install.html",
1343    ),
1344    (
1345        "terraform",
1346        "https://developer.hashicorp.com/terraform/install",
1347    ),
1348    (
1349        "dart",
1350        "https://dart.dev/get-dart — or the Flutter SDK, which bundles it",
1351    ),
1352];
1353
1354/// How to install the manager behind `adapter`, if there is a one-line answer.
1355pub fn install_hint(adapter: &str) -> Option<&'static str> {
1356    INSTALL_HINTS
1357        .iter()
1358        .find(|(name, _)| *name == adapter)
1359        .map(|(_, hint)| *hint)
1360}
1361
1362/// Information describing status of a required package manager binary.
1363#[derive(Debug, Clone)]
1364pub struct BinaryCheckStatus {
1365    pub name: String,
1366    pub available: bool,
1367    pub version: Option<String>,
1368}
1369
1370/// Scan only the package manager binaries needed by candidate repos.
1371pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
1372    let mut unique: Vec<String> = adapter_names
1373        .iter()
1374        // venv restores through python, and the build-tool adapters restore by the
1375        // next compile — none of them has a binary named after the adapter to probe.
1376        .filter(|&n| !NO_RESTORE_BINARY.contains(&n.as_str()) && n != "-")
1377        .cloned()
1378        .collect();
1379    unique.sort();
1380    unique.dedup();
1381
1382    unique
1383        .into_iter()
1384        .map(|name| {
1385            let binary = adapter_binary(&name);
1386            let output = crate::spawn::command(resolve_program(binary))
1387                .args(version_probe_args(binary))
1388                .stdin(std::process::Stdio::null())
1389                .output();
1390            match output {
1391                Ok(out) if out.status.success() => {
1392                    let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
1393                    let first_line = ver.lines().next().unwrap_or(&ver).to_string();
1394                    BinaryCheckStatus {
1395                        name,
1396                        available: true,
1397                        version: if first_line.is_empty() {
1398                            None
1399                        } else {
1400                            Some(first_line)
1401                        },
1402                    }
1403                }
1404                _ => BinaryCheckStatus {
1405                    name,
1406                    available: false,
1407                    version: None,
1408                },
1409            }
1410        })
1411        .collect()
1412}
1413
1414#[cfg(test)]
1415mod tests {
1416    use super::*;
1417    use std::fs;
1418    use tempfile::TempDir;
1419
1420    #[test]
1421    fn every_adapter_is_grouped_exactly_once() {
1422        // The picker is built from ADAPTER_GROUPS, not from the registry, so an adapter
1423        // missing here is an adapter nobody can switch off from the configurator.
1424        let registered = all_adapter_names();
1425        let grouped: Vec<&str> = ADAPTER_GROUPS
1426            .iter()
1427            .flat_map(|(_, names)| names.iter().copied())
1428            .collect();
1429
1430        for name in &registered {
1431            assert_eq!(
1432                grouped.iter().filter(|g| *g == name).count(),
1433                1,
1434                "`{name}` must appear in exactly one ADAPTER_GROUPS entry"
1435            );
1436        }
1437        for name in &grouped {
1438            assert!(
1439                registered.contains(name),
1440                "ADAPTER_GROUPS names `{name}`, which is not a registered adapter"
1441            );
1442        }
1443        assert_eq!(registered.len(), grouped.len());
1444    }
1445
1446    #[test]
1447    fn the_opt_in_adapters_are_the_ones_that_hold_compiler_output() {
1448        // Not a restatement of the code: this is the product rule. An adapter whose
1449        // directory only comes back by recompiling must be opt-in, and one that comes
1450        // back by downloading must not be — otherwise the longer `build_idle_days`
1451        // window and the trust report both describe something else.
1452        let mut opt_in = opt_in_adapter_names();
1453        opt_in.sort_unstable();
1454        assert_eq!(
1455            opt_in,
1456            vec![
1457                "cargo",
1458                "cmake_build",
1459                "dart",
1460                "gradle",
1461                "maven",
1462                "mix_build",
1463                "swift",
1464                "vcpkg"
1465            ]
1466        );
1467    }
1468
1469    #[test]
1470    fn go_is_probed_with_the_subcommand_it_actually_accepts() {
1471        // `go --version` exits 2 with "flag provided but not defined: -version". The
1472        // probe reading that as "go is not installed" made `devp doctor` warn on every
1473        // machine with Go on it, and made the Go adapter fall back from `go mod
1474        // download` to the weaker manifest-age check before deleting anything.
1475        assert_eq!(version_probe_args("go"), &["version"]);
1476        assert_eq!(version_probe_args("npm"), &["--version"]);
1477    }
1478
1479    #[test]
1480    fn every_probed_adapter_binary_has_somewhere_to_get_it() {
1481        // A `doctor` warning that names a manager and not how to install it is research
1482        // homework. The adapters excluded from the probe have no binary to install.
1483        for adapter in get_all_adapters() {
1484            let name = adapter.name();
1485            if NO_RESTORE_BINARY.contains(&name) {
1486                continue;
1487            }
1488            assert!(
1489                install_hint(name).is_some(),
1490                "adapter `{name}` has no install hint"
1491            );
1492        }
1493    }
1494
1495    #[test]
1496    fn test_bloat_dir_display() {
1497        let bd = BloatDir {
1498            name: "node_modules".to_string(),
1499            path: PathBuf::from("/test/node_modules"),
1500            size_bytes: 1024,
1501            shared_bytes: 0,
1502        };
1503        assert!(bd.to_string().contains("node_modules"));
1504    }
1505
1506    #[test]
1507    fn test_hardlink_size_counts_a_plain_file_in_full() {
1508        let tmp = TempDir::new().unwrap();
1509        let tree = tmp.path().join("tree");
1510        fs::create_dir(&tree).unwrap();
1511        fs::write(tree.join("copied.txt"), "12345").unwrap();
1512        let size = dir_size_with_hardlinks(&tree);
1513        assert_eq!(size.freed_bytes, 5);
1514        assert_eq!(size.shared_bytes, 0);
1515    }
1516
1517    #[test]
1518    fn test_hardlink_size_excludes_a_file_the_store_keeps() {
1519        // The pnpm shape: the store's copy lives outside the tree being deleted, so
1520        // deleting the tree frees nothing for this file.
1521        let tmp = TempDir::new().unwrap();
1522        let store = tmp.path().join("store");
1523        let tree = tmp.path().join("tree");
1524        fs::create_dir(&store).unwrap();
1525        fs::create_dir(&tree).unwrap();
1526        fs::write(store.join("pkg.js"), "0123456789").unwrap();
1527        fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
1528        let size = dir_size_with_hardlinks(&tree);
1529        assert_eq!(size.freed_bytes, 0);
1530        assert_eq!(size.shared_bytes, 10);
1531    }
1532
1533    #[test]
1534    fn test_hardlink_size_counts_an_internal_pair_once() {
1535        // Both names live inside the tree, so the delete removes the last link and
1536        // the bytes really are freed — but only once, not per name.
1537        let tmp = TempDir::new().unwrap();
1538        let tree = tmp.path().join("tree");
1539        fs::create_dir(&tree).unwrap();
1540        fs::write(tree.join("a.js"), "abcdefg").unwrap();
1541        fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
1542        let size = dir_size_with_hardlinks(&tree);
1543        assert_eq!(size.freed_bytes, 7);
1544        assert_eq!(size.shared_bytes, 0);
1545    }
1546
1547    #[test]
1548    fn test_dir_size_empty() {
1549        let tmp = TempDir::new().unwrap();
1550        assert_eq!(dir_size(tmp.path()), 0);
1551    }
1552
1553    #[test]
1554    fn test_dir_size_with_files() {
1555        let tmp = TempDir::new().unwrap();
1556        fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1557        fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1558        assert_eq!(dir_size(tmp.path()), 11); // 5 + 6
1559    }
1560
1561    #[test]
1562    fn test_dir_size_nonexistent() {
1563        assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1564    }
1565
1566    #[test]
1567    fn test_get_all_adapters_not_empty() {
1568        let adapters = get_all_adapters();
1569        assert!(adapters.len() >= 6);
1570    }
1571
1572    #[test]
1573    fn test_detect_adapters_npm() {
1574        let tmp = TempDir::new().unwrap();
1575        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1576        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1577        let adapters = detect_adapters(tmp.path());
1578        let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1579        assert!(names.contains(&"npm"));
1580    }
1581
1582    #[test]
1583    fn test_detect_adapters_empty_dir() {
1584        let tmp = TempDir::new().unwrap();
1585        let adapters = detect_adapters(tmp.path());
1586        assert!(adapters.is_empty());
1587    }
1588
1589    /// Names of the adapters that detect in `dir`, sorted.
1590    ///
1591    /// Deliberately goes through [`detect_adapters_with`] with both lists empty: no
1592    /// opt-in adapter is on and nothing is disabled, whatever the machine running the
1593    /// test happens to have in its own config.
1594    fn detected_names(dir: &Path) -> Vec<&'static str> {
1595        let mut names: Vec<&'static str> = detect_adapters_with(dir, &[], &[])
1596            .iter()
1597            .map(|a| a.name())
1598            .collect();
1599        names.sort_unstable();
1600        names
1601    }
1602
1603    #[test]
1604    fn test_detect_adapters_multiple_ecosystems_coexist() {
1605        // Different managers owning different directories must all survive detection.
1606        let tmp = TempDir::new().unwrap();
1607        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1608        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1609        fs::write(tmp.path().join("uv.lock"), "").unwrap();
1610        fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1611        fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1612
1613        // Cargo is missing on purpose: it is opt-in, nothing has switched it on here,
1614        // and detection is the single funnel that has to enforce that. An opt-in
1615        // adapter that still detected would be counted by status and stats and only
1616        // refuse at the point of deletion.
1617        assert_eq!(detected_names(tmp.path()), vec!["go", "npm", "uv"]);
1618    }
1619
1620    #[test]
1621    fn test_detect_adapters_opt_in_appears_once_enabled() {
1622        // The other half of the gate: switching cargo on has to make it detect, or the
1623        // setting is a no-op that silently never fires.
1624        let tmp = TempDir::new().unwrap();
1625        fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1626
1627        let on = [String::from("cargo")];
1628        let mut names: Vec<&str> = detect_adapters_with(tmp.path(), &on, &[])
1629            .iter()
1630            .map(|a| a.name())
1631            .collect();
1632        names.sort_unstable();
1633        assert_eq!(names, vec!["cargo"]);
1634    }
1635
1636    #[test]
1637    fn every_adapter_counts_as_used_even_when_it_is_switched_off() {
1638        // `devp caches` asks which package managers a repository *uses*, which is not the
1639        // same question as which ones a prune pass would act on. A machine full of Rust
1640        // with `enable_cargo` off must not report the cargo cache as needed by nobody —
1641        // that is the one wrong answer that gets a cache cleared.
1642        let tmp = TempDir::new().unwrap();
1643        fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1644
1645        assert!(
1646            detect_adapters_with(tmp.path(), &[], &[]).is_empty(),
1647            "cargo is opt-in, so the prune-facing detector must not see it here"
1648        );
1649        let names: Vec<&str> = detect_all_adapters(tmp.path())
1650            .iter()
1651            .map(|a| a.name())
1652            .collect();
1653        assert_eq!(names, vec!["cargo"]);
1654    }
1655
1656    #[test]
1657    fn test_detect_adapters_disabled_adapter_is_invisible() {
1658        // `disabled_adapters` has to bite at the same funnel, for the same reason.
1659        let tmp = TempDir::new().unwrap();
1660        fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1661
1662        let off = [String::from("go")];
1663        assert!(detect_adapters_with(tmp.path(), &[], &off).is_empty());
1664    }
1665
1666    #[test]
1667    fn test_js_conflict_resolved_by_package_manager_field() {
1668        let tmp = TempDir::new().unwrap();
1669        fs::write(
1670            tmp.path().join("package.json"),
1671            r#"{"packageManager":"yarn@4.1.0"}"#,
1672        )
1673        .unwrap();
1674        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1675        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1676        fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1677
1678        assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1679    }
1680
1681    #[test]
1682    fn test_js_conflict_resolved_by_what_installed_node_modules() {
1683        // npm's lockfile is written last, but the tree on disk was built by pnpm — and
1684        // that tree is what is about to be deleted.
1685        let tmp = TempDir::new().unwrap();
1686        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1687        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1688        fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1689        std::thread::sleep(std::time::Duration::from_millis(20));
1690        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1691
1692        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1693    }
1694
1695    #[test]
1696    fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1697        // A repo migrated npm → yarn keeps npm's hidden lockfile inside node_modules.
1698        let tmp = TempDir::new().unwrap();
1699        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1700        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1701        fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1702        let nm = tmp.path().join("node_modules");
1703        fs::create_dir_all(&nm).unwrap();
1704        fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1705        fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1706
1707        assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1708    }
1709
1710    #[test]
1711    fn test_declared_package_manager_outranks_what_is_installed() {
1712        // Corepack pins the project to pnpm; the npm tree on disk is the accident.
1713        let tmp = TempDir::new().unwrap();
1714        fs::write(
1715            tmp.path().join("package.json"),
1716            r#"{"packageManager":"pnpm@9.1.0"}"#,
1717        )
1718        .unwrap();
1719        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1720        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1721        let nm = tmp.path().join("node_modules");
1722        fs::create_dir_all(&nm).unwrap();
1723        fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1724
1725        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1726    }
1727
1728    #[test]
1729    fn test_uv_takes_precedence_over_plain_venv() {
1730        // A uv project declared through `[tool.uv]` alone, with a requirements.txt and a
1731        // virtual environment left over from before the migration.
1732        let tmp = TempDir::new().unwrap();
1733        fs::write(
1734            tmp.path().join("pyproject.toml"),
1735            "[project]\nname = \"x\"\n\n[tool.uv]\n",
1736        )
1737        .unwrap();
1738        fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1739        let venv = tmp.path().join(".venv");
1740        fs::create_dir_all(&venv).unwrap();
1741        fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1742
1743        assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1744    }
1745
1746    #[test]
1747    fn test_plain_venv_handles_projects_uv_does_not_claim() {
1748        let tmp = TempDir::new().unwrap();
1749        fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1750        let venv = tmp.path().join("venv");
1751        fs::create_dir_all(&venv).unwrap();
1752        fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1753
1754        assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1755    }
1756
1757    #[test]
1758    fn test_js_conflict_falls_back_to_newest_lockfile() {
1759        let tmp = TempDir::new().unwrap();
1760        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1761        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1762        // Written second, and touched again, so pnpm is unambiguously the newer of the
1763        // two even on filesystems with coarse timestamp granularity.
1764        std::thread::sleep(std::time::Duration::from_millis(20));
1765        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1766
1767        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1768    }
1769
1770    #[test]
1771    fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1772        // A `packageManager` naming something we have no adapter for must not wipe out
1773        // the detection entirely — fall through to the lockfile timestamps.
1774        let tmp = TempDir::new().unwrap();
1775        fs::write(
1776            tmp.path().join("package.json"),
1777            r#"{"packageManager":"deno@2.0.0"}"#,
1778        )
1779        .unwrap();
1780        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1781        std::thread::sleep(std::time::Duration::from_millis(20));
1782        fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1783
1784        assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1785    }
1786
1787    #[test]
1788    fn test_js_conflict_does_not_disturb_a_single_manager() {
1789        let tmp = TempDir::new().unwrap();
1790        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1791        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1792        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1793    }
1794
1795    #[test]
1796    fn test_js_adapters_declare_their_lockfiles() {
1797        for adapter in get_all_adapters() {
1798            if JS_MANAGERS.contains(&adapter.name()) {
1799                assert!(
1800                    !adapter.lockfiles().is_empty(),
1801                    "{} shares node_modules and must declare its lockfiles for \
1802                     conflict resolution",
1803                    adapter.name()
1804                );
1805            }
1806        }
1807    }
1808
1809    #[test]
1810    fn test_adapter_names_unique() {
1811        let adapters = get_all_adapters();
1812        let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1813        let mut unique = names.clone();
1814        unique.sort();
1815        unique.dedup();
1816        assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1817    }
1818
1819    #[test]
1820    fn a_runtime_tag_is_a_version_number_and_nothing_else() {
1821        // This is spliced into a command line, and the registry it comes from is a file
1822        // on disk. A hand-edited or corrupted entry must not reach a shell.
1823        assert!(is_valid_runtime_tag("3.12"));
1824        assert!(is_valid_runtime_tag("3.9"));
1825        for bad in [
1826            "",
1827            "3",
1828            "3.12.1",
1829            "3.x",
1830            "3.12; rm -rf /",
1831            "-3.12",
1832            "../python",
1833            "3.1234",
1834            "300.1",
1835        ] {
1836            assert!(!is_valid_runtime_tag(bad), "{bad} must be refused");
1837        }
1838    }
1839
1840    #[test]
1841    fn the_interpreter_is_read_from_the_environments_own_pyvenv_cfg() {
1842        let tmp = tempfile::tempdir().unwrap();
1843        let venv = tmp.path().join(".venv");
1844        std::fs::create_dir_all(&venv).unwrap();
1845        std::fs::write(
1846            venv.join("pyvenv.cfg"),
1847            "home = /usr/bin\nversion = 3.12.4\ninclude-system-site-packages = false\n",
1848        )
1849        .unwrap();
1850        assert_eq!(venv_runtime_tag(&venv), Some("3.12".to_string()));
1851    }
1852
1853    #[test]
1854    fn a_directory_that_is_not_an_environment_records_no_interpreter() {
1855        let tmp = tempfile::tempdir().unwrap();
1856        assert_eq!(venv_runtime_tag(tmp.path()), None);
1857    }
1858}