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