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