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 cargo_adapter;
21pub mod go;
22pub mod gradle;
23pub mod maven;
24pub mod npm;
25pub mod pnpm;
26pub mod poetry;
27pub mod uv;
28pub mod venv;
29pub mod yarn;
30
31use std::collections::HashMap;
32use std::fmt;
33use std::path::{Path, PathBuf};
34use std::sync::{Mutex, OnceLock};
35
36use anyhow::{Context as _, Result};
37use walkdir::WalkDir;
38
39/// Information about a bloat directory that can be pruned.
40#[derive(Debug, Clone)]
41pub struct BloatDir {
42    /// Human-readable name (e.g., "node_modules").
43    pub name: String,
44    /// Full path to the bloat directory.
45    pub path: PathBuf,
46    /// Bytes that deleting this directory actually gives back to the disk.
47    pub size_bytes: u64,
48    /// Bytes reachable through hardlinks from outside this directory — pnpm's and
49    /// bun's store links. Deleting the directory does not free these; the store
50    /// keeps them. Zero for managers that copy instead of link.
51    pub shared_bytes: u64,
52}
53
54impl fmt::Display for BloatDir {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "{} ({})", self.name, self.path.display())
57    }
58}
59
60/// Packages sitting in a manager's environment directory that its lockfile does not
61/// record — installs a post-prune restore would not bring back.
62#[derive(Debug, Clone)]
63pub struct DriftReport {
64    /// The environment directory that drifted (e.g. `.venv`, `node_modules`).
65    pub directory: String,
66    /// The unrecorded package names, sorted.
67    pub unrecorded: Vec<String>,
68    /// The command that writes them into the lockfile.
69    pub record_command: &'static str,
70}
71
72/// The core trait that every package manager adapter must implement.
73///
74/// Each adapter is responsible for:
75/// - **Detecting** whether it applies to a given project directory
76/// - **Listing** the bloat directories it manages
77/// - **Enforcing** lockfile consistency before deletion
78/// - **Restoring** dependencies from lockfiles
79pub trait PackageManager: Send + Sync {
80    /// Human-readable name for this adapter (e.g., "npm", "pnpm", "uv").
81    fn name(&self) -> &'static str;
82
83    /// Check if this adapter applies to the given project directory.
84    ///
85    /// Typically checks for the presence of a specific lockfile or config file.
86    fn detect(&self, project_path: &Path) -> bool;
87
88    /// List all bloat directories this adapter manages in the given project.
89    ///
90    /// Only returns directories that actually exist on disk.
91    fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
92
93    /// Prove the lockfile can rebuild what is about to be deleted.
94    ///
95    /// This is a **safety-critical** method. It MUST succeed before any bloat
96    /// directory is deleted. If this fails, deletion for this adapter is aborted.
97    ///
98    /// See [`EnforcePolicy`] for the one rule every adapter follows.
99    fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
100
101    /// Restore dependencies from the lockfile (for `dev-prune restore`).
102    ///
103    /// `timeout` is threaded explicitly for the same reason [`EnforcePolicy`] is: the
104    /// restore path used to burn the compiled-in default regardless of
105    /// `command_timeout_secs`, and a full `npm ci` on a large tree needs the raised
106    /// timeout far more often than a verify does.
107    fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
108
109    /// [`PackageManager::restore`], told the name the pruned directory had.
110    ///
111    /// Most managers have exactly one possible directory name and ignore this. venv does
112    /// not: it prunes any folder carrying a `pyvenv.cfg` — `venv`, `env`, `my_env` — and
113    /// without the recorded name it would rebuild the environment as `.venv`, leaving
114    /// every activate script, IDE interpreter path and Makefile pointing at nothing.
115    fn restore_named(
116        &self,
117        project_path: &Path,
118        dir_name: &str,
119        timeout: std::time::Duration,
120    ) -> Result<()> {
121        let _ = dir_name;
122        self.restore(project_path, timeout)
123    }
124
125    /// The file this manager rebuilds its bloat directory from.
126    ///
127    /// Two callers. Conflict resolution breaks ties between managers that share a bloat
128    /// directory — npm, pnpm, yarn and bun all own the same `node_modules` — by comparing
129    /// these files' timestamps. `devp doctor` names them, because a missing one is the
130    /// most common reason a project is not pruneable.
131    ///
132    /// More than one entry means the manager accepts any of them (bun's binary and text
133    /// lockfiles). An empty slice means the manager has no single file to point at.
134    fn lockfiles(&self) -> &'static [&'static str] {
135        &[]
136    }
137
138    /// Installed-but-unrecorded packages, as data instead of a refusal.
139    ///
140    /// The same comparison [`PackageManager::enforce_lockfile`] refuses a prune on,
141    /// surfaced early so `devp status --drift` can point at the problem before a prune
142    /// is ever attempted. Runs nothing and writes nothing. An empty answer means
143    /// "nothing detected", not "proven clean" — most managers have no cheap way to
144    /// compare and say nothing here.
145    fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
146        let _ = project_path;
147        Vec::new()
148    }
149
150    /// Whether this adapter is inert until the user enables it in settings.
151    ///
152    /// Build-tool adapters (gradle, maven) answer `true`: their directories come back
153    /// by recompiling the project, which costs far more than a dependency reinstall,
154    /// so nobody should find them deleted without having asked. The engine also holds
155    /// them to the longer `build_idle_days` idle window.
156    fn opt_in(&self) -> bool {
157        false
158    }
159}
160
161/// Adapters that all manage `node_modules` and therefore cannot coexist.
162const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
163
164/// Bookkeeping files that each JavaScript manager writes into `node_modules` when it
165/// installs. Finding one identifies the manager that actually produced the tree on
166/// disk, which is stronger evidence than a lockfile's timestamp.
167///
168/// pnpm and yarn are checked before npm: a project migrated away from npm can still
169/// carry npm's `.package-lock.json` inside a tree the new manager rebuilt around it.
170/// Bun has no marker we rely on, so a bun conflict falls through to the later rules.
171const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
172    ("pnpm", &[".pnpm", ".modules.yaml"]),
173    ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
174    ("npm", &[".package-lock.json"]),
175];
176
177/// Returns all registered package manager adapters.
178///
179/// To add a new adapter, create your struct and add it to this list.
180pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
181    vec![
182        Box::new(npm::Npm),
183        Box::new(pnpm::Pnpm),
184        Box::new(yarn::Yarn),
185        Box::new(bun::Bun),
186        Box::new(uv::Uv),
187        Box::new(poetry::Poetry),
188        Box::new(venv::Venv),
189        Box::new(cargo_adapter::Cargo),
190        Box::new(go::Go),
191        Box::new(gradle::Gradle),
192        Box::new(maven::Maven),
193    ]
194}
195
196/// The names of the opt-in adapters the user has switched on, resolved once per
197/// process from the registry settings.
198///
199/// Resolved here rather than threaded through every caller because `detect_adapters`
200/// is the single funnel every command discovers projects through — gating detection
201/// makes a disabled adapter invisible everywhere at once (status, stats, run, doctor),
202/// instead of visible in one view and inert in another.
203fn opt_in_enabled() -> &'static [String] {
204    static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
205    ENABLED.get_or_init(|| {
206        crate::config::Registry::load()
207            .map(|r| {
208                let mut names = Vec::new();
209                if r.settings.enable_gradle {
210                    names.push("gradle".to_string());
211                }
212                if r.settings.enable_maven {
213                    names.push("maven".to_string());
214                }
215                names
216            })
217            .unwrap_or_default()
218    })
219}
220
221/// Detect which adapters apply to a given project directory.
222///
223/// Several adapters detecting at once is normal and supported — a directory holding
224/// `package-lock.json`, `uv.lock` and `Cargo.toml` legitimately has three managers,
225/// each owning a different bloat directory. Adapters that would fight over the *same*
226/// directory are reduced to one first; see [`resolve_conflicts`].
227pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
228    let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
229        .into_iter()
230        .filter(|adapter| !adapter.opt_in() || opt_in_enabled().iter().any(|n| n == adapter.name()))
231        .filter(|adapter| adapter.detect(project_path))
232        .collect();
233    resolve_conflicts(project_path, &mut detected);
234    detected
235}
236
237/// Reduce every set of adapters that shares a bloat directory down to a single owner.
238fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
239    resolve_js_conflict(project_path, detected);
240    resolve_python_conflict(project_path, detected);
241}
242
243/// Reduce several JavaScript managers claiming the same `node_modules` down to one.
244///
245/// A directory carrying more than one JS lockfile is usually a half-finished migration
246/// or a stray file nobody deleted. Running the wrong manager's `enforce_lockfile` would
247/// rewrite a lockfile the project does not use, so pick deliberately, strongest signal
248/// first:
249///
250/// 1. The `packageManager` field of `package.json` — the maintainers said so outright.
251/// 2. The bookkeeping files inside `node_modules` — whoever built the tree we are about
252///    to delete is the manager whose lockfile has to be able to rebuild it.
253/// 3. The most recently written lockfile — a last resort when nothing else distinguishes
254///    them.
255fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
256    if detected
257        .iter()
258        .filter(|a| JS_MANAGERS.contains(&a.name()))
259        .count()
260        < 2
261    {
262        return;
263    }
264
265    let winner = declared_package_manager(project_path)
266        .filter(|name| detected.iter().any(|a| a.name() == name))
267        .or_else(|| installed_manager(project_path, detected))
268        .or_else(|| newest_lockfile_owner(project_path, detected));
269
270    let Some(winner) = winner else { return };
271    detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
272}
273
274/// Give uv sole ownership of the Python environment whenever it applies.
275///
276/// uv and the plain-venv adapter both point at the same virtual environment directory.
277/// uv is the more capable of the two — it has a real lockfile and can rebuild the
278/// environment exactly — so it takes the project whenever it recognises one, and the
279/// `requirements.txt` + `pyvenv.cfg` adapter picks up everything else.
280fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
281    if detected.iter().any(|a| a.name() == "uv") {
282        detected.retain(|a| a.name() != "venv");
283    }
284    // uv and poetry both claim `.venv`. When both detect — usually a half-finished
285    // migration — the one whose lockfile actually exists is the one that built the tree;
286    // with both or neither on disk, uv keeps the tie by `get_all_adapters()` order.
287    let uv_detected = detected.iter().any(|a| a.name() == "uv");
288    let poetry_detected = detected.iter().any(|a| a.name() == "poetry");
289    if uv_detected && poetry_detected {
290        let loser = if !project_path.join("uv.lock").exists()
291            && project_path.join("poetry.lock").exists()
292        {
293            "uv"
294        } else {
295            "poetry"
296        };
297        detected.retain(|a| a.name() != loser);
298    }
299}
300
301/// The manager that actually installed the `node_modules` tree currently on disk.
302fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
303    let node_modules = project_path.join("node_modules");
304    if !node_modules.is_dir() {
305        return None;
306    }
307
308    JS_INSTALL_MARKERS
309        .iter()
310        .find(|(name, markers)| {
311            detected.iter().any(|a| a.name() == *name)
312                && markers.iter().any(|m| node_modules.join(m).exists())
313        })
314        .map(|(name, _)| (*name).to_string())
315}
316
317/// Read the Corepack `packageManager` field (e.g. `"pnpm@9.1.0"`) from `package.json`.
318fn declared_package_manager(project_path: &Path) -> Option<String> {
319    let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
320    let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
321    let declared = json.get("packageManager")?.as_str()?;
322    let name = declared.split('@').next().unwrap_or_default();
323    JS_MANAGERS
324        .iter()
325        .find(|m| **m == name)
326        .map(|m| (*m).to_string())
327}
328
329/// The detected JS manager whose lockfile has the most recent modification time.
330fn newest_lockfile_owner(
331    project_path: &Path,
332    detected: &[Box<dyn PackageManager>],
333) -> Option<String> {
334    detected
335        .iter()
336        .filter(|a| JS_MANAGERS.contains(&a.name()))
337        .filter_map(|a| {
338            let newest = a
339                .lockfiles()
340                .iter()
341                .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
342                .max()?;
343            Some((newest, a.name().to_string()))
344        })
345        // Ties keep the earlier adapter in `get_all_adapters()` order, so the choice is
346        // deterministic when two lockfiles share a timestamp.
347        .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
348            match best {
349                Some(b) if b.0 >= cur.0 => Some(b),
350                _ => Some(cur),
351            }
352        })
353        .map(|(_, name)| name)
354}
355
356/// Calculate the total size of a directory recursively (in bytes).
357pub fn dir_size(path: &Path) -> u64 {
358    if !path.exists() {
359        return 0;
360    }
361    WalkDir::new(path)
362        .follow_links(false)
363        .into_iter()
364        .flatten()
365        .filter_map(|entry| entry.metadata().ok())
366        .filter(|meta| meta.is_file())
367        .map(|meta| meta.len())
368        .sum()
369}
370
371/// A directory's size split by what deleting it would actually free.
372#[derive(Debug, Clone, Copy, Default)]
373pub struct DirSizeBreakdown {
374    /// Bytes `remove_dir_all` gives back to the disk.
375    pub freed_bytes: u64,
376    /// Bytes that survive the deletion because a hardlink outside the directory —
377    /// for pnpm and bun, the global store — still points at them.
378    pub shared_bytes: u64,
379}
380
381/// [`dir_size`], but hardlink-aware.
382///
383/// pnpm and bun do not copy packages into `node_modules`; they hardlink them from a
384/// machine-wide store, so summing file sizes counts bytes the store keeps after the
385/// delete and promises space a prune cannot deliver. Here a physical file is counted
386/// once no matter how many names it has inside the tree, and counts as freed only
387/// when every one of its links is inside the tree. A store that fell back to copying
388/// — a different volume, a filesystem without hardlinks — leaves the link count at
389/// one, so copied installs still count in full. A file whose link count cannot be
390/// read is counted as freed, which errs toward the plain [`dir_size`] figure.
391pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
392    let mut out = DirSizeBreakdown::default();
393    if !path.exists() {
394        return out;
395    }
396    // (volume, file id) → (bytes, links on disk, links seen inside this walk)
397    let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
398    for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
399        let Ok(meta) = entry.metadata() else { continue };
400        if !meta.is_file() {
401            continue;
402        }
403        match file_link_identity(entry.path(), &meta) {
404            Some((dev, ino, nlink)) if nlink > 1 => {
405                linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
406            }
407            _ => out.freed_bytes += meta.len(),
408        }
409    }
410    for (bytes, nlink, seen) in linked.into_values() {
411        if seen >= nlink {
412            out.freed_bytes += bytes;
413        } else {
414            out.shared_bytes += bytes;
415        }
416    }
417    out
418}
419
420/// (volume, file id, hardlink count) for one file, where the platform can say.
421#[cfg(unix)]
422fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
423    use std::os::unix::fs::MetadataExt as _;
424    Some((meta.dev(), meta.ino(), meta.nlink()))
425}
426
427/// Windows keeps the link count behind an opened handle, not in the directory entry
428/// (std exposes it only on an unstable feature), so this costs one metadata-only open
429/// per file. Only the adapters that actually hardlink — pnpm and bun — pay it.
430#[cfg(windows)]
431fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
432    use std::os::windows::fs::OpenOptionsExt as _;
433    use std::os::windows::io::AsRawHandle as _;
434    use windows_sys::Win32::Storage::FileSystem::{
435        BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
436    };
437
438    // access_mode(0) asks for attribute access only, so a file another process holds
439    // open without read sharing — an antivirus scan, an editor — does not fail here.
440    let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
441    let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
442    // SAFETY: `file` keeps the handle open for the whole call, and `info` is a
443    // plain-data out-parameter the API fills before returning.
444    if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
445        return None;
446    }
447    Some((
448        u64::from(info.dwVolumeSerialNumber),
449        (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
450        u64::from(info.nNumberOfLinks),
451    ))
452}
453
454#[cfg(not(any(unix, windows)))]
455fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
456    None
457}
458
459/// Resolve a program name into something `Command::new` can actually spawn.
460///
461/// On Windows the JS package managers (`npm`, `pnpm`, `yarn`, `bun`) are shipped as
462/// `.cmd` shims. `CreateProcess` only ever appends `.exe`, so `Command::new("npm")`
463/// fails with `NotFound` even when npm is installed and on `PATH`. Search `PATH`
464/// ourselves for the shim extensions and hand back the full path.
465///
466/// Names that already contain a path separator (e.g. `.venv\Scripts\python.exe`)
467/// are returned unchanged, as are all names on non-Windows platforms.
468pub fn resolve_program(program: &str) -> String {
469    #[cfg(windows)]
470    {
471        if Path::new(program).components().count() > 1 {
472            return program.to_string();
473        }
474        let Some(path_var) = std::env::var_os("PATH") else {
475            return program.to_string();
476        };
477        for dir in std::env::split_paths(&path_var) {
478            for ext in ["exe", "cmd", "bat"] {
479                let candidate = dir.join(format!("{program}.{ext}"));
480                if candidate.is_file() {
481                    return candidate.to_string_lossy().into_owned();
482                }
483            }
484        }
485    }
486    program.to_string()
487}
488
489/// Check whether a package manager binary is present and runnable.
490///
491/// Answers are cached for the life of the process. Every adapter asks this before it
492/// enforces a lockfile, so a monorepo with ten projects on the same manager otherwise
493/// pays for ten `npm --version` process spawns — around half a second each on Windows —
494/// to learn the same fact ten times. A run is short-lived, so nothing installed or
495/// removed mid-run can be missed for long.
496pub fn binary_available(program: &str) -> bool {
497    static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
498    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
499
500    // Held across the probe on purpose: two threads asking about the same missing
501    // binary should spawn one process, not two. Nothing else takes this lock.
502    let mut guard = match cache.lock() {
503        Ok(g) => g,
504        // A poisoned lock only means some other thread panicked mid-probe; the answer
505        // is still worth having, so fall back to probing without the cache.
506        Err(_) => return probe_binary(program),
507    };
508    if let Some(known) = guard.get(program) {
509        return *known;
510    }
511    let available = probe_binary(program);
512    guard.insert(program.to_string(), available);
513    available
514}
515
516/// The actual `<program> --version` spawn behind [`binary_available`].
517fn probe_binary(program: &str) -> bool {
518    crate::spawn::command(resolve_program(program))
519        .arg("--version")
520        .stdin(std::process::Stdio::null())
521        .output()
522        .map(|o| o.status.success())
523        .unwrap_or(false)
524}
525
526/// The exit status and drained pipes of a finished command.
527struct CommandOutput {
528    status: std::process::ExitStatus,
529    stdout: String,
530    stderr: String,
531}
532
533/// Spawn a command, drain both of its pipes and wait for it, bounded by `timeout`.
534///
535/// Shared by the two public wrappers below. `devp caches` needs a command's *output* —
536/// `npm config get cache` answers a question rather than performing an action — and a
537/// second copy of the draining and polling below would be a second place for the
538/// deadlock it exists to prevent to come back.
539fn spawn_capture(
540    program: &str,
541    args: &[&str],
542    cwd: &Path,
543    timeout: std::time::Duration,
544) -> Result<CommandOutput> {
545    use std::io::Read;
546    use std::process::Stdio;
547    use std::thread;
548    use std::time::Instant;
549
550    let resolved = resolve_program(program);
551    let mut child = crate::spawn::command(&resolved)
552        .args(args)
553        .current_dir(cwd)
554        .stdin(Stdio::null())
555        .stdout(Stdio::piped())
556        .stderr(Stdio::piped())
557        .spawn()
558        .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
559
560    // Drain both pipes on their own threads. A package manager easily emits more
561    // than the ~64 KiB OS pipe buffer; if nobody reads it the child blocks on write
562    // and never exits, which would turn every large install into a timeout kill.
563    let mut stdout_pipe = child.stdout.take();
564    let mut stderr_pipe = child.stderr.take();
565    let stdout_reader = thread::spawn(move || {
566        let mut buf = Vec::new();
567        if let Some(pipe) = stdout_pipe.as_mut() {
568            let _ = pipe.read_to_end(&mut buf);
569        }
570        buf
571    });
572    let stderr_reader = thread::spawn(move || {
573        let mut buf = Vec::new();
574        if let Some(pipe) = stderr_pipe.as_mut() {
575            let _ = pipe.read_to_end(&mut buf);
576        }
577        buf
578    });
579
580    let start = Instant::now();
581    let status = loop {
582        match child.try_wait()? {
583            Some(status) => break status,
584            None => {
585                if start.elapsed() >= timeout {
586                    let _ = child.kill();
587                    let _ = child.wait();
588                    anyhow::bail!(
589                        "Command timed out after {}s: {} {}\n\
590                         To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
591                        timeout.as_secs(),
592                        program,
593                        args.join(" ")
594                    );
595                }
596                thread::sleep(std::time::Duration::from_millis(100));
597            }
598        }
599    };
600
601    let stderr = stderr_reader
602        .join()
603        .map(|b| String::from_utf8_lossy(&b).into_owned())
604        .unwrap_or_default();
605    let stdout = stdout_reader
606        .join()
607        .map(|b| String::from_utf8_lossy(&b).into_owned())
608        .unwrap_or_default();
609
610    Ok(CommandOutput {
611        status,
612        stdout,
613        stderr,
614    })
615}
616
617/// Helper: run a command with a configurable timeout.
618pub fn run_command_with_timeout(
619    program: &str,
620    args: &[&str],
621    cwd: &Path,
622    timeout: std::time::Duration,
623) -> Result<()> {
624    let out = spawn_capture(program, args, cwd, timeout)?;
625    if out.status.success() {
626        Ok(())
627    } else {
628        anyhow::bail!(
629            "{} {} failed (exit code {:?}):\n{}",
630            program,
631            args.join(" "),
632            out.status.code(),
633            out.stderr.trim()
634        )
635    }
636}
637
638/// Run a command and hand back what it printed on stdout, bounded by `timeout`.
639///
640/// For commands that answer a question instead of doing work. A non-zero exit is an
641/// error like anywhere else, so a caller never mistakes an error message on stderr for
642/// the answer it asked for.
643pub fn capture_command_with_timeout(
644    program: &str,
645    args: &[&str],
646    cwd: &Path,
647    timeout: std::time::Duration,
648) -> Result<String> {
649    let out = spawn_capture(program, args, cwd, timeout)?;
650    if out.status.success() {
651        Ok(out.stdout)
652    } else {
653        anyhow::bail!(
654            "{} {} failed (exit code {:?}):\n{}",
655            program,
656            args.join(" "),
657            out.status.code(),
658            out.stderr.trim()
659        )
660    }
661}
662
663/// Helper: attempt a command but return `true`/`false` instead of `Err`.
664pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
665    crate::spawn::command(resolve_program(program))
666        .args(args)
667        .current_dir(cwd)
668        .stdin(std::process::Stdio::null())
669        .output()
670        .map(|o| o.status.success())
671        .unwrap_or(false)
672}
673
674/// How much newer the manifest must be than the lockfile before
675/// [`refuse_if_manifest_newer`] calls it drift.
676///
677/// A clone or checkout writes both files within moments of each other, in whichever
678/// order the tree walk happens to visit them — a strict comparison would refuse half of
679/// all fresh clones. A hand edit that never got a lockfile sync is separated by minutes
680/// or days, which a minute of tolerance still catches.
681const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
682
683/// When the package manager is missing, a lockfile is only proof if the manifest has
684/// not been edited since it was written.
685///
686/// With the binary present the verify command answers this properly; without it, mtimes
687/// are the only signal there is. The manifest is inferred from the lockfile's file
688/// name; an unrecognised name changes nothing.
689fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
690    let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
691        Some("Cargo.lock") => "Cargo.toml",
692        Some("package-lock.json")
693        | Some("yarn.lock")
694        | Some("pnpm-lock.yaml")
695        | Some("bun.lockb")
696        | Some("bun.lock") => "package.json",
697        Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
698        Some("go.sum") => "go.mod",
699        Some("composer.lock") => "composer.json",
700        _ => return Ok(()),
701    };
702    let manifest = cwd.join(manifest_name);
703    let (Ok(manifest_meta), Ok(lock_meta)) =
704        (std::fs::metadata(&manifest), std::fs::metadata(lockfile))
705    else {
706        return Ok(());
707    };
708    if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
709        && manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
710    {
711        anyhow::bail!(
712            "`{program}` is not available, and `{manifest_name}` has been edited more \
713                 recently than `{}` — the lockfile may no longer record the current \
714                 dependencies, and without `{program}` that cannot be verified. Install \
715                 {program} and run its lockfile sync, then prune again.",
716            lockfile.display()
717        );
718    }
719    Ok(())
720}
721
722/// Two-tier lockfile enforcement with configurable timeout.
723pub fn lock_sync_or_verify_with_timeout(
724    lockfile: &Path,
725    program: &str,
726    sync_args: &[&str],
727    cwd: &Path,
728    timeout: std::time::Duration,
729) -> Result<()> {
730    let lockfile_exists = lockfile.exists();
731
732    if !binary_available(program) {
733        if lockfile_exists {
734            refuse_if_manifest_newer(lockfile, program, cwd)?;
735            return Ok(());
736        } else {
737            anyhow::bail!(
738                "`{program}` is not available and no lockfile was found at `{}`. \
739                 Cannot safely delete dependencies — install {program} first, \
740                 or commit a lockfile.",
741                lockfile.display()
742            );
743        }
744    }
745
746    // Binary is available — run the sync with timeout.
747    run_command_with_timeout(program, sync_args, cwd, timeout)
748}
749
750/// What an adapter is allowed to do while enforcing a lockfile on this pass.
751///
752/// The two things that used to be hardcoded per adapter, and were wrong in both places:
753/// every adapter burned the compiled-in timeout regardless of `command_timeout_secs`,
754/// and only cargo and go consulted `allow_manifest_rewrite`.
755#[derive(Debug, Clone, Copy)]
756pub struct EnforcePolicy {
757    /// Whether a sync command that writes files Git tracks may run anyway.
758    ///
759    /// The user's `allow_manifest_rewrite`. Off by default: a prune pass can come from
760    /// the scheduler, and a background process that leaves a dirty working tree behind
761    /// is a surprise no matter which file it wrote.
762    pub allow_rewrite: bool,
763    /// Ceiling on any one package-manager command — the user's `command_timeout_secs`.
764    pub timeout: std::time::Duration,
765}
766
767impl Default for EnforcePolicy {
768    fn default() -> Self {
769        Self {
770            allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
771            timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
772        }
773    }
774}
775
776impl EnforcePolicy {
777    /// A policy from the user's own settings.
778    pub fn from_settings(settings: &crate::config::Settings) -> Self {
779        Self {
780            allow_rewrite: settings.allow_manifest_rewrite,
781            timeout: std::time::Duration::from_secs(settings.command_timeout_secs),
782        }
783    }
784}
785
786/// The one rule every adapter enforces, given the manager's two spellings of the check.
787///
788/// - lockfile present → `verify_args`, which resolves the graph against the lockfile and
789///   **fails** rather than writing when the two have drifted apart
790/// - lockfile absent → `write_args`, because `restore` needs a lockfile to exist at all
791///   and there is nothing there to preserve
792/// - `allow_rewrite` → `write_args` either way: the informed opt-in, for the user who
793///   would rather have a stale lockfile refreshed than have the prune refused
794///
795/// This used to be the cargo/go rule only. Every other adapter ran its writing sync
796/// unconditionally — `npm install --package-lock-only`, `pnpm install --lockfile-only`,
797/// `uv lock` and `yarn install --mode update-lockfile` all rewrite a lockfile Git tracks
798/// when it has drifted from the manifest. That is a smaller edit than `go mod tidy`
799/// makes, but it is still an unattended pass modifying a tracked file, and it made
800/// `allow_manifest_rewrite` mean two different things depending on the ecosystem.
801pub fn enforce_two_tier(
802    lockfile: &Path,
803    program: &str,
804    verify_args: &[&str],
805    write_args: &[&str],
806    cwd: &Path,
807    policy: EnforcePolicy,
808) -> Result<()> {
809    if policy.allow_rewrite {
810        return lock_sync_or_verify_with_timeout(
811            lockfile,
812            program,
813            write_args,
814            cwd,
815            policy.timeout,
816        );
817    }
818    lock_verify_or_generate(
819        lockfile,
820        program,
821        verify_args,
822        write_args,
823        cwd,
824        policy.timeout,
825    )
826}
827
828/// Lockfile enforcement for ecosystems whose "sync" command rewrites source manifests.
829///
830/// `cargo generate-lockfile` re-resolves every dependency and overwrites `Cargo.lock`;
831/// `go mod tidy` edits both `go.mod` and `go.sum` and can drop requirements. Running
832/// either as a precondition for deletion would silently modify tracked source files,
833/// which contradicts the lockfile-safety guarantee. So:
834///
835/// - lockfile present → run the read-only `verify_args` (never writes)
836/// - lockfile absent  → run `generate_args`, since a lockfile must exist for `restore`
837pub fn lock_verify_or_generate(
838    lockfile: &Path,
839    program: &str,
840    verify_args: &[&str],
841    generate_args: &[&str],
842    cwd: &Path,
843    timeout: std::time::Duration,
844) -> Result<()> {
845    let lockfile_exists = lockfile.exists();
846
847    if !binary_available(program) {
848        if lockfile_exists {
849            refuse_if_manifest_newer(lockfile, program, cwd)?;
850            return Ok(());
851        }
852        anyhow::bail!(
853            "`{program}` is not available and no lockfile was found at `{}`. \
854             Cannot safely delete dependencies — install {program} first, \
855             or commit a lockfile.",
856            lockfile.display()
857        );
858    }
859
860    if lockfile_exists {
861        run_command_with_timeout(program, verify_args, cwd, timeout)
862    } else {
863        run_command_with_timeout(program, generate_args, cwd, timeout)
864    }
865}
866
867/// Two-tier lockfile enforcement using default timeout.
868pub fn lock_sync_or_verify(
869    lockfile: &Path,
870    program: &str,
871    sync_args: &[&str],
872    cwd: &Path,
873) -> Result<()> {
874    lock_sync_or_verify_with_timeout(
875        lockfile,
876        program,
877        sync_args,
878        cwd,
879        std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
880    )
881}
882
883/// Information describing status of a required package manager binary.
884#[derive(Debug, Clone)]
885pub struct BinaryCheckStatus {
886    pub name: String,
887    pub available: bool,
888    pub version: Option<String>,
889}
890
891/// Scan only the package manager binaries needed by candidate repos.
892pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
893    let mut unique: Vec<String> = adapter_names
894        .iter()
895        // venv restores through python, and the build-tool adapters restore by the
896        // next compile — none of them has a binary named after the adapter to probe.
897        .filter(|&n| n != "-" && n != "venv" && n != "gradle" && n != "maven")
898        .cloned()
899        .collect();
900    unique.sort();
901    unique.dedup();
902
903    unique
904        .into_iter()
905        .map(|name| {
906            let output = crate::spawn::command(resolve_program(&name))
907                .arg("--version")
908                .stdin(std::process::Stdio::null())
909                .output();
910            match output {
911                Ok(out) if out.status.success() => {
912                    let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
913                    let first_line = ver.lines().next().unwrap_or(&ver).to_string();
914                    BinaryCheckStatus {
915                        name,
916                        available: true,
917                        version: if first_line.is_empty() {
918                            None
919                        } else {
920                            Some(first_line)
921                        },
922                    }
923                }
924                _ => BinaryCheckStatus {
925                    name,
926                    available: false,
927                    version: None,
928                },
929            }
930        })
931        .collect()
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937    use std::fs;
938    use tempfile::TempDir;
939
940    #[test]
941    fn test_bloat_dir_display() {
942        let bd = BloatDir {
943            name: "node_modules".to_string(),
944            path: PathBuf::from("/test/node_modules"),
945            size_bytes: 1024,
946            shared_bytes: 0,
947        };
948        assert!(bd.to_string().contains("node_modules"));
949    }
950
951    #[test]
952    fn test_hardlink_size_counts_a_plain_file_in_full() {
953        let tmp = TempDir::new().unwrap();
954        let tree = tmp.path().join("tree");
955        fs::create_dir(&tree).unwrap();
956        fs::write(tree.join("copied.txt"), "12345").unwrap();
957        let size = dir_size_with_hardlinks(&tree);
958        assert_eq!(size.freed_bytes, 5);
959        assert_eq!(size.shared_bytes, 0);
960    }
961
962    #[test]
963    fn test_hardlink_size_excludes_a_file_the_store_keeps() {
964        // The pnpm shape: the store's copy lives outside the tree being deleted, so
965        // deleting the tree frees nothing for this file.
966        let tmp = TempDir::new().unwrap();
967        let store = tmp.path().join("store");
968        let tree = tmp.path().join("tree");
969        fs::create_dir(&store).unwrap();
970        fs::create_dir(&tree).unwrap();
971        fs::write(store.join("pkg.js"), "0123456789").unwrap();
972        fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
973        let size = dir_size_with_hardlinks(&tree);
974        assert_eq!(size.freed_bytes, 0);
975        assert_eq!(size.shared_bytes, 10);
976    }
977
978    #[test]
979    fn test_hardlink_size_counts_an_internal_pair_once() {
980        // Both names live inside the tree, so the delete removes the last link and
981        // the bytes really are freed — but only once, not per name.
982        let tmp = TempDir::new().unwrap();
983        let tree = tmp.path().join("tree");
984        fs::create_dir(&tree).unwrap();
985        fs::write(tree.join("a.js"), "abcdefg").unwrap();
986        fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
987        let size = dir_size_with_hardlinks(&tree);
988        assert_eq!(size.freed_bytes, 7);
989        assert_eq!(size.shared_bytes, 0);
990    }
991
992    #[test]
993    fn test_dir_size_empty() {
994        let tmp = TempDir::new().unwrap();
995        assert_eq!(dir_size(tmp.path()), 0);
996    }
997
998    #[test]
999    fn test_dir_size_with_files() {
1000        let tmp = TempDir::new().unwrap();
1001        fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
1002        fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
1003        assert_eq!(dir_size(tmp.path()), 11); // 5 + 6
1004    }
1005
1006    #[test]
1007    fn test_dir_size_nonexistent() {
1008        assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
1009    }
1010
1011    #[test]
1012    fn test_get_all_adapters_not_empty() {
1013        let adapters = get_all_adapters();
1014        assert!(adapters.len() >= 6);
1015    }
1016
1017    #[test]
1018    fn test_detect_adapters_npm() {
1019        let tmp = TempDir::new().unwrap();
1020        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1021        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1022        let adapters = detect_adapters(tmp.path());
1023        let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1024        assert!(names.contains(&"npm"));
1025    }
1026
1027    #[test]
1028    fn test_detect_adapters_empty_dir() {
1029        let tmp = TempDir::new().unwrap();
1030        let adapters = detect_adapters(tmp.path());
1031        assert!(adapters.is_empty());
1032    }
1033
1034    /// Names of the adapters that detect in `dir`, sorted.
1035    fn detected_names(dir: &Path) -> Vec<&'static str> {
1036        let mut names: Vec<&'static str> = detect_adapters(dir).iter().map(|a| a.name()).collect();
1037        names.sort_unstable();
1038        names
1039    }
1040
1041    #[test]
1042    fn test_detect_adapters_multiple_ecosystems_coexist() {
1043        // Different managers owning different directories must all survive detection.
1044        let tmp = TempDir::new().unwrap();
1045        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1046        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1047        fs::write(tmp.path().join("uv.lock"), "").unwrap();
1048        fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
1049        fs::write(tmp.path().join("go.mod"), "module x").unwrap();
1050
1051        assert_eq!(detected_names(tmp.path()), vec!["cargo", "go", "npm", "uv"]);
1052    }
1053
1054    #[test]
1055    fn test_js_conflict_resolved_by_package_manager_field() {
1056        let tmp = TempDir::new().unwrap();
1057        fs::write(
1058            tmp.path().join("package.json"),
1059            r#"{"packageManager":"yarn@4.1.0"}"#,
1060        )
1061        .unwrap();
1062        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1063        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1064        fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1065
1066        assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1067    }
1068
1069    #[test]
1070    fn test_js_conflict_resolved_by_what_installed_node_modules() {
1071        // npm's lockfile is written last, but the tree on disk was built by pnpm — and
1072        // that tree is what is about to be deleted.
1073        let tmp = TempDir::new().unwrap();
1074        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1075        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1076        fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
1077        std::thread::sleep(std::time::Duration::from_millis(20));
1078        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1079
1080        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1081    }
1082
1083    #[test]
1084    fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
1085        // A repo migrated npm → yarn keeps npm's hidden lockfile inside node_modules.
1086        let tmp = TempDir::new().unwrap();
1087        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1088        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1089        fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1090        let nm = tmp.path().join("node_modules");
1091        fs::create_dir_all(&nm).unwrap();
1092        fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1093        fs::write(nm.join(".yarn-state.yml"), "").unwrap();
1094
1095        assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1096    }
1097
1098    #[test]
1099    fn test_declared_package_manager_outranks_what_is_installed() {
1100        // Corepack pins the project to pnpm; the npm tree on disk is the accident.
1101        let tmp = TempDir::new().unwrap();
1102        fs::write(
1103            tmp.path().join("package.json"),
1104            r#"{"packageManager":"pnpm@9.1.0"}"#,
1105        )
1106        .unwrap();
1107        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1108        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1109        let nm = tmp.path().join("node_modules");
1110        fs::create_dir_all(&nm).unwrap();
1111        fs::write(nm.join(".package-lock.json"), "{}").unwrap();
1112
1113        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1114    }
1115
1116    #[test]
1117    fn test_uv_takes_precedence_over_plain_venv() {
1118        // A uv project declared through `[tool.uv]` alone, with a requirements.txt and a
1119        // virtual environment left over from before the migration.
1120        let tmp = TempDir::new().unwrap();
1121        fs::write(
1122            tmp.path().join("pyproject.toml"),
1123            "[project]\nname = \"x\"\n\n[tool.uv]\n",
1124        )
1125        .unwrap();
1126        fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1127        let venv = tmp.path().join(".venv");
1128        fs::create_dir_all(&venv).unwrap();
1129        fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1130
1131        assert_eq!(detected_names(tmp.path()), vec!["uv"]);
1132    }
1133
1134    #[test]
1135    fn test_plain_venv_handles_projects_uv_does_not_claim() {
1136        let tmp = TempDir::new().unwrap();
1137        fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
1138        let venv = tmp.path().join("venv");
1139        fs::create_dir_all(&venv).unwrap();
1140        fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1141
1142        assert_eq!(detected_names(tmp.path()), vec!["venv"]);
1143    }
1144
1145    #[test]
1146    fn test_js_conflict_falls_back_to_newest_lockfile() {
1147        let tmp = TempDir::new().unwrap();
1148        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1149        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1150        // Written second, and touched again, so pnpm is unambiguously the newer of the
1151        // two even on filesystems with coarse timestamp granularity.
1152        std::thread::sleep(std::time::Duration::from_millis(20));
1153        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1154
1155        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1156    }
1157
1158    #[test]
1159    fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
1160        // A `packageManager` naming something we have no adapter for must not wipe out
1161        // the detection entirely — fall through to the lockfile timestamps.
1162        let tmp = TempDir::new().unwrap();
1163        fs::write(
1164            tmp.path().join("package.json"),
1165            r#"{"packageManager":"deno@2.0.0"}"#,
1166        )
1167        .unwrap();
1168        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1169        std::thread::sleep(std::time::Duration::from_millis(20));
1170        fs::write(tmp.path().join("yarn.lock"), "").unwrap();
1171
1172        assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
1173    }
1174
1175    #[test]
1176    fn test_js_conflict_does_not_disturb_a_single_manager() {
1177        let tmp = TempDir::new().unwrap();
1178        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1179        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
1180        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
1181    }
1182
1183    #[test]
1184    fn test_js_adapters_declare_their_lockfiles() {
1185        for adapter in get_all_adapters() {
1186            if JS_MANAGERS.contains(&adapter.name()) {
1187                assert!(
1188                    !adapter.lockfiles().is_empty(),
1189                    "{} shares node_modules and must declare its lockfiles for \
1190                     conflict resolution",
1191                    adapter.name()
1192                );
1193            }
1194        }
1195    }
1196
1197    #[test]
1198    fn test_adapter_names_unique() {
1199        let adapters = get_all_adapters();
1200        let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
1201        let mut unique = names.clone();
1202        unique.sort();
1203        unique.dedup();
1204        assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
1205    }
1206}