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    /// Size in bytes (calculated lazily).
44    pub size_bytes: u64,
45}
46
47impl fmt::Display for BloatDir {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        write!(f, "{} ({})", self.name, self.path.display())
50    }
51}
52
53/// The core trait that every package manager adapter must implement.
54///
55/// Each adapter is responsible for:
56/// - **Detecting** whether it applies to a given project directory
57/// - **Listing** the bloat directories it manages
58/// - **Enforcing** lockfile consistency before deletion
59/// - **Restoring** dependencies from lockfiles
60pub trait PackageManager: Send + Sync {
61    /// Human-readable name for this adapter (e.g., "npm", "pnpm", "uv").
62    fn name(&self) -> &'static str;
63
64    /// Check if this adapter applies to the given project directory.
65    ///
66    /// Typically checks for the presence of a specific lockfile or config file.
67    fn detect(&self, project_path: &Path) -> bool;
68
69    /// List all bloat directories this adapter manages in the given project.
70    ///
71    /// Only returns directories that actually exist on disk.
72    fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
73
74    /// Prove the lockfile can rebuild what is about to be deleted.
75    ///
76    /// This is a **safety-critical** method. It MUST succeed before any bloat
77    /// directory is deleted. If this fails, deletion for this adapter is aborted.
78    ///
79    /// See [`EnforcePolicy`] for the one rule every adapter follows.
80    fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
81
82    /// Restore dependencies from the lockfile (for `dev-prune restore`).
83    fn restore(&self, project_path: &Path) -> Result<()>;
84
85    /// The file this manager rebuilds its bloat directory from.
86    ///
87    /// Two callers. Conflict resolution breaks ties between managers that share a bloat
88    /// directory — npm, pnpm, yarn and bun all own the same `node_modules` — by comparing
89    /// these files' timestamps. `devp doctor` names them, because a missing one is the
90    /// most common reason a project is not pruneable.
91    ///
92    /// More than one entry means the manager accepts any of them (bun's binary and text
93    /// lockfiles). An empty slice means the manager has no single file to point at.
94    fn lockfiles(&self) -> &'static [&'static str] {
95        &[]
96    }
97}
98
99/// Adapters that all manage `node_modules` and therefore cannot coexist.
100const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
101
102/// Bookkeeping files that each JavaScript manager writes into `node_modules` when it
103/// installs. Finding one identifies the manager that actually produced the tree on
104/// disk, which is stronger evidence than a lockfile's timestamp.
105///
106/// pnpm and yarn are checked before npm: a project migrated away from npm can still
107/// carry npm's `.package-lock.json` inside a tree the new manager rebuilt around it.
108/// Bun has no marker we rely on, so a bun conflict falls through to the later rules.
109const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
110    ("pnpm", &[".pnpm", ".modules.yaml"]),
111    ("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
112    ("npm", &[".package-lock.json"]),
113];
114
115/// Returns all registered package manager adapters.
116///
117/// To add a new adapter, create your struct and add it to this list.
118pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
119    vec![
120        Box::new(npm::Npm),
121        Box::new(pnpm::Pnpm),
122        Box::new(yarn::Yarn),
123        Box::new(bun::Bun),
124        Box::new(uv::Uv),
125        Box::new(venv::Venv),
126        Box::new(cargo_adapter::Cargo),
127        Box::new(go::Go),
128    ]
129}
130
131/// Detect which adapters apply to a given project directory.
132///
133/// Several adapters detecting at once is normal and supported — a directory holding
134/// `package-lock.json`, `uv.lock` and `Cargo.toml` legitimately has three managers,
135/// each owning a different bloat directory. Adapters that would fight over the *same*
136/// directory are reduced to one first; see [`resolve_conflicts`].
137pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
138    let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
139        .into_iter()
140        .filter(|adapter| adapter.detect(project_path))
141        .collect();
142    resolve_conflicts(project_path, &mut detected);
143    detected
144}
145
146/// Reduce every set of adapters that shares a bloat directory down to a single owner.
147fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
148    resolve_js_conflict(project_path, detected);
149    resolve_python_conflict(detected);
150}
151
152/// Reduce several JavaScript managers claiming the same `node_modules` down to one.
153///
154/// A directory carrying more than one JS lockfile is usually a half-finished migration
155/// or a stray file nobody deleted. Running the wrong manager's `enforce_lockfile` would
156/// rewrite a lockfile the project does not use, so pick deliberately, strongest signal
157/// first:
158///
159/// 1. The `packageManager` field of `package.json` — the maintainers said so outright.
160/// 2. The bookkeeping files inside `node_modules` — whoever built the tree we are about
161///    to delete is the manager whose lockfile has to be able to rebuild it.
162/// 3. The most recently written lockfile — a last resort when nothing else distinguishes
163///    them.
164fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
165    if detected
166        .iter()
167        .filter(|a| JS_MANAGERS.contains(&a.name()))
168        .count()
169        < 2
170    {
171        return;
172    }
173
174    let winner = declared_package_manager(project_path)
175        .filter(|name| detected.iter().any(|a| a.name() == name))
176        .or_else(|| installed_manager(project_path, detected))
177        .or_else(|| newest_lockfile_owner(project_path, detected));
178
179    let Some(winner) = winner else { return };
180    detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
181}
182
183/// Give uv sole ownership of the Python environment whenever it applies.
184///
185/// uv and the plain-venv adapter both point at the same virtual environment directory.
186/// uv is the more capable of the two — it has a real lockfile and can rebuild the
187/// environment exactly — so it takes the project whenever it recognises one, and the
188/// `requirements.txt` + `pyvenv.cfg` adapter picks up everything else.
189fn resolve_python_conflict(detected: &mut Vec<Box<dyn PackageManager>>) {
190    if detected.iter().any(|a| a.name() == "uv") {
191        detected.retain(|a| a.name() != "venv");
192    }
193}
194
195/// The manager that actually installed the `node_modules` tree currently on disk.
196fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
197    let node_modules = project_path.join("node_modules");
198    if !node_modules.is_dir() {
199        return None;
200    }
201
202    JS_INSTALL_MARKERS
203        .iter()
204        .find(|(name, markers)| {
205            detected.iter().any(|a| a.name() == *name)
206                && markers.iter().any(|m| node_modules.join(m).exists())
207        })
208        .map(|(name, _)| (*name).to_string())
209}
210
211/// Read the Corepack `packageManager` field (e.g. `"pnpm@9.1.0"`) from `package.json`.
212fn declared_package_manager(project_path: &Path) -> Option<String> {
213    let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
214    let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
215    let declared = json.get("packageManager")?.as_str()?;
216    let name = declared.split('@').next().unwrap_or_default();
217    JS_MANAGERS
218        .iter()
219        .find(|m| **m == name)
220        .map(|m| (*m).to_string())
221}
222
223/// The detected JS manager whose lockfile has the most recent modification time.
224fn newest_lockfile_owner(
225    project_path: &Path,
226    detected: &[Box<dyn PackageManager>],
227) -> Option<String> {
228    detected
229        .iter()
230        .filter(|a| JS_MANAGERS.contains(&a.name()))
231        .filter_map(|a| {
232            let newest = a
233                .lockfiles()
234                .iter()
235                .filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
236                .max()?;
237            Some((newest, a.name().to_string()))
238        })
239        // Ties keep the earlier adapter in `get_all_adapters()` order, so the choice is
240        // deterministic when two lockfiles share a timestamp.
241        .fold(None::<(std::time::SystemTime, String)>, |best, cur| {
242            match best {
243                Some(b) if b.0 >= cur.0 => Some(b),
244                _ => Some(cur),
245            }
246        })
247        .map(|(_, name)| name)
248}
249
250/// Calculate the total size of a directory recursively (in bytes).
251pub fn dir_size(path: &Path) -> u64 {
252    if !path.exists() {
253        return 0;
254    }
255    WalkDir::new(path)
256        .follow_links(false)
257        .into_iter()
258        .flatten()
259        .filter_map(|entry| entry.metadata().ok())
260        .filter(|meta| meta.is_file())
261        .map(|meta| meta.len())
262        .sum()
263}
264
265/// Resolve a program name into something `Command::new` can actually spawn.
266///
267/// On Windows the JS package managers (`npm`, `pnpm`, `yarn`, `bun`) are shipped as
268/// `.cmd` shims. `CreateProcess` only ever appends `.exe`, so `Command::new("npm")`
269/// fails with `NotFound` even when npm is installed and on `PATH`. Search `PATH`
270/// ourselves for the shim extensions and hand back the full path.
271///
272/// Names that already contain a path separator (e.g. `.venv\Scripts\python.exe`)
273/// are returned unchanged, as are all names on non-Windows platforms.
274pub fn resolve_program(program: &str) -> String {
275    #[cfg(windows)]
276    {
277        if Path::new(program).components().count() > 1 {
278            return program.to_string();
279        }
280        let Some(path_var) = std::env::var_os("PATH") else {
281            return program.to_string();
282        };
283        for dir in std::env::split_paths(&path_var) {
284            for ext in ["exe", "cmd", "bat"] {
285                let candidate = dir.join(format!("{program}.{ext}"));
286                if candidate.is_file() {
287                    return candidate.to_string_lossy().into_owned();
288                }
289            }
290        }
291    }
292    program.to_string()
293}
294
295/// Check whether a package manager binary is present and runnable.
296///
297/// Answers are cached for the life of the process. Every adapter asks this before it
298/// enforces a lockfile, so a monorepo with ten projects on the same manager otherwise
299/// pays for ten `npm --version` process spawns — around half a second each on Windows —
300/// to learn the same fact ten times. A run is short-lived, so nothing installed or
301/// removed mid-run can be missed for long.
302pub fn binary_available(program: &str) -> bool {
303    static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
304    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
305
306    // Held across the probe on purpose: two threads asking about the same missing
307    // binary should spawn one process, not two. Nothing else takes this lock.
308    let mut guard = match cache.lock() {
309        Ok(g) => g,
310        // A poisoned lock only means some other thread panicked mid-probe; the answer
311        // is still worth having, so fall back to probing without the cache.
312        Err(_) => return probe_binary(program),
313    };
314    if let Some(known) = guard.get(program) {
315        return *known;
316    }
317    let available = probe_binary(program);
318    guard.insert(program.to_string(), available);
319    available
320}
321
322/// The actual `<program> --version` spawn behind [`binary_available`].
323fn probe_binary(program: &str) -> bool {
324    std::process::Command::new(resolve_program(program))
325        .arg("--version")
326        .stdin(std::process::Stdio::null())
327        .output()
328        .map(|o| o.status.success())
329        .unwrap_or(false)
330}
331
332/// The exit status and drained pipes of a finished command.
333struct CommandOutput {
334    status: std::process::ExitStatus,
335    stdout: String,
336    stderr: String,
337}
338
339/// Spawn a command, drain both of its pipes and wait for it, bounded by `timeout`.
340///
341/// Shared by the two public wrappers below. `devp caches` needs a command's *output* —
342/// `npm config get cache` answers a question rather than performing an action — and a
343/// second copy of the draining and polling below would be a second place for the
344/// deadlock it exists to prevent to come back.
345fn spawn_capture(
346    program: &str,
347    args: &[&str],
348    cwd: &Path,
349    timeout: std::time::Duration,
350) -> Result<CommandOutput> {
351    use std::io::Read;
352    use std::process::{Command, Stdio};
353    use std::thread;
354    use std::time::Instant;
355
356    let resolved = resolve_program(program);
357    let mut child = Command::new(&resolved)
358        .args(args)
359        .current_dir(cwd)
360        .stdin(Stdio::null())
361        .stdout(Stdio::piped())
362        .stderr(Stdio::piped())
363        .spawn()
364        .with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
365
366    // Drain both pipes on their own threads. A package manager easily emits more
367    // than the ~64 KiB OS pipe buffer; if nobody reads it the child blocks on write
368    // and never exits, which would turn every large install into a timeout kill.
369    let mut stdout_pipe = child.stdout.take();
370    let mut stderr_pipe = child.stderr.take();
371    let stdout_reader = thread::spawn(move || {
372        let mut buf = Vec::new();
373        if let Some(pipe) = stdout_pipe.as_mut() {
374            let _ = pipe.read_to_end(&mut buf);
375        }
376        buf
377    });
378    let stderr_reader = thread::spawn(move || {
379        let mut buf = Vec::new();
380        if let Some(pipe) = stderr_pipe.as_mut() {
381            let _ = pipe.read_to_end(&mut buf);
382        }
383        buf
384    });
385
386    let start = Instant::now();
387    let status = loop {
388        match child.try_wait()? {
389            Some(status) => break status,
390            None => {
391                if start.elapsed() >= timeout {
392                    let _ = child.kill();
393                    let _ = child.wait();
394                    anyhow::bail!(
395                        "Command timed out after {}s: {} {}\n\
396                         To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
397                        timeout.as_secs(),
398                        program,
399                        args.join(" ")
400                    );
401                }
402                thread::sleep(std::time::Duration::from_millis(100));
403            }
404        }
405    };
406
407    let stderr = stderr_reader
408        .join()
409        .map(|b| String::from_utf8_lossy(&b).into_owned())
410        .unwrap_or_default();
411    let stdout = stdout_reader
412        .join()
413        .map(|b| String::from_utf8_lossy(&b).into_owned())
414        .unwrap_or_default();
415
416    Ok(CommandOutput {
417        status,
418        stdout,
419        stderr,
420    })
421}
422
423/// Helper: run a command with a configurable timeout.
424pub fn run_command_with_timeout(
425    program: &str,
426    args: &[&str],
427    cwd: &Path,
428    timeout: std::time::Duration,
429) -> Result<()> {
430    let out = spawn_capture(program, args, cwd, timeout)?;
431    if out.status.success() {
432        Ok(())
433    } else {
434        anyhow::bail!(
435            "{} {} failed (exit code {:?}):\n{}",
436            program,
437            args.join(" "),
438            out.status.code(),
439            out.stderr.trim()
440        )
441    }
442}
443
444/// Run a command and hand back what it printed on stdout, bounded by `timeout`.
445///
446/// For commands that answer a question instead of doing work. A non-zero exit is an
447/// error like anywhere else, so a caller never mistakes an error message on stderr for
448/// the answer it asked for.
449pub fn capture_command_with_timeout(
450    program: &str,
451    args: &[&str],
452    cwd: &Path,
453    timeout: std::time::Duration,
454) -> Result<String> {
455    let out = spawn_capture(program, args, cwd, timeout)?;
456    if out.status.success() {
457        Ok(out.stdout)
458    } else {
459        anyhow::bail!(
460            "{} {} failed (exit code {:?}):\n{}",
461            program,
462            args.join(" "),
463            out.status.code(),
464            out.stderr.trim()
465        )
466    }
467}
468
469/// Helper: run a command and return success/failure.
470pub fn run_command(program: &str, args: &[&str], cwd: &Path) -> Result<()> {
471    run_command_with_timeout(
472        program,
473        args,
474        cwd,
475        std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
476    )
477}
478
479/// Helper: attempt a command but return `true`/`false` instead of `Err`.
480pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
481    std::process::Command::new(resolve_program(program))
482        .args(args)
483        .current_dir(cwd)
484        .stdin(std::process::Stdio::null())
485        .output()
486        .map(|o| o.status.success())
487        .unwrap_or(false)
488}
489
490/// Two-tier lockfile enforcement with configurable timeout.
491pub fn lock_sync_or_verify_with_timeout(
492    lockfile: &Path,
493    program: &str,
494    sync_args: &[&str],
495    cwd: &Path,
496    timeout: std::time::Duration,
497) -> Result<()> {
498    let lockfile_exists = lockfile.exists();
499
500    if !binary_available(program) {
501        if lockfile_exists {
502            return Ok(());
503        } else {
504            anyhow::bail!(
505                "`{program}` is not available and no lockfile was found at `{}`. \
506                 Cannot safely delete dependencies — install {program} first, \
507                 or commit a lockfile.",
508                lockfile.display()
509            );
510        }
511    }
512
513    // Binary is available — run the sync with timeout.
514    run_command_with_timeout(program, sync_args, cwd, timeout)
515}
516
517/// What an adapter is allowed to do while enforcing a lockfile on this pass.
518///
519/// The two things that used to be hardcoded per adapter, and were wrong in both places:
520/// every adapter burned the compiled-in timeout regardless of `command_timeout_secs`,
521/// and only cargo and go consulted `allow_manifest_rewrite`.
522#[derive(Debug, Clone, Copy)]
523pub struct EnforcePolicy {
524    /// Whether a sync command that writes files Git tracks may run anyway.
525    ///
526    /// The user's `allow_manifest_rewrite`. Off by default: a prune pass can come from
527    /// the scheduler, and a background process that leaves a dirty working tree behind
528    /// is a surprise no matter which file it wrote.
529    pub allow_rewrite: bool,
530    /// Ceiling on any one package-manager command — the user's `command_timeout_secs`.
531    pub timeout: std::time::Duration,
532}
533
534impl Default for EnforcePolicy {
535    fn default() -> Self {
536        Self {
537            allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
538            timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
539        }
540    }
541}
542
543impl EnforcePolicy {
544    /// A policy from the user's own settings.
545    pub fn from_settings(settings: &crate::config::Settings) -> Self {
546        Self {
547            allow_rewrite: settings.allow_manifest_rewrite,
548            timeout: std::time::Duration::from_secs(settings.command_timeout_secs),
549        }
550    }
551}
552
553/// The one rule every adapter enforces, given the manager's two spellings of the check.
554///
555/// - lockfile present → `verify_args`, which resolves the graph against the lockfile and
556///   **fails** rather than writing when the two have drifted apart
557/// - lockfile absent → `write_args`, because `restore` needs a lockfile to exist at all
558///   and there is nothing there to preserve
559/// - `allow_rewrite` → `write_args` either way: the informed opt-in, for the user who
560///   would rather have a stale lockfile refreshed than have the prune refused
561///
562/// This used to be the cargo/go rule only. Every other adapter ran its writing sync
563/// unconditionally — `npm install --package-lock-only`, `pnpm install --lockfile-only`,
564/// `uv lock` and `yarn install --mode update-lockfile` all rewrite a lockfile Git tracks
565/// when it has drifted from the manifest. That is a smaller edit than `go mod tidy`
566/// makes, but it is still an unattended pass modifying a tracked file, and it made
567/// `allow_manifest_rewrite` mean two different things depending on the ecosystem.
568pub fn enforce_two_tier(
569    lockfile: &Path,
570    program: &str,
571    verify_args: &[&str],
572    write_args: &[&str],
573    cwd: &Path,
574    policy: EnforcePolicy,
575) -> Result<()> {
576    if policy.allow_rewrite {
577        return lock_sync_or_verify_with_timeout(
578            lockfile,
579            program,
580            write_args,
581            cwd,
582            policy.timeout,
583        );
584    }
585    lock_verify_or_generate(
586        lockfile,
587        program,
588        verify_args,
589        write_args,
590        cwd,
591        policy.timeout,
592    )
593}
594
595/// Lockfile enforcement for ecosystems whose "sync" command rewrites source manifests.
596///
597/// `cargo generate-lockfile` re-resolves every dependency and overwrites `Cargo.lock`;
598/// `go mod tidy` edits both `go.mod` and `go.sum` and can drop requirements. Running
599/// either as a precondition for deletion would silently modify tracked source files,
600/// which contradicts the lockfile-safety guarantee. So:
601///
602/// - lockfile present → run the read-only `verify_args` (never writes)
603/// - lockfile absent  → run `generate_args`, since a lockfile must exist for `restore`
604pub fn lock_verify_or_generate(
605    lockfile: &Path,
606    program: &str,
607    verify_args: &[&str],
608    generate_args: &[&str],
609    cwd: &Path,
610    timeout: std::time::Duration,
611) -> Result<()> {
612    let lockfile_exists = lockfile.exists();
613
614    if !binary_available(program) {
615        if lockfile_exists {
616            return Ok(());
617        }
618        anyhow::bail!(
619            "`{program}` is not available and no lockfile was found at `{}`. \
620             Cannot safely delete dependencies — install {program} first, \
621             or commit a lockfile.",
622            lockfile.display()
623        );
624    }
625
626    if lockfile_exists {
627        run_command_with_timeout(program, verify_args, cwd, timeout)
628    } else {
629        run_command_with_timeout(program, generate_args, cwd, timeout)
630    }
631}
632
633/// Two-tier lockfile enforcement using default timeout.
634pub fn lock_sync_or_verify(
635    lockfile: &Path,
636    program: &str,
637    sync_args: &[&str],
638    cwd: &Path,
639) -> Result<()> {
640    lock_sync_or_verify_with_timeout(
641        lockfile,
642        program,
643        sync_args,
644        cwd,
645        std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
646    )
647}
648
649/// Information describing status of a required package manager binary.
650#[derive(Debug, Clone)]
651pub struct BinaryCheckStatus {
652    pub name: String,
653    pub available: bool,
654    pub version: Option<String>,
655}
656
657/// Scan only the package manager binaries needed by candidate repos.
658pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
659    let mut unique: Vec<String> = adapter_names
660        .iter()
661        .filter(|&n| n != "-" && n != "venv")
662        .cloned()
663        .collect();
664    unique.sort();
665    unique.dedup();
666
667    unique
668        .into_iter()
669        .map(|name| {
670            let output = std::process::Command::new(resolve_program(&name))
671                .arg("--version")
672                .stdin(std::process::Stdio::null())
673                .output();
674            match output {
675                Ok(out) if out.status.success() => {
676                    let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
677                    let first_line = ver.lines().next().unwrap_or(&ver).to_string();
678                    BinaryCheckStatus {
679                        name,
680                        available: true,
681                        version: if first_line.is_empty() {
682                            None
683                        } else {
684                            Some(first_line)
685                        },
686                    }
687                }
688                _ => BinaryCheckStatus {
689                    name,
690                    available: false,
691                    version: None,
692                },
693            }
694        })
695        .collect()
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use std::fs;
702    use tempfile::TempDir;
703
704    #[test]
705    fn test_bloat_dir_display() {
706        let bd = BloatDir {
707            name: "node_modules".to_string(),
708            path: PathBuf::from("/test/node_modules"),
709            size_bytes: 1024,
710        };
711        assert!(bd.to_string().contains("node_modules"));
712    }
713
714    #[test]
715    fn test_dir_size_empty() {
716        let tmp = TempDir::new().unwrap();
717        assert_eq!(dir_size(tmp.path()), 0);
718    }
719
720    #[test]
721    fn test_dir_size_with_files() {
722        let tmp = TempDir::new().unwrap();
723        fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
724        fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
725        assert_eq!(dir_size(tmp.path()), 11); // 5 + 6
726    }
727
728    #[test]
729    fn test_dir_size_nonexistent() {
730        assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
731    }
732
733    #[test]
734    fn test_get_all_adapters_not_empty() {
735        let adapters = get_all_adapters();
736        assert!(adapters.len() >= 6);
737    }
738
739    #[test]
740    fn test_detect_adapters_npm() {
741        let tmp = TempDir::new().unwrap();
742        fs::write(tmp.path().join("package.json"), "{}").unwrap();
743        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
744        let adapters = detect_adapters(tmp.path());
745        let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
746        assert!(names.contains(&"npm"));
747    }
748
749    #[test]
750    fn test_detect_adapters_empty_dir() {
751        let tmp = TempDir::new().unwrap();
752        let adapters = detect_adapters(tmp.path());
753        assert!(adapters.is_empty());
754    }
755
756    /// Names of the adapters that detect in `dir`, sorted.
757    fn detected_names(dir: &Path) -> Vec<&'static str> {
758        let mut names: Vec<&'static str> = detect_adapters(dir).iter().map(|a| a.name()).collect();
759        names.sort_unstable();
760        names
761    }
762
763    #[test]
764    fn test_detect_adapters_multiple_ecosystems_coexist() {
765        // Different managers owning different directories must all survive detection.
766        let tmp = TempDir::new().unwrap();
767        fs::write(tmp.path().join("package.json"), "{}").unwrap();
768        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
769        fs::write(tmp.path().join("uv.lock"), "").unwrap();
770        fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
771        fs::write(tmp.path().join("go.mod"), "module x").unwrap();
772
773        assert_eq!(detected_names(tmp.path()), vec!["cargo", "go", "npm", "uv"]);
774    }
775
776    #[test]
777    fn test_js_conflict_resolved_by_package_manager_field() {
778        let tmp = TempDir::new().unwrap();
779        fs::write(
780            tmp.path().join("package.json"),
781            r#"{"packageManager":"yarn@4.1.0"}"#,
782        )
783        .unwrap();
784        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
785        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
786        fs::write(tmp.path().join("yarn.lock"), "").unwrap();
787
788        assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
789    }
790
791    #[test]
792    fn test_js_conflict_resolved_by_what_installed_node_modules() {
793        // npm's lockfile is written last, but the tree on disk was built by pnpm — and
794        // that tree is what is about to be deleted.
795        let tmp = TempDir::new().unwrap();
796        fs::write(tmp.path().join("package.json"), "{}").unwrap();
797        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
798        fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
799        std::thread::sleep(std::time::Duration::from_millis(20));
800        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
801
802        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
803    }
804
805    #[test]
806    fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
807        // A repo migrated npm → yarn keeps npm's hidden lockfile inside node_modules.
808        let tmp = TempDir::new().unwrap();
809        fs::write(tmp.path().join("package.json"), "{}").unwrap();
810        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
811        fs::write(tmp.path().join("yarn.lock"), "").unwrap();
812        let nm = tmp.path().join("node_modules");
813        fs::create_dir_all(&nm).unwrap();
814        fs::write(nm.join(".package-lock.json"), "{}").unwrap();
815        fs::write(nm.join(".yarn-state.yml"), "").unwrap();
816
817        assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
818    }
819
820    #[test]
821    fn test_declared_package_manager_outranks_what_is_installed() {
822        // Corepack pins the project to pnpm; the npm tree on disk is the accident.
823        let tmp = TempDir::new().unwrap();
824        fs::write(
825            tmp.path().join("package.json"),
826            r#"{"packageManager":"pnpm@9.1.0"}"#,
827        )
828        .unwrap();
829        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
830        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
831        let nm = tmp.path().join("node_modules");
832        fs::create_dir_all(&nm).unwrap();
833        fs::write(nm.join(".package-lock.json"), "{}").unwrap();
834
835        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
836    }
837
838    #[test]
839    fn test_uv_takes_precedence_over_plain_venv() {
840        // A uv project declared through `[tool.uv]` alone, with a requirements.txt and a
841        // virtual environment left over from before the migration.
842        let tmp = TempDir::new().unwrap();
843        fs::write(
844            tmp.path().join("pyproject.toml"),
845            "[project]\nname = \"x\"\n\n[tool.uv]\n",
846        )
847        .unwrap();
848        fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
849        let venv = tmp.path().join(".venv");
850        fs::create_dir_all(&venv).unwrap();
851        fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
852
853        assert_eq!(detected_names(tmp.path()), vec!["uv"]);
854    }
855
856    #[test]
857    fn test_plain_venv_handles_projects_uv_does_not_claim() {
858        let tmp = TempDir::new().unwrap();
859        fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
860        let venv = tmp.path().join("venv");
861        fs::create_dir_all(&venv).unwrap();
862        fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
863
864        assert_eq!(detected_names(tmp.path()), vec!["venv"]);
865    }
866
867    #[test]
868    fn test_js_conflict_falls_back_to_newest_lockfile() {
869        let tmp = TempDir::new().unwrap();
870        fs::write(tmp.path().join("package.json"), "{}").unwrap();
871        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
872        // Written second, and touched again, so pnpm is unambiguously the newer of the
873        // two even on filesystems with coarse timestamp granularity.
874        std::thread::sleep(std::time::Duration::from_millis(20));
875        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
876
877        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
878    }
879
880    #[test]
881    fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
882        // A `packageManager` naming something we have no adapter for must not wipe out
883        // the detection entirely — fall through to the lockfile timestamps.
884        let tmp = TempDir::new().unwrap();
885        fs::write(
886            tmp.path().join("package.json"),
887            r#"{"packageManager":"deno@2.0.0"}"#,
888        )
889        .unwrap();
890        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
891        std::thread::sleep(std::time::Duration::from_millis(20));
892        fs::write(tmp.path().join("yarn.lock"), "").unwrap();
893
894        assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
895    }
896
897    #[test]
898    fn test_js_conflict_does_not_disturb_a_single_manager() {
899        let tmp = TempDir::new().unwrap();
900        fs::write(tmp.path().join("package.json"), "{}").unwrap();
901        fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
902        assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
903    }
904
905    #[test]
906    fn test_js_adapters_declare_their_lockfiles() {
907        for adapter in get_all_adapters() {
908            if JS_MANAGERS.contains(&adapter.name()) {
909                assert!(
910                    !adapter.lockfiles().is_empty(),
911                    "{} shares node_modules and must declare its lockfiles for \
912                     conflict resolution",
913                    adapter.name()
914                );
915            }
916        }
917    }
918
919    #[test]
920    fn test_adapter_names_unique() {
921        let adapters = get_all_adapters();
922        let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
923        let mut unique = names.clone();
924        unique.sort();
925        unique.dedup();
926        assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
927    }
928}