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