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