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