Skip to main content

dev_prune/
engine.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Pruning engine — orchestrates the full prune pass.
5//
6// This module is the "brain" of dev-prune. It coordinates:
7// 1. Git repo validation
8// 2. Activity/idle checking
9// 3. Adapter detection
10// 4. Lockfile enforcement
11// 5. Safe bloat directory deletion
12//
13// The engine enforces all safety invariants described in the project spec.
14
15use std::collections::BTreeMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::time::SystemTime;
19
20use anyhow::Result;
21use chrono::{DateTime, Utc};
22
23use crate::adapters::BloatDir;
24use crate::config::{Registry, RepoEntry};
25use crate::constants;
26use crate::scanner;
27use crate::scanner::git;
28use crate::workspace;
29
30/// The outcome of pruning a single bloat directory.
31#[derive(Debug, Clone)]
32pub enum PruneStatus {
33    /// Successfully deleted the bloat directory.
34    Pruned,
35    /// Skipped because the repo is still active (not idle).
36    SkippedActive,
37    /// Skipped because it's a dry run.
38    SkippedDryRun,
39    /// Skipped because lockfile enforcement failed.
40    LockfileError(String),
41    /// Skipped because the repository's last activity could not be determined.
42    ///
43    /// Not a `LockfileError`: that tag carries a `fix_command` an agent is told it can
44    /// run, and "git failed to answer" has no such mechanical fix.
45    ActivityCheckError(String),
46    /// The registered path no longer exists on disk.
47    PathMissing,
48    /// Skipped because the bloat directory doesn't exist.
49    NoBloat,
50    /// Repo is disabled in the registry.
51    Disabled,
52    /// Repo has a `ignore.devprune.json` file — opted out.
53    SkippedIgnored,
54    /// Error during deletion.
55    DeleteError(String),
56    /// The bloat directory is a symlink or junction, so it was deliberately left alone.
57    ///
58    /// A skip, not an error: the storage it points at is not this repository's to
59    /// delete, and the situation is permanent — reporting it as a failure made every
60    /// scheduled pass over such a repo exit non-zero forever.
61    SkippedSymlink(String),
62    /// `.devprune.json` exists but could not be parsed, so the repo was left alone.
63    ConfigError(String),
64    /// A directory the project declared prunable did not survive its checks.
65    ///
66    /// Not a `LockfileError` even though it is the same kind of refusal: that tag
67    /// carries a `fix_command` in `--json`, and there is no command that fixes "your
68    /// repository declares its own source directory".
69    SkippedDeclaration(String),
70}
71
72impl std::fmt::Display for PruneStatus {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            PruneStatus::Pruned => write!(f, "Pruned"),
76            PruneStatus::SkippedActive => write!(f, "Skipped (active)"),
77            PruneStatus::SkippedDryRun => write!(f, "Skipped (dry run)"),
78            PruneStatus::LockfileError(e) => write!(f, "Lockfile error: {e}"),
79            PruneStatus::ActivityCheckError(e) => write!(f, "Activity check failed: {e}"),
80            PruneStatus::PathMissing => {
81                write!(
82                    f,
83                    "Path no longer exists (`devp unlink --missing` clears it)"
84                )
85            }
86            PruneStatus::NoBloat => write!(f, "No bloat found"),
87            PruneStatus::Disabled => write!(f, "Disabled"),
88            PruneStatus::SkippedIgnored => write!(
89                f,
90                "Ignored (ignore.devprune.json or ignore config in .devprune.json)"
91            ),
92            PruneStatus::DeleteError(e) => write!(f, "Delete error: {e}"),
93            PruneStatus::SkippedSymlink(e) => write!(f, "Skipped (symlink): {e}"),
94            PruneStatus::ConfigError(e) => write!(f, "Unreadable .devprune.json: {e}"),
95            PruneStatus::SkippedDeclaration(e) => write!(f, "Skipped (declaration): {e}"),
96        }
97    }
98}
99
100/// Bytes in one mebibyte. The size floor is configured in MiB because that is the unit
101/// `format_bytes` prints, so a user who sets `10` sees the same number they typed.
102pub const BYTES_PER_MIB: u64 = 1024 * 1024;
103
104/// Which package managers a pass is allowed to act on.
105///
106/// The default allows everything. Built through [`AdapterFilter::new`], which rejects
107/// names no adapter answers to — a typo like `--only pmpm` silently matching nothing
108/// looks exactly like "there was no bloat", and that is the wrong thing to believe about
109/// a tool that deletes directories.
110#[derive(Debug, Clone, Default, PartialEq)]
111pub struct AdapterFilter {
112    only: Option<Vec<String>>,
113    skip: Vec<String>,
114}
115
116impl AdapterFilter {
117    /// Build a filter from comma-separated `--only` / `--skip` values.
118    ///
119    /// Names are matched case-insensitively. Listing the same adapter in both lists is
120    /// a contradiction rather than a precedence puzzle, so it is rejected outright.
121    pub fn new(only: Option<&str>, skip: Option<&str>) -> Result<Self> {
122        // `declared` is not a package manager and has no adapter, but it fills the
123        // same column of the same report, and a user who can see `declared` in
124        // `devp status` will reasonably try to `--only` it.
125        let known: Vec<&'static str> = crate::adapters::get_all_adapters()
126            .iter()
127            .map(|a| a.name())
128            .chain(std::iter::once(constants::DECLARED_ADAPTER_NAME))
129            .collect();
130
131        let parse = |raw: &str, flag: &str| -> Result<Vec<String>> {
132            let mut out = Vec::new();
133            for token in raw.split(',') {
134                let name = token.trim().to_lowercase();
135                if name.is_empty() {
136                    continue;
137                }
138                if !known.contains(&name.as_str()) {
139                    anyhow::bail!(
140                        "`--{flag} {name}` names no known package manager. Available: {}.",
141                        known.join(", ")
142                    );
143                }
144                if !out.contains(&name) {
145                    out.push(name);
146                }
147            }
148            if out.is_empty() {
149                anyhow::bail!("`--{flag}` was given no adapter names.");
150            }
151            Ok(out)
152        };
153
154        let only = only.map(|raw| parse(raw, "only")).transpose()?;
155        let skip = skip
156            .map(|raw| parse(raw, "skip"))
157            .transpose()?
158            .unwrap_or_default();
159
160        if let Some(only) = &only
161            && let Some(clash) = only.iter().find(|n| skip.contains(n))
162        {
163            anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
164        }
165
166        Ok(Self { only, skip })
167    }
168
169    /// Whether this filter would let `name` through.
170    pub fn allows(&self, name: &str) -> bool {
171        if self.skip.iter().any(|s| s == name) {
172            return false;
173        }
174        match &self.only {
175            Some(only) => only.iter().any(|o| o == name),
176            None => true,
177        }
178    }
179
180    /// Whether the filter restricts anything at all.
181    pub fn is_unrestricted(&self) -> bool {
182        self.only.is_none() && self.skip.is_empty()
183    }
184
185    /// Human-readable summary for the run header, or `None` when nothing is filtered.
186    pub fn describe(&self) -> Option<String> {
187        if self.is_unrestricted() {
188            return None;
189        }
190        let mut parts = Vec::new();
191        if let Some(only) = &self.only {
192            parts.push(format!("only {}", only.join(", ")));
193        }
194        if !self.skip.is_empty() {
195            parts.push(format!("skipping {}", self.skip.join(", ")));
196        }
197        Some(parts.join("; "))
198    }
199}
200
201/// Everything that shapes a prune pass beyond the repository itself.
202///
203/// `Default` is written out rather than derived: `scan_depth` has a real default that is
204/// not zero, and a derived one would have made every `..Default::default()` call site
205/// quietly walk a single level and report a monorepo as empty.
206#[derive(Debug, Clone)]
207pub struct PruneOptions {
208    /// Days of inactivity required before the repository is eligible.
209    pub idle_days: u64,
210    /// Report sizes and stop. Nothing is verified and nothing is deleted.
211    pub dry_run: bool,
212    /// Bypass the idle check. Lockfile verification still applies.
213    pub force: bool,
214    /// Restrict the pass to these repository-relative bloat directory labels.
215    ///
216    /// `Some` means a caller has already chosen — the interactive selector, or the
217    /// second phase of `devp run`. The size floor is not applied on top of an explicit
218    /// choice, because the caller has already decided these directories are wanted.
219    pub only_dirs: Option<Vec<String>>,
220    /// Which package managers may act.
221    pub adapters: AdapterFilter,
222    /// Smallest directory worth deleting. `0` disables the floor.
223    pub min_size_bytes: u64,
224    /// How deep to walk each repository looking for projects.
225    ///
226    /// The global setting. A repository's own `.devprune.json` may raise or lower it —
227    /// see [`workspace::resolve_depth`], which this is fed into.
228    pub scan_depth: usize,
229    /// Whether an adapter may run the sync command that rewrites its tracked lockfile.
230    pub allow_manifest_rewrite: bool,
231    /// Ceiling on any one package-manager command, in seconds.
232    ///
233    /// The user's `command_timeout_secs`. It was settable, displayed by `devp status`
234    /// and named in the timeout message long before anything actually read it here.
235    pub command_timeout_secs: u64,
236    /// Idle days required before *build-tree* directories are touched.
237    ///
238    /// Applied as `max(build_idle_days, idle_days)`, only to adapters that answer
239    /// [`crate::adapters::PackageManager::opt_in`]. A recompile costs more than a
240    /// reinstall, so those directories wait longer.
241    pub build_idle_days: u64,
242    /// Per-adapter idle windows, keyed by adapter name; the user's `adapter_idle_days`.
243    ///
244    /// A floor on top of everything else, never a bypass — see
245    /// [`PruneOptions::idle_threshold_for`].
246    pub adapter_idle_days: BTreeMap<String, u64>,
247}
248
249impl Default for PruneOptions {
250    fn default() -> Self {
251        Self {
252            idle_days: 0,
253            dry_run: false,
254            force: false,
255            only_dirs: None,
256            adapters: AdapterFilter::default(),
257            min_size_bytes: 0,
258            scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
259            allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
260            command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
261            build_idle_days: crate::constants::DEFAULT_BUILD_IDLE_DAYS,
262            adapter_idle_days: BTreeMap::new(),
263        }
264    }
265}
266
267impl PruneOptions {
268    /// How many idle days this adapter needs before its directories may be deleted.
269    ///
270    /// Three rules, applied in order and each one only ever able to raise the bar:
271    ///
272    /// 1. the repository-level window (`idle_days`, or the repo's own override), which
273    ///    every adapter has already had to clear before this is asked;
274    /// 2. `build_idle_days` for an adapter holding compiler output;
275    /// 3. this adapter's own entry in `adapter_idle_days`, if there is one.
276    ///
277    /// Only ever raise, because the repository-level check runs once for the whole
278    /// repository and is the gate that makes "active work is never touched" true. An
279    /// adapter that could lower it would be a bypass of that gate wearing a
280    /// preference's clothes, so a smaller number is accepted and does nothing.
281    fn idle_threshold_for(&self, name: &str, opt_in: bool, base: u64) -> u64 {
282        let mut days = base;
283        if opt_in {
284            days = days.max(self.build_idle_days);
285        }
286        if let Some(&explicit) = self.adapter_idle_days.get(name) {
287            days = days.max(explicit);
288        }
289        days
290    }
291
292    /// The common case: prune everything eligible in this repository.
293    pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
294        Self {
295            idle_days,
296            dry_run,
297            force,
298            ..Self::default()
299        }
300    }
301}
302
303/// Result of pruning a single bloat directory in a single repo.
304#[derive(Debug, Clone)]
305pub struct PruneResult {
306    /// Path to the repository.
307    pub repo_path: PathBuf,
308    /// Name of the adapter that handled this.
309    pub adapter_name: String,
310    /// The bloat directory that was (or would be) pruned.
311    pub bloat_dir: String,
312    /// Bytes freed (0 if not pruned).
313    pub size_freed: u64,
314    /// Bytes hardlinked into a package-manager store outside the pruned directory.
315    /// The store keeps them, so they are excluded from `size_freed` — this carries
316    /// them separately so reports can say why the number is smaller than `du` says.
317    pub shared_bytes: u64,
318    /// The language runtime the directory was built against, captured before the delete.
319    /// Only the Python managers set it; see [`crate::config::PrunedDir::runtime`].
320    pub runtime: Option<String>,
321    /// What happened.
322    pub status: PruneStatus,
323}
324
325impl PruneResult {
326    /// The directory a fix command has to be run from.
327    ///
328    /// `bloat_dir` is the label relative to the repository root, so in a monorepo it is
329    /// `backend/.venv`, not `.venv` — and `uv lock` run at the repository root would
330    /// rebuild a different project, or find nothing at all. Its parent is the project
331    /// directory the adapter actually detected. A fix command you can only run after
332    /// working out which directory it meant is not a fix command.
333    pub fn project_dir(&self) -> PathBuf {
334        self.repo_path
335            .join(&self.bloat_dir)
336            .parent()
337            .map(Path::to_path_buf)
338            .unwrap_or_else(|| self.repo_path.clone())
339    }
340}
341
342/// Prune a single repository. Returns results for each bloat directory found.
343///
344/// # Safety Invariants
345/// 1. The path MUST contain a valid `.git` directory
346/// 2. The repo must be idle (unless `force` is true)
347/// 3. Lockfile enforcement MUST succeed before any deletion
348pub fn prune_repo(
349    repo_path: &Path,
350    idle_days: u64,
351    dry_run: bool,
352    force: bool,
353) -> Vec<PruneResult> {
354    prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
355}
356
357/// Prune a repository, optionally restricted to a specific set of bloat directories.
358///
359/// `only` is a list of `BloatDir::name` values. When `Some`, any bloat directory whose
360/// name is not in the list is left untouched and produces no result — this is what makes
361/// the interactive selector's per-directory choices meaningful. When `None`, every
362/// detected bloat directory is pruned.
363///
364/// Same safety invariants as [`prune_repo`].
365pub fn prune_repo_selected(
366    repo_path: &Path,
367    idle_days: u64,
368    dry_run: bool,
369    force: bool,
370    only: Option<&[String]>,
371) -> Vec<PruneResult> {
372    prune_repo_with(
373        repo_path,
374        &PruneOptions {
375            only_dirs: only.map(<[String]>::to_vec),
376            ..PruneOptions::new(idle_days, dry_run, force)
377        },
378    )
379}
380
381/// Prune a repository under a full set of [`PruneOptions`].
382///
383/// This is the single implementation; [`prune_repo`] and [`prune_repo_selected`] are
384/// thin wrappers for the two common shapes.
385pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
386    let idle_days = opts.idle_days;
387    let dry_run = opts.dry_run;
388    let force = opts.force;
389    let only = opts.only_dirs.as_deref();
390    let mut results = Vec::new();
391
392    // A registered path that is gone gets a visible line, not silence. Returning empty
393    // results made the repository vanish from the run report entirely, which reads as
394    // "handled" when the truth is "not found".
395    if !repo_path.exists() {
396        results.push(PruneResult {
397            repo_path: repo_path.to_path_buf(),
398            adapter_name: "-".to_string(),
399            bloat_dir: "-".to_string(),
400            size_freed: 0,
401            shared_bytes: 0,
402            runtime: None,
403            status: PruneStatus::PathMissing,
404        });
405        return results;
406    }
407
408    // A registered path whose `.git` has gone (deleted by hand, or a worktree pruned
409    // by `git worktree prune`) must not vanish from the report the way it once did —
410    // same reasoning as the PathMissing line above: silence reads as "handled".
411    if !scanner::is_git_repo(repo_path) {
412        results.push(PruneResult {
413            repo_path: repo_path.to_path_buf(),
414            adapter_name: "-".to_string(),
415            bloat_dir: "-".to_string(),
416            size_freed: 0,
417            shared_bytes: 0,
418            runtime: None,
419            status: PruneStatus::ActivityCheckError(format!(
420                "`{}` is no longer a git repository — nothing was touched. \
421                 `devp unlink` removes it from the registry.",
422                repo_path.display()
423            )),
424        });
425        return results;
426    }
427
428    // Instant 0ms Check: if `ignore.devprune.json` exists in repo root, skip immediately without parsing any JSON files!
429    if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
430        results.push(PruneResult {
431            repo_path: repo_path.to_path_buf(),
432            adapter_name: "-".to_string(),
433            bloat_dir: "-".to_string(),
434            size_freed: 0,
435            shared_bytes: 0,
436            runtime: None,
437            status: PruneStatus::SkippedIgnored,
438        });
439        return results;
440    }
441
442    // A `.devprune.json` that does not parse is a refusal to guess, not a missing file.
443    // Falling back to defaults would drop `"ignore": true` and prune a repository the
444    // user explicitly opted out of, so an unreadable config skips the repo entirely.
445    let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
446        Ok(cfg) => cfg,
447        Err(e) => {
448            results.push(PruneResult {
449                repo_path: repo_path.to_path_buf(),
450                adapter_name: "-".to_string(),
451                bloat_dir: "-".to_string(),
452                size_freed: 0,
453                shared_bytes: 0,
454                runtime: None,
455                status: PruneStatus::ConfigError(e),
456            });
457            return results;
458        }
459    };
460    if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
461        results.push(PruneResult {
462            repo_path: repo_path.to_path_buf(),
463            adapter_name: "-".to_string(),
464            bloat_dir: "-".to_string(),
465            size_freed: 0,
466            shared_bytes: 0,
467            runtime: None,
468            status: PruneStatus::SkippedIgnored,
469        });
470        return results;
471    }
472
473    // Effective idle days from per-repo config or parameter
474    let effective_idle_days = per_repo_config
475        .as_ref()
476        .and_then(|c| c.override_idle_days)
477        .unwrap_or(idle_days);
478
479    // A repository may set its own floor, including `0` to opt out of a global one.
480    // An explicit directory selection overrides both: the caller already chose.
481    let min_size_bytes = if only.is_some() {
482        0
483    } else {
484        per_repo_config
485            .as_ref()
486            .and_then(|c| c.min_size_mb)
487            .map(|mb| mb.saturating_mul(BYTES_PER_MIB))
488            .unwrap_or(opts.min_size_bytes)
489    };
490
491    // Check if repo is idle (skip if active, unless forced)
492    if !force {
493        match git::is_repo_idle(repo_path, effective_idle_days) {
494            Ok(false) => {
495                results.push(PruneResult {
496                    repo_path: repo_path.to_path_buf(),
497                    adapter_name: "-".to_string(),
498                    bloat_dir: "-".to_string(),
499                    size_freed: 0,
500                    shared_bytes: 0,
501                    runtime: None,
502                    status: PruneStatus::SkippedActive,
503                });
504                return results;
505            }
506            Ok(true) => {} // Continue — repo is idle
507            Err(e) => {
508                results.push(PruneResult {
509                    repo_path: repo_path.to_path_buf(),
510                    adapter_name: "-".to_string(),
511                    bloat_dir: "-".to_string(),
512                    size_freed: 0,
513                    shared_bytes: 0,
514                    runtime: None,
515                    status: PruneStatus::ActivityCheckError(e.to_string()),
516                });
517                return results;
518            }
519        }
520    }
521
522    // A repository can hold several projects at several depths — `frontend/` on pnpm,
523    // `services/api/` on uv, `cli/` on cargo — and each is verified and pruned on its
524    // own terms.
525    let projects = workspace::discover_to_depth(
526        repo_path,
527        workspace::resolve_depth(repo_path, opts.scan_depth),
528    );
529
530    // Two adapters can legitimately claim the same directory (e.g. a cargo workspace
531    // member and its workspace root both resolving to the same `target`). Without this
532    // guard the size is counted twice and the second delete fails with "not found".
533    let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
534
535    // One `git log` walk per distinct threshold, computed lazily. Before per-adapter
536    // windows there was only ever one extra threshold to check; now a repository with
537    // cargo at 45 days and npm pinned to 30 needs two, and each is worth caching because
538    // the walk is the expensive part of a pass that finds nothing.
539    let mut idle_at: BTreeMap<u64, bool> = BTreeMap::new();
540
541    for project in &projects {
542        for adapter in &project.adapters {
543            if !opts.adapters.allows(adapter.name()) {
544                continue;
545            }
546
547            // Build trees come back by recompiling, and any adapter may carry a window
548            // of its own, so each one is asked what it needs. `force` bypasses this
549            // exactly as it bypasses the normal idle check; verification still applies.
550            let threshold =
551                opts.idle_threshold_for(adapter.name(), adapter.opt_in(), effective_idle_days);
552            if threshold > effective_idle_days && !force {
553                let idle_enough = *idle_at
554                    .entry(threshold)
555                    .or_insert_with(|| git::is_repo_idle(repo_path, threshold).unwrap_or(false));
556                if !idle_enough {
557                    continue;
558                }
559            }
560
561            // Labels are repo-relative (`node_modules`, `frontend/node_modules`) so that
562            // two directories with the same basename in different projects stay
563            // distinguishable — both on screen and in the `only` selection.
564            //
565            // The size floor is applied before `claimed`, so a directory rejected for
566            // being too small does not also block a second adapter from considering it.
567            let bloat_dirs: Vec<(String, BloatDir)> = adapter
568                .bloat_dirs(&project.path)
569                .into_iter()
570                .map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
571                .filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
572                .filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
573                .filter(|(_, bd)| claimed.insert(bd.path.clone()))
574                .collect();
575
576            if bloat_dirs.is_empty() {
577                continue;
578            }
579
580            // Both refusals run before the dry-run branch AND before lockfile
581            // enforcement. Before dry-run, because an analysis that counted a
582            // symlinked or nested-git directory as reclaimable promised space the
583            // real pass then refused to touch. Before enforcement, because with
584            // `allow_manifest_rewrite` the enforcement step may rewrite a tracked
585            // lockfile — and rewriting one in service of directories that are then
586            // every one refused leaves a modified tracked file behind with nothing
587            // deleted, the exact background-pass surprise the config forbids.
588            let mut deletable: Vec<(String, BloatDir)> = Vec::new();
589            for (label, bd) in bloat_dirs {
590                if let Some(status) = shared_storage_refusal(&bd.path) {
591                    results.push(PruneResult {
592                        repo_path: repo_path.to_path_buf(),
593                        adapter_name: adapter.name().to_string(),
594                        bloat_dir: label,
595                        size_freed: 0,
596                        shared_bytes: 0,
597                        runtime: None,
598                        status,
599                    });
600                    continue;
601                }
602
603                deletable.push((label, bd));
604            }
605
606            if deletable.is_empty() {
607                continue;
608            }
609
610            // Enforce lockfile BEFORE any deletion (skipped in dry-run — analysis only)
611            if !dry_run {
612                let policy = crate::adapters::EnforcePolicy {
613                    allow_rewrite: opts.allow_manifest_rewrite,
614                    timeout: std::time::Duration::from_secs(opts.command_timeout_secs),
615                };
616                if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
617                    for (label, _) in &deletable {
618                        results.push(PruneResult {
619                            repo_path: repo_path.to_path_buf(),
620                            adapter_name: adapter.name().to_string(),
621                            bloat_dir: label.clone(),
622                            size_freed: 0,
623                            shared_bytes: 0,
624                            runtime: None,
625                            status: PruneStatus::LockfileError(e.to_string()),
626                        });
627                    }
628                    continue;
629                }
630            }
631
632            for (label, bd) in deletable {
633                if dry_run {
634                    results.push(PruneResult {
635                        repo_path: repo_path.to_path_buf(),
636                        adapter_name: adapter.name().to_string(),
637                        bloat_dir: label,
638                        size_freed: bd.size_bytes,
639                        shared_bytes: bd.shared_bytes,
640                        runtime: None,
641                        status: PruneStatus::SkippedDryRun,
642                    });
643                    continue;
644                }
645
646                // Asked *before* the delete: the record of which interpreter built a
647                // virtual environment lives inside the environment, so a moment later
648                // there is nothing left to ask.
649                let runtime = adapter.runtime_tag(&project.path, &bd.name);
650                results.push(delete_bloat(repo_path, adapter.name(), label, &bd, runtime));
651            }
652        }
653    }
654
655    results.extend(prune_declarations(
656        repo_path,
657        per_repo_config.as_ref(),
658        opts,
659        min_size_bytes,
660        only,
661        &mut claimed,
662    ));
663
664    // Nothing recognised, or recognised but nothing on disk to reclaim.
665    if results.is_empty() {
666        results.push(PruneResult {
667            repo_path: repo_path.to_path_buf(),
668            adapter_name: "-".to_string(),
669            bloat_dir: "-".to_string(),
670            size_freed: 0,
671            shared_bytes: 0,
672            runtime: None,
673            status: PruneStatus::NoBloat,
674        });
675    }
676
677    results
678}
679
680/// The refusals that apply to any directory, whoever nominated it.
681///
682/// Shared by the adapter loop, the declared-directory pass and `collect_bloat`, because
683/// the three of them disagreeing is exactly the bug this prevents: what `devp status`
684/// counts as reclaimable has to be what `devp run` actually deletes, and a directory
685/// declared by hand is no more deletable than one an adapter found.
686///
687/// `None` means nothing here stands in the way.
688fn shared_storage_refusal(path: &Path) -> Option<PruneStatus> {
689    // A symlinked/junctioned directory points at storage we do not own — in a monorepo
690    // it is usually the workspace root's real `node_modules`. Refuse rather than risk a
691    // recursive delete outside the repo.
692    if fs::symlink_metadata(path)
693        .map(|m| m.file_type().is_symlink())
694        .unwrap_or(false)
695    {
696        return Some(PruneStatus::SkippedSymlink(format!(
697            "`{}` is a symlink to storage dev-prune does not own — left alone. Remove \
698             the link yourself if you really want it gone.",
699            path.display()
700        )));
701    }
702
703    // A mount point is the same problem wearing different clothes: the name is inside
704    // the repository but the storage is somebody else's, and here there is no link to
705    // remove — unmounting is the only way out, which is a decision for whoever mounted
706    // it.
707    if is_mount_point(path) {
708        return Some(PruneStatus::SkippedSymlink(format!(
709            "`{}` is a mount point — it is on a different filesystem than the repository \
710             around it, so its contents are shared with whatever mounted it. Left alone.",
711            path.display()
712        )));
713    }
714
715    // Invariant 7 keeps the *walk* out of nested repositories, but the directory about
716    // to be deleted can hold one inside it — a `file:` dependency, a vendored checkout —
717    // with its own unpushed history. No lockfile rebuilds somebody else's git history,
718    // so refuse.
719    if let Some(nested) = find_nested_git(path) {
720        return Some(PruneStatus::DeleteError(format!(
721            "`{}` contains a git repository at `{}` — refusing to delete it. Move or \
722             remove that checkout yourself if it holds nothing you need.",
723            path.display(),
724            nested.display()
725        )));
726    }
727
728    None
729}
730
731/// Delete one directory and report what happened, with one retry.
732fn delete_bloat(
733    repo_path: &Path,
734    adapter_name: &str,
735    label: String,
736    bd: &BloatDir,
737    runtime: Option<String>,
738) -> PruneResult {
739    let size = bd.size_bytes;
740    // `remove_dir_all` is not atomic: one locked file — an antivirus scan, an editor's
741    // file watcher — aborts it half-way, leaving a directory that is neither usable nor
742    // gone. Retry once after a beat, because such locks are usually released within
743    // moments of being hit — and a back-to-back retry lost the race to the very
744    // scanners it was meant to outwait.
745    let delete = fs::remove_dir_all(&bd.path).or_else(|_| {
746        std::thread::sleep(std::time::Duration::from_millis(250));
747        fs::remove_dir_all(&bd.path)
748    });
749    // "Not found" after a failed first attempt means the delete *did* complete — treat
750    // both the same.
751    let (size_freed, shared_bytes, status) = match delete {
752        Ok(()) => (size, bd.shared_bytes, PruneStatus::Pruned),
753        Err(_) if !bd.path.exists() => (size, bd.shared_bytes, PruneStatus::Pruned),
754        Err(e) => {
755            // Say what state the failure left behind. A half-deleted `node_modules` is
756            // corrupt whatever caused the abort, so the honest report is "no longer
757            // usable, rebuild it" — and the bytes already freed, so callers can record
758            // the partial pass and `devp restore` knows what to rebuild.
759            let remaining = crate::adapters::dir_size(&bd.path);
760            let freed = size.saturating_sub(remaining);
761            let message = if freed > 0 {
762                format!(
763                    "{e} — `{}` was partially deleted ({} of {} remains) and is no \
764                     longer usable. Close whatever holds it open, then run `devp \
765                     restore` to rebuild it.",
766                    bd.path.display(),
767                    crate::output::format_bytes(remaining),
768                    crate::output::format_bytes(size)
769                )
770            } else {
771                e.to_string()
772            };
773            (freed, 0, PruneStatus::DeleteError(message))
774        }
775    };
776    PruneResult {
777        repo_path: repo_path.to_path_buf(),
778        adapter_name: adapter_name.to_string(),
779        bloat_dir: label,
780        size_freed,
781        shared_bytes,
782        runtime,
783        status,
784    }
785}
786
787/// The directories this repository declared prunable, checked and then treated as bloat.
788///
789/// Runs after the adapters and shares their `claimed` set, so a project that declares
790/// something an adapter already found is not charged for it twice — and so the adapter's
791/// version, which has a lockfile behind it, is the one that wins.
792fn prune_declarations(
793    repo_path: &Path,
794    config: Option<&crate::config::PerRepoConfig>,
795    opts: &PruneOptions,
796    min_size_bytes: u64,
797    only: Option<&[String]>,
798    claimed: &mut std::collections::HashSet<PathBuf>,
799) -> Vec<PruneResult> {
800    let name = constants::DECLARED_ADAPTER_NAME;
801    if !opts.adapters.allows(name) {
802        return Vec::new();
803    }
804    let Some(declared) = config.and_then(|c| c.prunable.as_ref()) else {
805        return Vec::new();
806    };
807
808    let mut results = Vec::new();
809    for outcome in crate::declared::resolve(repo_path, &declared.directories) {
810        let target = match outcome {
811            crate::declared::Declaration::Prunable(target) => target,
812            // Printed even under `--only`, and even when the directory is below the
813            // size floor: a refusal means the repository asked for something dev-prune
814            // will not do, and silently doing nothing is how that stays unnoticed.
815            crate::declared::Declaration::Refused { label, reason } => {
816                results.push(PruneResult {
817                    repo_path: repo_path.to_path_buf(),
818                    adapter_name: name.to_string(),
819                    bloat_dir: label,
820                    size_freed: 0,
821                    shared_bytes: 0,
822                    runtime: None,
823                    status: PruneStatus::SkippedDeclaration(reason),
824                });
825                continue;
826            }
827        };
828
829        if only.is_some_and(|names| !names.contains(&target.label)) {
830            continue;
831        }
832        if target.size_bytes < min_size_bytes {
833            continue;
834        }
835        if !claimed.insert(target.path.clone()) {
836            continue;
837        }
838
839        let bd = BloatDir {
840            name: target.label.clone(),
841            path: target.path.clone(),
842            size_bytes: target.size_bytes,
843            shared_bytes: 0,
844        };
845        if let Some(status) = shared_storage_refusal(&bd.path) {
846            results.push(PruneResult {
847                repo_path: repo_path.to_path_buf(),
848                adapter_name: name.to_string(),
849                bloat_dir: target.label,
850                size_freed: 0,
851                shared_bytes: 0,
852                runtime: None,
853                status,
854            });
855            continue;
856        }
857        if opts.dry_run {
858            results.push(PruneResult {
859                repo_path: repo_path.to_path_buf(),
860                adapter_name: name.to_string(),
861                bloat_dir: target.label,
862                size_freed: target.size_bytes,
863                shared_bytes: 0,
864                runtime: None,
865                status: PruneStatus::SkippedDryRun,
866            });
867            continue;
868        }
869
870        // The rebuild command travels with the result the way an adapter's runtime tag
871        // does. It is the only record of how to put this directory back, and once the
872        // directory is gone the config file is the only place left holding it — which
873        // is no help at all to somebody reading a finished run report.
874        results.push(delete_bloat(
875            repo_path,
876            name,
877            target.label.clone(),
878            &bd,
879            Some(target.rebuild.clone()),
880        ));
881    }
882    results
883}
884
885/// Does `path` sit on a different filesystem from the directory that holds it?
886///
887/// Nothing inside a repository should: `node_modules` is an ordinary directory on the
888/// same volume as its parent. A mismatch means something was *mounted* there — a
889/// container's `-v shared_modules:/app/node_modules`, an NFS export, a bind mount
890/// pointing two checkouts at one cache — and what lives under it belongs to whoever
891/// set that up, not to this repository. A lockfile can rebuild this checkout's copy;
892/// it cannot rebuild the other consumers' copy, because there is only one copy.
893///
894/// Windows expresses the same idea as a reparse point, which the symlink refusal
895/// already catches, so this is a Unix-only check.
896#[cfg(unix)]
897fn is_mount_point(path: &Path) -> bool {
898    use std::os::unix::fs::MetadataExt;
899    let Some(parent) = path.parent() else {
900        return false;
901    };
902    match (fs::symlink_metadata(path), fs::symlink_metadata(parent)) {
903        (Ok(here), Ok(above)) => here.dev() != above.dev(),
904        // Unreadable is not evidence of a mount; the delete will fail on its own terms.
905        _ => false,
906    }
907}
908
909#[cfg(not(unix))]
910fn is_mount_point(_path: &Path) -> bool {
911    false
912}
913
914/// The first git repository found anywhere inside `dir`, if there is one.
915///
916/// `.git` as a directory is a full repository; as a file it is a submodule or worktree
917/// gitlink. Either way the history it anchors lives (at least partly) in the tree that
918/// is about to be deleted, and no lockfile can rebuild that.
919fn find_nested_git(dir: &Path) -> Option<PathBuf> {
920    walkdir::WalkDir::new(dir)
921        .follow_links(false)
922        .into_iter()
923        .flatten()
924        .find(|e| e.file_name() == ".git")
925        .map(|e| e.into_path())
926}
927
928/// Every bloat directory in a repository, across every nested project.
929///
930/// Returns the distinct adapter names in play alongside the deduplicated directories,
931/// each labelled with its repository-relative path, and how many bytes each adapter
932/// accounts for. Directories under `min_size_bytes` are omitted so that what `devp
933/// status` reports as reclaimable is what `devp run` would actually offer to delete.
934///
935/// The per-adapter tally exists for the restore estimate: `node_modules` and `target`
936/// come back at wildly different speeds, so a repository's total tells you nothing about
937/// how long it takes to put back until it is split by who puts it back.
938fn collect_bloat(
939    repo_path: &Path,
940    min_size_bytes: u64,
941    depth: usize,
942) -> (Vec<String>, Vec<BloatDir>, Vec<(String, u64)>) {
943    let mut adapter_names: Vec<String> = Vec::new();
944    let mut bloat: Vec<BloatDir> = Vec::new();
945    let mut by_adapter: BTreeMap<String, u64> = BTreeMap::new();
946    let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
947
948    for project in workspace::discover_to_depth(repo_path, depth) {
949        for adapter in &project.adapters {
950            let name = adapter.name();
951            if !adapter_names.iter().any(|existing| existing == name) {
952                adapter_names.push(name.to_string());
953            }
954            for bd in adapter.bloat_dirs(&project.path) {
955                if bd.size_bytes < min_size_bytes {
956                    continue;
957                }
958                // The same refusals the prune pass applies, for the same reason the
959                // size floor is applied here: what `devp status` reports as reclaimable
960                // must be what `devp run` would actually delete. A junctioned
961                // `node_modules` even sizes somebody else's storage, so counting it
962                // overstates the dashboard twice over.
963                if shared_storage_refusal(&bd.path).is_some() {
964                    continue;
965                }
966                if claimed.insert(bd.path.clone()) {
967                    *by_adapter.entry(name.to_string()).or_default() += bd.size_bytes;
968                    bloat.push(BloatDir {
969                        name: workspace::relative_label(repo_path, &bd.path),
970                        ..bd
971                    });
972                }
973            }
974        }
975    }
976
977    // Declared directories count towards the dashboard exactly as adapter-found ones
978    // do. A repository whose largest reclaimable tree is a declared one would otherwise
979    // read as empty in `devp status` and then delete gigabytes in `devp run`.
980    let declared = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
981        .ok()
982        .flatten()
983        .and_then(|c| c.prunable)
984        .map(|p| p.directories)
985        .unwrap_or_default();
986    for outcome in crate::declared::resolve(repo_path, &declared) {
987        let crate::declared::Declaration::Prunable(target) = outcome else {
988            continue;
989        };
990        if target.size_bytes < min_size_bytes
991            || shared_storage_refusal(&target.path).is_some()
992            || !claimed.insert(target.path.clone())
993        {
994            continue;
995        }
996        let name = constants::DECLARED_ADAPTER_NAME;
997        if !adapter_names.iter().any(|existing| existing == name) {
998            adapter_names.push(name.to_string());
999        }
1000        *by_adapter.entry(name.to_string()).or_default() += target.size_bytes;
1001        bloat.push(BloatDir {
1002            name: target.label,
1003            path: target.path,
1004            size_bytes: target.size_bytes,
1005            shared_bytes: 0,
1006        });
1007    }
1008
1009    (adapter_names, bloat, by_adapter.into_iter().collect())
1010}
1011
1012/// Run a prune pass across all registered repositories.
1013///
1014/// Each repository's own idle threshold applies; everything else in `opts` — the
1015/// adapter filter, the size floor, dry-run and force — is shared by the whole pass.
1016pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
1017    let mut all_results = Vec::new();
1018
1019    // Collect paths first to avoid borrow issues.
1020    //
1021    // Sorted, because `repositories` is a HashMap: without this the output of two
1022    // identical runs lists the same repositories in a different order, which makes the
1023    // summary hard to read and the JSON document impossible to diff.
1024    let mut repos: Vec<(PathBuf, u64, bool)> = registry
1025        .repositories
1026        .iter()
1027        .map(|(path, entry)| {
1028            let idle_days = entry
1029                .override_idle_days
1030                .unwrap_or(registry.settings.idle_days);
1031            (path.clone(), idle_days, entry.enabled)
1032        })
1033        .collect();
1034    repos.sort_by(|a, b| a.0.cmp(&b.0));
1035
1036    for (path, idle_days, enabled) in repos {
1037        if !enabled {
1038            all_results.push(PruneResult {
1039                repo_path: path.clone(),
1040                adapter_name: "-".to_string(),
1041                bloat_dir: "-".to_string(),
1042                size_freed: 0,
1043                shared_bytes: 0,
1044                runtime: None,
1045                status: PruneStatus::Disabled,
1046            });
1047            continue;
1048        }
1049
1050        let results = prune_repo_with(
1051            &path,
1052            &PruneOptions {
1053                idle_days,
1054                ..opts.clone()
1055            },
1056        );
1057
1058        let path_freed: u64 = results
1059            .iter()
1060            .filter(|r| matches!(r.status, PruneStatus::Pruned))
1061            .map(|r| r.size_freed)
1062            .sum();
1063
1064        if path_freed > 0 {
1065            registry.mark_pruned(&path, path_freed);
1066        }
1067
1068        all_results.extend(results);
1069    }
1070
1071    all_results
1072}
1073
1074/// Run a prune pass across all registered repositories with default options.
1075pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
1076    prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
1077}
1078
1079/// Restore dependencies across every project in a tree.
1080///
1081/// Mirrors pruning: if `frontend/`, `services/api/` and `cli/` were each pruned, each is
1082/// restored by its own manager. The returned label is the adapter name for a project at
1083/// the root and `adapter (relative/path)` for a nested one.
1084///
1085/// Restore must reach at least as deep as the prune did. A repository configured to a
1086/// depth of 10 and pruned at 10, then restored at the default 6, comes back with its
1087/// deepest projects still empty — and nothing would have said so. `timeout` is the
1088/// user's `command_timeout_secs` for the same reason: a full reinstall is the longest
1089/// command this tool ever runs, and it used to be the only one that ignored the setting.
1090pub fn restore_project_to_depth(
1091    project_path: &Path,
1092    global_depth: usize,
1093    timeout: std::time::Duration,
1094) -> Result<Vec<(String, Result<()>)>> {
1095    let depth = workspace::resolve_depth(project_path, global_depth);
1096    let projects = workspace::discover_to_depth(project_path, depth);
1097
1098    if projects.is_empty() {
1099        anyhow::bail!(
1100            "No recognized package manager found in {}",
1101            project_path.display()
1102        );
1103    }
1104
1105    let mut results = Vec::new();
1106    for project in &projects {
1107        for adapter in &project.adapters {
1108            let label = if project.relative == "." {
1109                adapter.name().to_string()
1110            } else {
1111                format!("{} ({})", adapter.name(), project.relative)
1112            };
1113            results.push((label, adapter.restore(&project.path, timeout)));
1114        }
1115    }
1116
1117    Ok(results)
1118}
1119
1120/// The project directory that owns a bloat directory, as a repository-relative label.
1121///
1122/// Every adapter puts its bloat directory immediately inside the project it belongs to —
1123/// `node_modules`, `target`, `.venv`, `vendor` — so the owner is the label's parent.
1124/// `"node_modules"` belongs to the repository root, `"frontend/node_modules"` to
1125/// `frontend/`. Labels are `/`-separated on every platform; see
1126/// [`workspace::relative_label`].
1127fn owning_project(bloat_label: &str) -> &str {
1128    match bloat_label.rsplit_once('/') {
1129        Some((parent, _)) => parent,
1130        None => ".",
1131    }
1132}
1133
1134/// Restore exactly the projects a previous pass emptied, and nothing else.
1135///
1136/// `deleted` is the `(bloat directory label, adapter name)` pairs recorded at prune time.
1137/// [`restore_project_to_depth`] would reinstall every project in the tree; a repository
1138/// where one of five projects was pruned does not want the other four rebuilt, which on a
1139/// monorepo is the difference between one `npm ci` and five.
1140///
1141/// A recorded pair that no longer matches anything in the tree — the project was deleted,
1142/// renamed, or its manifest removed since the prune — comes back as an `Err` under its
1143/// own label rather than being dropped, because a restore that silently skips half its
1144/// work is the failure mode this whole command exists to avoid.
1145/// One recorded directory's restore: what was attempted, whether it worked, and what it
1146/// cost.
1147///
1148/// The cost is carried out of the engine rather than measured by the caller because this
1149/// is the only place that knows where one directory's rebuild starts and ends. A caller
1150/// timing the whole pass would learn the average of a `node_modules` and a `target`,
1151/// which is a number about nothing.
1152pub struct RestoreOutcome {
1153    /// `adapter (repo/relative/dir)`, as the command prints it.
1154    pub label: String,
1155    /// The adapter that owned the directory.
1156    pub adapter: String,
1157    /// Bytes the prune recorded for it — what this restore is putting back.
1158    pub bytes: u64,
1159    /// How long the rebuild took, successful or not.
1160    pub elapsed: std::time::Duration,
1161    /// Whether it worked.
1162    pub result: Result<()>,
1163}
1164
1165pub fn restore_deleted(
1166    repo_path: &Path,
1167    deleted: &[crate::config::PrunedDir],
1168    global_depth: usize,
1169    timeout: std::time::Duration,
1170) -> Vec<RestoreOutcome> {
1171    let depth = workspace::resolve_depth(repo_path, global_depth);
1172    let projects = workspace::discover_to_depth(repo_path, depth);
1173
1174    let mut results = Vec::new();
1175    for dir in deleted {
1176        let (bloat_label, adapter_name) = (&dir.bloat_dir, &dir.adapter);
1177        // Closure rather than a helper: it captures the record being restored, and the
1178        // three call sites below differ only in what they pass for `result`.
1179        let timed = |result: Result<()>, started: std::time::Instant| RestoreOutcome {
1180            label: format!("{adapter_name} ({bloat_label})"),
1181            adapter: adapter_name.clone(),
1182            bytes: dir.size_freed,
1183            elapsed: started.elapsed(),
1184            result,
1185        };
1186        let runtime = dir.runtime.as_deref();
1187        let wanted = owning_project(bloat_label);
1188        // The deleted directory's own name, so an adapter that supports several
1189        // (venv's `.venv`/`venv`/`my_env`) rebuilds the one that was actually there.
1190        let dir_name = bloat_label
1191            .rsplit_once('/')
1192            .map_or(bloat_label.as_str(), |(_, name)| name);
1193
1194        let found = projects
1195            .iter()
1196            .filter(|p| p.relative == wanted)
1197            .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
1198            .find(|(_, a)| a.name() == adapter_name);
1199
1200        if let Some((project, adapter)) = found {
1201            let started = std::time::Instant::now();
1202            let result = adapter.restore_named(&project.path, dir_name, runtime, timeout);
1203            results.push(timed(result, started));
1204            continue;
1205        }
1206
1207        // Re-detection can fail *because* the prune succeeded: deleting a virtual
1208        // environment removes the very `pyvenv.cfg` that venv detection looks for. The
1209        // recorded adapter passed detection and lockfile verification at prune time, so
1210        // when the project directory still exists, trust the record over a re-detect
1211        // that is looking at the hole the prune left.
1212        let project_dir = if wanted == "." {
1213            repo_path.to_path_buf()
1214        } else {
1215            repo_path.join(wanted)
1216        };
1217        let recorded = crate::adapters::get_all_adapters()
1218            .into_iter()
1219            .find(|a| a.name() == adapter_name);
1220        match recorded {
1221            Some(adapter) if project_dir.is_dir() => {
1222                let started = std::time::Instant::now();
1223                let result = adapter.restore_named(&project_dir, dir_name, runtime, timeout);
1224                results.push(timed(result, started));
1225            }
1226            _ => results.push(timed(
1227                Err(anyhow::anyhow!(
1228                    "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
1229                     moved or removed since the prune. Restore it by hand if it still exists.",
1230                    repo_path.display()
1231                )),
1232                std::time::Instant::now(),
1233            )),
1234        }
1235    }
1236
1237    results
1238}
1239
1240/// Reason why a repo was not selected as a prune candidate.
1241#[derive(Debug, Clone, PartialEq)]
1242pub enum SkipReason {
1243    /// Has pruneable bloat — this IS a candidate.
1244    Candidate,
1245    /// Repo has been active recently.
1246    Active,
1247    /// Opted out: either registry-disabled OR `ignore.devprune.json` file present OR `.devprune.json` ignore config.
1248    /// Both are treated identically.
1249    Ignored,
1250    /// No recognised package manager / bloat dirs found.
1251    NoBloat,
1252    /// Path no longer exists on disk.
1253    PathMissing,
1254    /// `.devprune.json` exists but does not parse, so nothing about this repo is known.
1255    ConfigError(String),
1256}
1257
1258impl std::fmt::Display for SkipReason {
1259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1260        match self {
1261            SkipReason::Candidate => write!(f, "Candidate"),
1262            SkipReason::Active => write!(f, "Active (not idle)"),
1263            SkipReason::Ignored => write!(f, "Ignored"),
1264            SkipReason::NoBloat => write!(f, "No bloat found"),
1265            SkipReason::PathMissing => write!(f, "Path missing"),
1266            SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
1267        }
1268    }
1269}
1270
1271/// Full status entry for a single registered repository.
1272#[derive(Debug, Clone)]
1273pub struct RepoStatusEntry {
1274    /// Repository path.
1275    pub path: PathBuf,
1276    /// Registry metadata.
1277    pub entry: RepoEntry,
1278    /// Why this repo was/wasn't selected as a prune candidate.
1279    pub reason: SkipReason,
1280    /// Adapter names detected (e.g. ["npm", "uv"]).
1281    pub adapters: Vec<String>,
1282    /// Bloat directories and sizes (empty if not a candidate).
1283    pub bloat_dirs: Vec<BloatDir>,
1284    /// Total reclaimable bytes.
1285    pub reclaimable_bytes: u64,
1286    /// Reclaimable bytes split by the adapter that would have to put them back, sorted
1287    /// by adapter name. Empty whenever `bloat_dirs` is.
1288    pub reclaimable_by_adapter: Vec<(String, u64)>,
1289    /// Last git/file-system activity time.
1290    pub last_activity: Option<DateTime<Utc>>,
1291    /// Idle threshold that applies to this repo (days).
1292    pub idle_days: u64,
1293}
1294
1295/// Compute full status for ALL registered repositories.
1296///
1297/// Unlike `get_space_summary`, this includes every repo — active, disabled,
1298/// ignored, or missing — with a human-readable reason for each.
1299/// Everything `status` needs to say about one registered repository.
1300///
1301/// Split out of [`get_full_status`] so the scan can run several at once; it reads the
1302/// registry and the file system and writes nothing, which is what makes that safe.
1303fn status_for_repo(registry: &Registry, path: &Path, reg_entry: &RepoEntry) -> RepoStatusEntry {
1304    let registry_idle_days = reg_entry
1305        .override_idle_days
1306        .unwrap_or(registry.settings.idle_days);
1307
1308    // Path missing? Checked before the config is read, because a directory that is
1309    // gone has no config to read.
1310    if !path.exists() {
1311        return RepoStatusEntry {
1312            path: path.to_path_buf(),
1313            entry: reg_entry.clone(),
1314            reason: SkipReason::PathMissing,
1315            adapters: Vec::new(),
1316            bloat_dirs: Vec::new(),
1317            reclaimable_by_adapter: Vec::new(),
1318            reclaimable_bytes: 0,
1319            last_activity: None,
1320            idle_days: registry_idle_days,
1321        };
1322    }
1323
1324    // The same refusal-to-guess the prune pass makes. Reading this with
1325    // `load_from_repo` treated a broken file as "no config", so a repo that
1326    // `devp run` would refuse to touch showed up in the dashboard as a healthy
1327    // candidate with a reclaimable size next to it.
1328    let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
1329        Ok(cfg) => cfg,
1330        Err(e) => {
1331            return RepoStatusEntry {
1332                path: path.to_path_buf(),
1333                entry: reg_entry.clone(),
1334                reason: SkipReason::ConfigError(e),
1335                adapters: Vec::new(),
1336                bloat_dirs: Vec::new(),
1337                reclaimable_by_adapter: Vec::new(),
1338                reclaimable_bytes: 0,
1339                last_activity: last_activity_time(path),
1340                idle_days: registry_idle_days,
1341            };
1342        }
1343    };
1344    let idle_days = per_repo_config
1345        .as_ref()
1346        .and_then(|c| c.override_idle_days)
1347        .unwrap_or(registry_idle_days);
1348
1349    // Disabled in registry, ignore.devprune.json present, OR .devprune.json ignore=true
1350    let is_ignored = !reg_entry.enabled
1351        || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
1352        || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
1353    if is_ignored {
1354        return RepoStatusEntry {
1355            path: path.to_path_buf(),
1356            entry: reg_entry.clone(),
1357            reason: SkipReason::Ignored,
1358            adapters: Vec::new(),
1359            bloat_dirs: Vec::new(),
1360            reclaimable_by_adapter: Vec::new(),
1361            reclaimable_bytes: 0,
1362            last_activity: last_activity_time(path),
1363            idle_days,
1364        };
1365    }
1366
1367    // Activity check. One computation drives both the column and the decision —
1368    // they used to be computed separately, from different rules, so a repo with
1369    // uncommitted edits was correctly held back as "Active" while the column next
1370    // to it showed the last *commit*, months earlier.
1371    let activity = git::get_last_activity(path).ok().flatten();
1372    let activity_time = to_utc(activity);
1373    let is_idle = git::is_idle_at(activity, idle_days);
1374
1375    // Detect adapters & bloat across every project in the repository
1376    let min_size_bytes = per_repo_config
1377        .as_ref()
1378        .and_then(|c| c.min_size_mb)
1379        .unwrap_or(registry.settings.min_size_mb)
1380        .saturating_mul(BYTES_PER_MIB);
1381    // Same resolution order as the size floor just above: the repository's own
1382    // config first, the global setting otherwise. The dashboard and a run must walk
1383    // to the same depth or `status` will list projects `run` never sees.
1384    let depth = workspace::clamp_depth(
1385        per_repo_config
1386            .as_ref()
1387            .and_then(|c| c.scan_depth)
1388            .unwrap_or(registry.settings.scan_depth),
1389    );
1390    let (adapter_names, all_bloat, by_adapter) = collect_bloat(path, min_size_bytes, depth);
1391    let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1392
1393    let reason = if !is_idle {
1394        SkipReason::Active
1395    } else if all_bloat.is_empty() {
1396        SkipReason::NoBloat
1397    } else {
1398        SkipReason::Candidate
1399    };
1400
1401    RepoStatusEntry {
1402        path: path.to_path_buf(),
1403        entry: reg_entry.clone(),
1404        reason,
1405        adapters: adapter_names,
1406        bloat_dirs: all_bloat,
1407        reclaimable_bytes: reclaimable,
1408        reclaimable_by_adapter: by_adapter,
1409        last_activity: activity_time,
1410        idle_days,
1411    }
1412}
1413
1414/// How many threads the status scan should use for `total` repositories.
1415///
1416/// Each repository is an independent read of the file system and the pass is bound by
1417/// I/O, not by the CPU — so oversubscribing the cores still helps, up to the point where
1418/// the disk becomes the queue rather than the processor. The multiplier is the ramp:
1419/// a machine that reports more parallelism gets proportionally more, and the ceiling
1420/// stops a 64-core box from starting more threads than any disk can usefully serve.
1421///
1422/// Never more threads than there are repositories: a registry of three should not start
1423/// thirty-two of them to do nothing. Never fewer than one, because the calling thread is
1424/// itself the first worker.
1425///
1426/// [`constants::STATUS_SCAN_THREADS_ENV`] overrides the whole calculation, clamped the
1427/// same way — the escape hatch for a machine where the guess is wrong in either
1428/// direction: a network filesystem that wants far more requests in flight, or a spinning
1429/// disk that is fastest with one.
1430fn scan_thread_count(total: usize) -> usize {
1431    let requested = std::env::var(constants::STATUS_SCAN_THREADS_ENV)
1432        .ok()
1433        .and_then(|v| v.trim().parse::<usize>().ok())
1434        .filter(|n| *n > 0)
1435        .unwrap_or_else(|| {
1436            std::thread::available_parallelism()
1437                .map(std::num::NonZeroUsize::get)
1438                .unwrap_or(4)
1439                .saturating_mul(constants::STATUS_SCAN_THREADS_PER_CORE)
1440        });
1441    clamp_scan_threads(requested, total)
1442}
1443
1444/// The clamping half of [`scan_thread_count`], without the environment read, so the
1445/// bounds can be tested without mutating process-wide state.
1446fn clamp_scan_threads(requested: usize, total: usize) -> usize {
1447    requested
1448        .clamp(1, constants::STATUS_SCAN_MAX_THREADS)
1449        .min(total.max(1))
1450}
1451
1452pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
1453    get_full_status_reporting(registry, &|_done, _total| {})
1454}
1455
1456/// [`get_full_status`], reporting each repository as it finishes.
1457///
1458/// The scan is dominated by `collect_bloat`, which walks and sizes every dependency tree
1459/// it finds; on a registry of eighty repositories that was half a minute of silence
1460/// before anything appeared. The callback is what lets `devp status` draw a progress bar
1461/// over it instead — a dashboard that looks hung is one people kill before it renders.
1462///
1463/// The callback is invoked from several threads at once, and its first argument is the
1464/// number of repositories finished, not the index of this one: workers finish out of
1465/// order.
1466pub fn get_full_status_reporting(
1467    registry: &Registry,
1468    progress: &(dyn Fn(usize, usize) + Sync),
1469) -> Vec<RepoStatusEntry> {
1470    use std::sync::atomic::{AtomicUsize, Ordering};
1471
1472    let repos: Vec<(&PathBuf, &RepoEntry)> = registry.repositories.iter().collect();
1473    let total = repos.len();
1474    let workers = scan_thread_count(total);
1475
1476    // Work-stealing off a shared cursor rather than a fixed slice per thread, because the
1477    // cost per repository varies by orders of magnitude — one repository in a real
1478    // registry held a 2 GiB virtualenv while thirty others held nothing at all. A static
1479    // split leaves every other thread idle waiting for whichever one drew that repo.
1480    let next = AtomicUsize::new(0);
1481    let done = AtomicUsize::new(0);
1482
1483    let take_work = || {
1484        let mut mine = Vec::new();
1485        loop {
1486            let i = next.fetch_add(1, Ordering::Relaxed);
1487            if i >= total {
1488                break;
1489            }
1490            let (path, reg_entry) = repos[i];
1491            mine.push(status_for_repo(registry, path, reg_entry));
1492            progress(done.fetch_add(1, Ordering::Relaxed) + 1, total);
1493        }
1494        mine
1495    };
1496
1497    let chunks: Vec<Vec<RepoStatusEntry>> = std::thread::scope(|scope| {
1498        // `Builder::spawn_scoped` rather than `scope.spawn`, which panics when the OS
1499        // refuses a thread — under a low `ulimit -u`, in a constrained container, on a
1500        // machine already at its process limit. Refusing to draw a dashboard because the
1501        // system was busy is not an acceptable outcome, so a refusal here just means
1502        // fewer workers: whatever did start keeps pulling off the same cursor, and the
1503        // calling thread below is always one of them. In the worst case — nothing at all
1504        // would start — the scan runs single-threaded and still finishes.
1505        let mut handles = Vec::with_capacity(workers.saturating_sub(1));
1506        for n in 1..workers {
1507            match std::thread::Builder::new()
1508                .name(format!("devp-scan-{n}"))
1509                .spawn_scoped(scope, take_work)
1510            {
1511                Ok(handle) => handles.push(handle),
1512                Err(_) => break,
1513            }
1514        }
1515
1516        // The calling thread is a worker too, not a supervisor waiting on them. That is
1517        // what makes zero spawned threads a slow scan rather than a hung one.
1518        let mut chunks = vec![take_work()];
1519        chunks.extend(
1520            handles
1521                .into_iter()
1522                // A panicking worker takes the whole scan down with it. A dashboard for a
1523                // tool that deletes things must never quietly return a short list.
1524                .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e))),
1525        );
1526        chunks
1527    });
1528
1529    let mut entries: Vec<RepoStatusEntry> = chunks.into_iter().flatten().collect();
1530
1531    // Sort: what you can act on, then what is merely there, then what is gone — and by
1532    // path within each band. Path order alone put thirty-four dead entries at the top of
1533    // one dashboard, because `C:\Users\…\Temp` sorts before `V:\Code`, and the rows
1534    // that mattered started below the fold.
1535    fn rank(reason: &SkipReason) -> u8 {
1536        match reason {
1537            SkipReason::Candidate => 0,
1538            SkipReason::PathMissing => 2,
1539            _ => 1,
1540        }
1541    }
1542    entries.sort_by(|a, b| {
1543        rank(&a.reason)
1544            .cmp(&rank(&b.reason))
1545            .then_with(|| a.path.cmp(&b.path))
1546    });
1547
1548    entries
1549}
1550
1551/// The `n` repositories with the most reclaimable space, or all of them when `top` is
1552/// `None`.
1553///
1554/// `devp status` lists every registered repository, which on a machine tracking a hundred
1555/// of them pushes the handful actually worth pruning off the screen. Selection is by
1556/// reclaimable bytes, descending; the survivors are then put back into the order
1557/// [`get_full_status`] produced, so a truncated dashboard reads like a shorter version of
1558/// the full one rather than a differently-sorted one.
1559pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1560    let Some(n) = top else {
1561        return repos.to_vec();
1562    };
1563
1564    let mut ranked: Vec<usize> = (0..repos.len()).collect();
1565    ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1566    ranked.truncate(n);
1567    ranked.sort_unstable();
1568    ranked.into_iter().map(|i| repos[i].clone()).collect()
1569}
1570
1571/// Compute crisp, disambiguated project names for a repository path.
1572///
1573/// Uses `.devprune.json` custom `project_name` if present. Otherwise defaults to folder name.
1574/// If multiple repositories share the exact same folder name, disambiguates by including parent folder.
1575pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1576    // A label, so a config that does not parse just falls through to the folder name —
1577    // the states that matter are reported by the caller.
1578    if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1579        .ok()
1580        .flatten()
1581        && let Some(custom) = cfg.project_name
1582        && !custom.trim().is_empty()
1583    {
1584        return custom;
1585    }
1586
1587    let folder_name = repo_path
1588        .file_name()
1589        .map(|n| n.to_string_lossy().to_string())
1590        .unwrap_or_else(|| crate::output::clean_path(repo_path));
1591
1592    // Check if duplicate folder names exist
1593    let duplicate_count = all_paths
1594        .iter()
1595        .filter(|p| {
1596            p.file_name()
1597                .map(|n| n.to_string_lossy().to_string())
1598                .as_deref()
1599                == Some(&folder_name)
1600        })
1601        .count();
1602
1603    if duplicate_count > 1
1604        && let Some(parent) = repo_path.parent()
1605        && let Some(parent_name) = parent.file_name()
1606    {
1607        return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1608    }
1609
1610    folder_name
1611}
1612
1613/// Best-effort last activity time for a repo: the later of its last commit and the
1614/// newest source file mtime, which is the same value the idle check uses.
1615fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1616    to_utc(git::get_last_activity(path).ok().flatten())
1617}
1618
1619/// A `SystemTime` as the UTC timestamp the status entries carry.
1620fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1621    system_time.map(|st| {
1622        let duration = st
1623            .duration_since(SystemTime::UNIX_EPOCH)
1624            .unwrap_or_default();
1625        DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1626    })
1627}
1628
1629#[cfg(test)]
1630mod tests {
1631    use super::*;
1632    use std::fs;
1633    use std::process::Command;
1634    use tempfile::TempDir;
1635
1636    /// Restore in these tests either fails before running anything or runs against an
1637    /// empty project; none of them should ever sit anywhere near this long.
1638    const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1639
1640    #[test]
1641    fn an_ordinary_directory_is_not_a_mount_point() {
1642        // The check has to be silent on the only case that ever really happens; a real
1643        // mount cannot be created in a test without root, so this pins the negative.
1644        let tmp = TempDir::new().unwrap();
1645        let dir = tmp.path().join("node_modules");
1646        fs::create_dir_all(&dir).unwrap();
1647        assert!(!is_mount_point(&dir));
1648    }
1649
1650    #[test]
1651    fn a_filesystem_root_is_not_reported_as_a_mount_point() {
1652        // `/` has no parent, so the comparison has nothing to compare against. It must
1653        // answer "no" rather than panic on the `None`.
1654        let root = Path::new(std::path::MAIN_SEPARATOR_STR);
1655        assert!(!is_mount_point(root));
1656    }
1657
1658    fn create_git_repo_with_commit(path: &Path) {
1659        fs::create_dir_all(path).unwrap();
1660        Command::new("git")
1661            .args(["init"])
1662            .current_dir(path)
1663            .output()
1664            .unwrap();
1665        fs::write(path.join("README.md"), "# Test").unwrap();
1666        Command::new("git")
1667            .args(["add", "."])
1668            .current_dir(path)
1669            .output()
1670            .unwrap();
1671        Command::new("git")
1672            .args([
1673                "-c",
1674                "user.name=Test",
1675                "-c",
1676                "user.email=test@test.com",
1677                "commit",
1678                "-m",
1679                "initial",
1680            ])
1681            .current_dir(path)
1682            .output()
1683            .unwrap();
1684    }
1685
1686    #[test]
1687    fn a_bloat_label_names_the_project_that_owns_it() {
1688        assert_eq!(owning_project("node_modules"), ".");
1689        assert_eq!(owning_project("frontend/node_modules"), "frontend");
1690        assert_eq!(
1691            owning_project("packages/@scope/app/.venv"),
1692            "packages/@scope/app"
1693        );
1694    }
1695
1696    #[test]
1697    fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1698        // Two npm projects, one of which was pruned. Restoring the whole tree would
1699        // reinstall both; only the recorded one may be attempted.
1700        let tmp = TempDir::new().unwrap();
1701        let root = tmp.path();
1702        for name in ["frontend", "docs"] {
1703            let dir = root.join(name);
1704            fs::create_dir_all(&dir).unwrap();
1705            fs::write(dir.join("package.json"), "{}").unwrap();
1706            fs::write(dir.join("package-lock.json"), "{}").unwrap();
1707        }
1708
1709        let deleted = vec![crate::config::PrunedDir {
1710            repo_path: root.to_path_buf(),
1711            bloat_dir: "frontend/node_modules".to_string(),
1712            adapter: "npm".to_string(),
1713            size_freed: 0,
1714            runtime: None,
1715        }];
1716        let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1717
1718        assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1719        assert_eq!(results[0].label, "npm (frontend/node_modules)");
1720    }
1721
1722    #[test]
1723    fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1724        // Recorded at prune time, gone by restore time. Reported, never dropped: a
1725        // restore that quietly skips half its work is the failure this command prevents.
1726        let tmp = TempDir::new().unwrap();
1727        let deleted = vec![crate::config::PrunedDir {
1728            repo_path: tmp.path().to_path_buf(),
1729            bloat_dir: "services/api/.venv".to_string(),
1730            adapter: "uv".to_string(),
1731            size_freed: 0,
1732            runtime: None,
1733        }];
1734        let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1735
1736        assert_eq!(results.len(), 1);
1737        assert_eq!(results[0].label, "uv (services/api/.venv)");
1738        let err = results[0].result.as_ref().unwrap_err().to_string();
1739        assert!(err.contains("services/api"), "names the missing project");
1740        assert!(err.contains("uv"), "names the adapter that owned it");
1741    }
1742
1743    #[test]
1744    fn test_prune_status_display() {
1745        assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1746        assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1747        assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1748    }
1749
1750    #[test]
1751    fn test_prune_repo_non_git() {
1752        // A directory that is not a git repository produces a visible error line, not
1753        // silence — an empty result reads as "handled" in the run report.
1754        let tmp = TempDir::new().unwrap();
1755        let results = prune_repo(tmp.path(), 15, false, false);
1756        assert_eq!(results.len(), 1);
1757        assert!(matches!(
1758            results[0].status,
1759            PruneStatus::ActivityCheckError(_)
1760        ));
1761    }
1762
1763    #[test]
1764    fn test_prune_repo_active_skipped() {
1765        let tmp = TempDir::new().unwrap();
1766        let repo = tmp.path().join("repo");
1767        create_git_repo_with_commit(&repo);
1768        // Just committed — active
1769        let results = prune_repo(&repo, 15, false, false);
1770        assert_eq!(results.len(), 1);
1771        assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1772    }
1773
1774    /// An unreadable `.devprune.json` must never fall back to defaults: the file may have
1775    /// said `"ignore": true`, and guessing would delete from a repo that opted out.
1776    #[test]
1777    fn test_unparseable_per_repo_config_skips_the_repo() {
1778        let tmp = TempDir::new().unwrap();
1779        let repo = tmp.path().join("repo");
1780        create_git_repo_with_commit(&repo);
1781        fs::create_dir(repo.join("target")).unwrap();
1782        fs::write(repo.join("target").join("dummy"), "data").unwrap();
1783        fs::write(
1784            repo.join("Cargo.toml"),
1785            "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1786        )
1787        .unwrap();
1788        fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1789        // Trailing comma — valid-looking, but not valid JSON.
1790        fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1791
1792        // Forced, non-dry-run: everything else would have this repo pruned.
1793        let results = prune_repo(&repo, 15, false, true);
1794
1795        assert_eq!(results.len(), 1);
1796        assert!(
1797            matches!(results[0].status, PruneStatus::ConfigError(_)),
1798            "expected ConfigError, got {:?}",
1799            results[0].status
1800        );
1801        assert!(repo.join("target").exists(), "target must survive");
1802    }
1803
1804    /// The dashboard and the prune pass have to agree about a broken config file.
1805    /// Reporting it as a healthy candidate with a size next to it invites the user to
1806    /// select a repository that `devp run` will then refuse to touch.
1807    #[test]
1808    fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1809        let tmp = TempDir::new().unwrap();
1810        let repo = tmp.path().join("repo");
1811        create_git_repo_with_commit(&repo);
1812        create_python_project(&repo);
1813        fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1814
1815        let mut registry = Registry::default();
1816        registry.add_repo(repo.clone());
1817
1818        let entries = get_full_status(&registry);
1819        assert_eq!(entries.len(), 1);
1820        assert!(
1821            matches!(entries[0].reason, SkipReason::ConfigError(_)),
1822            "expected ConfigError, got {:?}",
1823            entries[0].reason
1824        );
1825        assert_eq!(entries[0].reclaimable_bytes, 0);
1826    }
1827
1828    #[test]
1829    fn test_prune_repo_dry_run() {
1830        let tmp = TempDir::new().unwrap();
1831        let repo = tmp.path().join("repo");
1832        create_git_repo_with_commit(&repo);
1833        // Go rather than Cargo: `target/` belongs to an opt-in adapter now, and a
1834        // fixture nothing detects would make this test pass for the wrong reason.
1835        create_go_project(&repo);
1836        // Force + dry run → should report what WOULD be pruned
1837        let results = prune_repo(&repo, 15, true, true);
1838        let dry_run_results: Vec<_> = results
1839            .iter()
1840            .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
1841            .collect();
1842        assert!(!dry_run_results.is_empty());
1843        // vendor should still exist
1844        assert!(repo.join("vendor").exists());
1845    }
1846
1847    /// A Python project with a populated `requirements.txt` and a virtual environment.
1848    ///
1849    /// The venv adapter verifies its lockfile by reading files rather than by shelling
1850    /// out, so it is the one ecosystem that can be pruned for real inside a test.
1851    fn create_python_project(dir: &Path) {
1852        fs::create_dir_all(dir).unwrap();
1853        fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1854        let venv = dir.join(".venv");
1855        fs::create_dir_all(&venv).unwrap();
1856        fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1857        fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
1858    }
1859
1860    /// A Go module with a vendored dependency tree.
1861    ///
1862    /// `modules.txt` is not decoration: the adapter refuses a `vendor/` without it,
1863    /// because only a `go mod vendor` product carries one.
1864    fn create_go_project(dir: &Path) {
1865        fs::create_dir_all(dir).unwrap();
1866        fs::write(dir.join("go.mod"), "module example.com/x\n\ngo 1.22\n").unwrap();
1867        fs::write(dir.join("go.sum"), "").unwrap();
1868        let vendor = dir.join("vendor");
1869        fs::create_dir_all(&vendor).unwrap();
1870        fs::write(vendor.join("modules.txt"), "# example.com/dep v1.0.0\n").unwrap();
1871        fs::write(vendor.join("payload.bin"), vec![0u8; 4096]).unwrap();
1872    }
1873
1874    /// The `bloat_dir` labels of every result, sorted.
1875    fn labels(results: &[PruneResult]) -> Vec<String> {
1876        let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
1877        out.sort();
1878        out
1879    }
1880
1881    #[test]
1882    fn test_prune_finds_several_ecosystems_at_the_repo_root() {
1883        let tmp = TempDir::new().unwrap();
1884        let repo = tmp.path().join("repo");
1885        create_git_repo_with_commit(&repo);
1886
1887        create_go_project(&repo);
1888        fs::write(repo.join("package.json"), "{}").unwrap();
1889        fs::write(repo.join("package-lock.json"), "{}").unwrap();
1890        fs::create_dir(repo.join("node_modules")).unwrap();
1891        create_python_project(&repo);
1892
1893        let results = prune_repo(&repo, 15, true, true);
1894        assert_eq!(labels(&results), vec![".venv", "node_modules", "vendor"]);
1895    }
1896
1897    #[test]
1898    fn test_prune_finds_ecosystems_at_different_depths() {
1899        let tmp = TempDir::new().unwrap();
1900        let repo = tmp.path().join("repo");
1901        create_git_repo_with_commit(&repo);
1902
1903        fs::create_dir_all(repo.join("frontend")).unwrap();
1904        fs::write(repo.join("frontend/package.json"), "{}").unwrap();
1905        fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
1906        fs::create_dir(repo.join("frontend/node_modules")).unwrap();
1907
1908        create_go_project(&repo.join("tools/cli"));
1909
1910        create_python_project(&repo.join("services/api"));
1911
1912        let results = prune_repo(&repo, 15, true, true);
1913        assert_eq!(
1914            labels(&results),
1915            vec![
1916                "frontend/node_modules",
1917                "services/api/.venv",
1918                "tools/cli/vendor",
1919            ]
1920        );
1921    }
1922
1923    #[test]
1924    fn test_prune_deletes_only_the_selected_nested_directory() {
1925        let tmp = TempDir::new().unwrap();
1926        let repo = tmp.path().join("repo");
1927        create_git_repo_with_commit(&repo);
1928        create_python_project(&repo.join("a"));
1929        create_python_project(&repo.join("b"));
1930
1931        let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
1932
1933        assert_eq!(labels(&results), vec!["a/.venv"]);
1934        assert!(matches!(results[0].status, PruneStatus::Pruned));
1935        assert!(!repo.join("a/.venv").exists());
1936        assert!(repo.join("b/.venv").exists());
1937    }
1938
1939    #[test]
1940    fn test_prune_ignores_bloat_inside_a_nested_repository() {
1941        let tmp = TempDir::new().unwrap();
1942        let repo = tmp.path().join("repo");
1943        create_git_repo_with_commit(&repo);
1944        create_python_project(&repo.join("outer"));
1945
1946        // A submodule is its own repository with its own activity history — pruning it
1947        // as part of the parent would ignore that.
1948        let nested = repo.join("nested");
1949        create_git_repo_with_commit(&nested);
1950        create_python_project(&nested);
1951
1952        let results = prune_repo(&repo, 15, true, true);
1953        assert_eq!(labels(&results), vec!["outer/.venv"]);
1954    }
1955
1956    #[test]
1957    fn test_prune_repo_no_adapters() {
1958        let tmp = TempDir::new().unwrap();
1959        let repo = tmp.path().join("repo");
1960        create_git_repo_with_commit(&repo);
1961        // Force prune but no package manager files
1962        let results = prune_repo(&repo, 15, false, true);
1963        assert!(
1964            results
1965                .iter()
1966                .any(|r| matches!(r.status, PruneStatus::NoBloat))
1967        );
1968    }
1969
1970    #[test]
1971    fn test_prune_all_disabled() {
1972        let tmp = TempDir::new().unwrap();
1973        let _registry_path = tmp.path().join("registry.json");
1974
1975        let mut registry = Registry::default();
1976        let repo_path = PathBuf::from("/nonexistent/repo");
1977        registry.add_repo(repo_path.clone());
1978        registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
1979
1980        let results = prune_all(&mut registry, false, false);
1981        assert!(
1982            results
1983                .iter()
1984                .any(|r| matches!(r.status, PruneStatus::Disabled))
1985        );
1986    }
1987
1988    #[test]
1989    fn test_restore_project_no_adapters() {
1990        let tmp = TempDir::new().unwrap();
1991        let result = restore_project_to_depth(
1992            tmp.path(),
1993            crate::constants::DEFAULT_SCAN_DEPTH,
1994            TEST_TIMEOUT,
1995        );
1996        assert!(result.is_err());
1997    }
1998
1999    #[test]
2000    fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
2001        // Deleting a venv removes the very pyvenv.cfg that detection looks for, so
2002        // re-detection finds nothing. The recorded adapter must still be attempted —
2003        // not reported as "no longer a venv project".
2004        let tmp = TempDir::new().unwrap();
2005        let api = tmp.path().join("api");
2006        fs::create_dir_all(&api).unwrap();
2007        fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
2008        // No .venv on disk — the prune already removed it.
2009
2010        let deleted = vec![crate::config::PrunedDir {
2011            repo_path: tmp.path().to_path_buf(),
2012            bloat_dir: "api/.venv".to_string(),
2013            adapter: "venv".to_string(),
2014            size_freed: 0,
2015            runtime: None,
2016        }];
2017        // A zero timeout kills the rebuild the moment it starts; the test is about
2018        // which branch routes, not whether python can build an environment here.
2019        let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
2020
2021        assert_eq!(results.len(), 1);
2022        assert_eq!(results[0].label, "venv (api/.venv)");
2023        if let Err(e) = &results[0].result {
2024            assert!(
2025                !e.to_string().contains("no longer a"),
2026                "the recorded adapter must be attempted, got: {e}"
2027            );
2028        }
2029    }
2030
2031    #[test]
2032    fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
2033        // A vendored checkout inside the directory about to be deleted carries its own
2034        // history, which no lockfile rebuilds. The whole delete must be refused.
2035        let tmp = TempDir::new().unwrap();
2036        let repo = tmp.path().join("repo");
2037        create_git_repo_with_commit(&repo);
2038        create_python_project(&repo);
2039        fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
2040
2041        let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
2042
2043        assert_eq!(results.len(), 1);
2044        let PruneStatus::DeleteError(msg) = &results[0].status else {
2045            panic!("expected a refusal, got {:?}", results[0].status);
2046        };
2047        assert!(msg.contains("git repository"), "says why: {msg}");
2048        assert!(repo.join(".venv").exists(), "nothing may be deleted");
2049        assert_eq!(results[0].size_freed, 0);
2050    }
2051
2052    fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
2053        RepoStatusEntry {
2054            path: PathBuf::from(name),
2055            entry: RepoEntry::new(),
2056            reason: SkipReason::Candidate,
2057            adapters: Vec::new(),
2058            bloat_dirs: Vec::new(),
2059            reclaimable_by_adapter: Vec::new(),
2060            reclaimable_bytes: reclaimable,
2061            last_activity: None,
2062            idle_days: 15,
2063        }
2064    }
2065
2066    #[test]
2067    fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
2068        let repos = [
2069            status_entry("small", 10),
2070            status_entry("big", 300),
2071            status_entry("mid", 200),
2072        ];
2073        let names: Vec<String> = take_top(&repos, Some(2))
2074            .iter()
2075            .map(|e| e.path.display().to_string())
2076            .collect();
2077        // Selection is by reclaimable bytes; the survivors come back in the order the
2078        // full dashboard had them, so a truncated list reads like a shorter version of
2079        // the full one rather than a differently-sorted one.
2080        assert_eq!(names, vec!["big", "mid"]);
2081    }
2082
2083    #[test]
2084    fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
2085        let repos = [status_entry("a", 1), status_entry("b", 2)];
2086        assert_eq!(take_top(&repos, None).len(), 2);
2087        assert_eq!(take_top(&repos, Some(10)).len(), 2);
2088        assert_eq!(take_top(&repos, Some(0)).len(), 0);
2089    }
2090
2091    #[test]
2092    fn test_restore_project_with_npm() {
2093        let tmp = TempDir::new().unwrap();
2094        fs::write(tmp.path().join("package.json"), "{}").unwrap();
2095        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
2096        let results = restore_project_to_depth(
2097            tmp.path(),
2098            crate::constants::DEFAULT_SCAN_DEPTH,
2099            TEST_TIMEOUT,
2100        );
2101        // Will fail because npm isn't available in test env, but shouldn't panic
2102        assert!(results.is_ok());
2103        let results = results.unwrap();
2104        assert!(!results.is_empty());
2105        assert_eq!(results[0].0, "npm");
2106    }
2107
2108    #[test]
2109    fn the_scan_never_starts_more_threads_than_there_is_work() {
2110        // A registry of three should not start thirty-two of them to do nothing.
2111        assert_eq!(clamp_scan_threads(32, 3), 3);
2112        // Nor fewer than one on an empty registry: the calling thread is worker zero, and
2113        // a count of zero would mean the work loop never ran at all.
2114        assert_eq!(clamp_scan_threads(0, 0), 1);
2115        assert_eq!(clamp_scan_threads(0, 50), 1);
2116    }
2117
2118    #[test]
2119    fn an_absurd_thread_request_is_clamped_rather_than_honoured() {
2120        // `DEV_PRUNE_SCAN_THREADS=9999` is a typo, not an instruction.
2121        assert_eq!(
2122            clamp_scan_threads(9_999, 500),
2123            constants::STATUS_SCAN_MAX_THREADS
2124        );
2125    }
2126
2127    #[test]
2128    fn a_registry_of_one_repository_is_scanned_on_the_calling_thread_alone() {
2129        assert_eq!(clamp_scan_threads(16, 1), 1);
2130    }
2131}