Skip to main content

dev_prune/
engine.rs

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