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