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