Skip to main content

dev_prune/adapters/
mod.rs

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