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