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