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