Skip to main content

dev_prune/
engine.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Pruning engine — orchestrates the full prune pass.
5//
6// This module is the "brain" of dev-prune. It coordinates:
7// 1. Git repo validation
8// 2. Activity/idle checking
9// 3. Adapter detection
10// 4. Lockfile enforcement
11// 5. Safe bloat directory deletion
12//
13// The engine enforces all safety invariants described in the project spec.
14
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::time::SystemTime;
18
19use anyhow::Result;
20use chrono::{DateTime, Utc};
21
22use crate::adapters::BloatDir;
23use crate::config::{Registry, RepoEntry};
24use crate::constants;
25use crate::scanner;
26use crate::scanner::git;
27use crate::workspace;
28
29/// The outcome of pruning a single bloat directory.
30#[derive(Debug, Clone)]
31pub enum PruneStatus {
32    /// Successfully deleted the bloat directory.
33    Pruned,
34    /// Skipped because the repo is still active (not idle).
35    SkippedActive,
36    /// Skipped because it's a dry run.
37    SkippedDryRun,
38    /// Skipped because lockfile enforcement failed.
39    LockfileError(String),
40    /// Skipped because the repository's last activity could not be determined.
41    ///
42    /// Not a `LockfileError`: that tag carries a `fix_command` an agent is told it can
43    /// run, and "git failed to answer" has no such mechanical fix.
44    ActivityCheckError(String),
45    /// The registered path no longer exists on disk.
46    PathMissing,
47    /// Skipped because the bloat directory doesn't exist.
48    NoBloat,
49    /// Repo is disabled in the registry.
50    Disabled,
51    /// Repo has a `ignore.devprune.json` file — opted out.
52    SkippedIgnored,
53    /// Error during deletion.
54    DeleteError(String),
55    /// The bloat directory is a symlink or junction, so it was deliberately left alone.
56    ///
57    /// A skip, not an error: the storage it points at is not this repository's to
58    /// delete, and the situation is permanent — reporting it as a failure made every
59    /// scheduled pass over such a repo exit non-zero forever.
60    SkippedSymlink(String),
61    /// `.devprune.json` exists but could not be parsed, so the repo was left alone.
62    ConfigError(String),
63}
64
65impl std::fmt::Display for PruneStatus {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            PruneStatus::Pruned => write!(f, "Pruned"),
69            PruneStatus::SkippedActive => write!(f, "Skipped (active)"),
70            PruneStatus::SkippedDryRun => write!(f, "Skipped (dry run)"),
71            PruneStatus::LockfileError(e) => write!(f, "Lockfile error: {e}"),
72            PruneStatus::ActivityCheckError(e) => write!(f, "Activity check failed: {e}"),
73            PruneStatus::PathMissing => {
74                write!(
75                    f,
76                    "Path no longer exists (`devp unlink --missing` clears it)"
77                )
78            }
79            PruneStatus::NoBloat => write!(f, "No bloat found"),
80            PruneStatus::Disabled => write!(f, "Disabled"),
81            PruneStatus::SkippedIgnored => write!(
82                f,
83                "Ignored (ignore.devprune.json or ignore config in .devprune.json)"
84            ),
85            PruneStatus::DeleteError(e) => write!(f, "Delete error: {e}"),
86            PruneStatus::SkippedSymlink(e) => write!(f, "Skipped (symlink): {e}"),
87            PruneStatus::ConfigError(e) => write!(f, "Unreadable .devprune.json: {e}"),
88        }
89    }
90}
91
92/// Bytes in one mebibyte. The size floor is configured in MiB because that is the unit
93/// `format_bytes` prints, so a user who sets `10` sees the same number they typed.
94pub const BYTES_PER_MIB: u64 = 1024 * 1024;
95
96/// Which package managers a pass is allowed to act on.
97///
98/// The default allows everything. Built through [`AdapterFilter::new`], which rejects
99/// names no adapter answers to — a typo like `--only pmpm` silently matching nothing
100/// looks exactly like "there was no bloat", and that is the wrong thing to believe about
101/// a tool that deletes directories.
102#[derive(Debug, Clone, Default, PartialEq)]
103pub struct AdapterFilter {
104    only: Option<Vec<String>>,
105    skip: Vec<String>,
106}
107
108impl AdapterFilter {
109    /// Build a filter from comma-separated `--only` / `--skip` values.
110    ///
111    /// Names are matched case-insensitively. Listing the same adapter in both lists is
112    /// a contradiction rather than a precedence puzzle, so it is rejected outright.
113    pub fn new(only: Option<&str>, skip: Option<&str>) -> Result<Self> {
114        let known: Vec<&'static str> = crate::adapters::get_all_adapters()
115            .iter()
116            .map(|a| a.name())
117            .collect();
118
119        let parse = |raw: &str, flag: &str| -> Result<Vec<String>> {
120            let mut out = Vec::new();
121            for token in raw.split(',') {
122                let name = token.trim().to_lowercase();
123                if name.is_empty() {
124                    continue;
125                }
126                if !known.contains(&name.as_str()) {
127                    anyhow::bail!(
128                        "`--{flag} {name}` names no known package manager. Available: {}.",
129                        known.join(", ")
130                    );
131                }
132                if !out.contains(&name) {
133                    out.push(name);
134                }
135            }
136            if out.is_empty() {
137                anyhow::bail!("`--{flag}` was given no adapter names.");
138            }
139            Ok(out)
140        };
141
142        let only = only.map(|raw| parse(raw, "only")).transpose()?;
143        let skip = skip
144            .map(|raw| parse(raw, "skip"))
145            .transpose()?
146            .unwrap_or_default();
147
148        if let Some(only) = &only {
149            if let Some(clash) = only.iter().find(|n| skip.contains(n)) {
150                anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
151            }
152        }
153
154        Ok(Self { only, skip })
155    }
156
157    /// Whether this filter would let `name` through.
158    pub fn allows(&self, name: &str) -> bool {
159        if self.skip.iter().any(|s| s == name) {
160            return false;
161        }
162        match &self.only {
163            Some(only) => only.iter().any(|o| o == name),
164            None => true,
165        }
166    }
167
168    /// Whether the filter restricts anything at all.
169    pub fn is_unrestricted(&self) -> bool {
170        self.only.is_none() && self.skip.is_empty()
171    }
172
173    /// Human-readable summary for the run header, or `None` when nothing is filtered.
174    pub fn describe(&self) -> Option<String> {
175        if self.is_unrestricted() {
176            return None;
177        }
178        let mut parts = Vec::new();
179        if let Some(only) = &self.only {
180            parts.push(format!("only {}", only.join(", ")));
181        }
182        if !self.skip.is_empty() {
183            parts.push(format!("skipping {}", self.skip.join(", ")));
184        }
185        Some(parts.join("; "))
186    }
187}
188
189/// Everything that shapes a prune pass beyond the repository itself.
190///
191/// `Default` is written out rather than derived: `scan_depth` has a real default that is
192/// not zero, and a derived one would have made every `..Default::default()` call site
193/// quietly walk a single level and report a monorepo as empty.
194#[derive(Debug, Clone)]
195pub struct PruneOptions {
196    /// Days of inactivity required before the repository is eligible.
197    pub idle_days: u64,
198    /// Report sizes and stop. Nothing is verified and nothing is deleted.
199    pub dry_run: bool,
200    /// Bypass the idle check. Lockfile verification still applies.
201    pub force: bool,
202    /// Restrict the pass to these repository-relative bloat directory labels.
203    ///
204    /// `Some` means a caller has already chosen — the interactive selector, or the
205    /// second phase of `devp run`. The size floor is not applied on top of an explicit
206    /// choice, because the caller has already decided these directories are wanted.
207    pub only_dirs: Option<Vec<String>>,
208    /// Which package managers may act.
209    pub adapters: AdapterFilter,
210    /// Smallest directory worth deleting. `0` disables the floor.
211    pub min_size_bytes: u64,
212    /// How deep to walk each repository looking for projects.
213    ///
214    /// The global setting. A repository's own `.devprune.json` may raise or lower it —
215    /// see [`workspace::resolve_depth`], which this is fed into.
216    pub scan_depth: usize,
217    /// Whether an adapter may run the sync command that rewrites its tracked lockfile.
218    pub allow_manifest_rewrite: bool,
219    /// Ceiling on any one package-manager command, in seconds.
220    ///
221    /// The user's `command_timeout_secs`. It was settable, displayed by `devp status`
222    /// and named in the timeout message long before anything actually read it here.
223    pub command_timeout_secs: u64,
224}
225
226impl Default for PruneOptions {
227    fn default() -> Self {
228        Self {
229            idle_days: 0,
230            dry_run: false,
231            force: false,
232            only_dirs: None,
233            adapters: AdapterFilter::default(),
234            min_size_bytes: 0,
235            scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
236            allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
237            command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
238        }
239    }
240}
241
242impl PruneOptions {
243    /// The common case: prune everything eligible in this repository.
244    pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
245        Self {
246            idle_days,
247            dry_run,
248            force,
249            ..Self::default()
250        }
251    }
252}
253
254/// Result of pruning a single bloat directory in a single repo.
255#[derive(Debug, Clone)]
256pub struct PruneResult {
257    /// Path to the repository.
258    pub repo_path: PathBuf,
259    /// Name of the adapter that handled this.
260    pub adapter_name: String,
261    /// The bloat directory that was (or would be) pruned.
262    pub bloat_dir: String,
263    /// Bytes freed (0 if not pruned).
264    pub size_freed: u64,
265    /// Bytes hardlinked into a package-manager store outside the pruned directory.
266    /// The store keeps them, so they are excluded from `size_freed` — this carries
267    /// them separately so reports can say why the number is smaller than `du` says.
268    pub shared_bytes: u64,
269    /// What happened.
270    pub status: PruneStatus,
271}
272
273/// Prune a single repository. Returns results for each bloat directory found.
274///
275/// # Safety Invariants
276/// 1. The path MUST contain a valid `.git` directory
277/// 2. The repo must be idle (unless `force` is true)
278/// 3. Lockfile enforcement MUST succeed before any deletion
279pub fn prune_repo(
280    repo_path: &Path,
281    idle_days: u64,
282    dry_run: bool,
283    force: bool,
284) -> Vec<PruneResult> {
285    prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
286}
287
288/// Prune a repository, optionally restricted to a specific set of bloat directories.
289///
290/// `only` is a list of `BloatDir::name` values. When `Some`, any bloat directory whose
291/// name is not in the list is left untouched and produces no result — this is what makes
292/// the interactive selector's per-directory choices meaningful. When `None`, every
293/// detected bloat directory is pruned.
294///
295/// Same safety invariants as [`prune_repo`].
296pub fn prune_repo_selected(
297    repo_path: &Path,
298    idle_days: u64,
299    dry_run: bool,
300    force: bool,
301    only: Option<&[String]>,
302) -> Vec<PruneResult> {
303    prune_repo_with(
304        repo_path,
305        &PruneOptions {
306            only_dirs: only.map(<[String]>::to_vec),
307            ..PruneOptions::new(idle_days, dry_run, force)
308        },
309    )
310}
311
312/// Prune a repository under a full set of [`PruneOptions`].
313///
314/// This is the single implementation; [`prune_repo`] and [`prune_repo_selected`] are
315/// thin wrappers for the two common shapes.
316pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
317    let idle_days = opts.idle_days;
318    let dry_run = opts.dry_run;
319    let force = opts.force;
320    let only = opts.only_dirs.as_deref();
321    let mut results = Vec::new();
322
323    // A registered path that is gone gets a visible line, not silence. Returning empty
324    // results made the repository vanish from the run report entirely, which reads as
325    // "handled" when the truth is "not found".
326    if !repo_path.exists() {
327        results.push(PruneResult {
328            repo_path: repo_path.to_path_buf(),
329            adapter_name: "-".to_string(),
330            bloat_dir: "-".to_string(),
331            size_freed: 0,
332            shared_bytes: 0,
333            status: PruneStatus::PathMissing,
334        });
335        return results;
336    }
337
338    // Safety check: must be a git repo
339    if !scanner::is_git_repo(repo_path) {
340        return results;
341    }
342
343    // Instant 0ms Check: if `ignore.devprune.json` exists in repo root, skip immediately without parsing any JSON files!
344    if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
345        results.push(PruneResult {
346            repo_path: repo_path.to_path_buf(),
347            adapter_name: "-".to_string(),
348            bloat_dir: "-".to_string(),
349            size_freed: 0,
350            shared_bytes: 0,
351            status: PruneStatus::SkippedIgnored,
352        });
353        return results;
354    }
355
356    // A `.devprune.json` that does not parse is a refusal to guess, not a missing file.
357    // Falling back to defaults would drop `"ignore": true` and prune a repository the
358    // user explicitly opted out of, so an unreadable config skips the repo entirely.
359    let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
360        Ok(cfg) => cfg,
361        Err(e) => {
362            results.push(PruneResult {
363                repo_path: repo_path.to_path_buf(),
364                adapter_name: "-".to_string(),
365                bloat_dir: "-".to_string(),
366                size_freed: 0,
367                shared_bytes: 0,
368                status: PruneStatus::ConfigError(e),
369            });
370            return results;
371        }
372    };
373    if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
374        results.push(PruneResult {
375            repo_path: repo_path.to_path_buf(),
376            adapter_name: "-".to_string(),
377            bloat_dir: "-".to_string(),
378            size_freed: 0,
379            shared_bytes: 0,
380            status: PruneStatus::SkippedIgnored,
381        });
382        return results;
383    }
384
385    // Effective idle days from per-repo config or parameter
386    let effective_idle_days = per_repo_config
387        .as_ref()
388        .and_then(|c| c.override_idle_days)
389        .unwrap_or(idle_days);
390
391    // A repository may set its own floor, including `0` to opt out of a global one.
392    // An explicit directory selection overrides both: the caller already chose.
393    let min_size_bytes = if only.is_some() {
394        0
395    } else {
396        per_repo_config
397            .as_ref()
398            .and_then(|c| c.min_size_mb)
399            .map(|mb| mb.saturating_mul(BYTES_PER_MIB))
400            .unwrap_or(opts.min_size_bytes)
401    };
402
403    // Check if repo is idle (skip if active, unless forced)
404    if !force {
405        match git::is_repo_idle(repo_path, effective_idle_days) {
406            Ok(false) => {
407                results.push(PruneResult {
408                    repo_path: repo_path.to_path_buf(),
409                    adapter_name: "-".to_string(),
410                    bloat_dir: "-".to_string(),
411                    size_freed: 0,
412                    shared_bytes: 0,
413                    status: PruneStatus::SkippedActive,
414                });
415                return results;
416            }
417            Ok(true) => {} // Continue — repo is idle
418            Err(e) => {
419                results.push(PruneResult {
420                    repo_path: repo_path.to_path_buf(),
421                    adapter_name: "-".to_string(),
422                    bloat_dir: "-".to_string(),
423                    size_freed: 0,
424                    shared_bytes: 0,
425                    status: PruneStatus::ActivityCheckError(e.to_string()),
426                });
427                return results;
428            }
429        }
430    }
431
432    // A repository can hold several projects at several depths — `frontend/` on pnpm,
433    // `services/api/` on uv, `cli/` on cargo — and each is verified and pruned on its
434    // own terms.
435    let projects = workspace::discover_to_depth(
436        repo_path,
437        workspace::resolve_depth(repo_path, opts.scan_depth),
438    );
439
440    // Two adapters can legitimately claim the same directory (e.g. a cargo workspace
441    // member and its workspace root both resolving to the same `target`). Without this
442    // guard the size is counted twice and the second delete fails with "not found".
443    let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
444
445    for project in &projects {
446        for adapter in &project.adapters {
447            if !opts.adapters.allows(adapter.name()) {
448                continue;
449            }
450
451            // Labels are repo-relative (`node_modules`, `frontend/node_modules`) so that
452            // two directories with the same basename in different projects stay
453            // distinguishable — both on screen and in the `only` selection.
454            //
455            // The size floor is applied before `claimed`, so a directory rejected for
456            // being too small does not also block a second adapter from considering it.
457            let bloat_dirs: Vec<(String, BloatDir)> = adapter
458                .bloat_dirs(&project.path)
459                .into_iter()
460                .map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
461                .filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
462                .filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
463                .filter(|(_, bd)| claimed.insert(bd.path.clone()))
464                .collect();
465
466            if bloat_dirs.is_empty() {
467                continue;
468            }
469
470            // Enforce lockfile BEFORE any deletion (skipped in dry-run — analysis only)
471            if !dry_run {
472                let policy = crate::adapters::EnforcePolicy {
473                    allow_rewrite: opts.allow_manifest_rewrite,
474                    timeout: std::time::Duration::from_secs(opts.command_timeout_secs),
475                };
476                if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
477                    for (label, _) in &bloat_dirs {
478                        results.push(PruneResult {
479                            repo_path: repo_path.to_path_buf(),
480                            adapter_name: adapter.name().to_string(),
481                            bloat_dir: label.clone(),
482                            size_freed: 0,
483                            shared_bytes: 0,
484                            status: PruneStatus::LockfileError(e.to_string()),
485                        });
486                    }
487                    continue;
488                }
489            }
490
491            for (label, bd) in bloat_dirs {
492                // A symlinked/junctioned bloat dir points at storage we do not own — in
493                // a monorepo it is usually the workspace root's real `node_modules`.
494                // Refuse rather than risk a recursive delete outside the repo. Checked
495                // before the dry-run branch, because an analysis that counted the link
496                // as reclaimable promised space the real pass then refused to touch.
497                if fs::symlink_metadata(&bd.path)
498                    .map(|m| m.file_type().is_symlink())
499                    .unwrap_or(false)
500                {
501                    results.push(PruneResult {
502                        repo_path: repo_path.to_path_buf(),
503                        adapter_name: adapter.name().to_string(),
504                        bloat_dir: label,
505                        size_freed: 0,
506                        shared_bytes: 0,
507                        status: PruneStatus::SkippedSymlink(format!(
508                            "`{}` is a symlink to storage dev-prune does not own — \
509                             left alone. Remove the link yourself if you really want \
510                             it gone.",
511                            bd.path.display()
512                        )),
513                    });
514                    continue;
515                }
516
517                if dry_run {
518                    results.push(PruneResult {
519                        repo_path: repo_path.to_path_buf(),
520                        adapter_name: adapter.name().to_string(),
521                        bloat_dir: label,
522                        size_freed: bd.size_bytes,
523                        shared_bytes: bd.shared_bytes,
524                        status: PruneStatus::SkippedDryRun,
525                    });
526                    continue;
527                }
528
529                // Invariant 7 keeps the *walk* out of nested repositories, but the
530                // directory about to be deleted can hold one inside it — a `file:`
531                // dependency, a vendored checkout — with its own unpushed history.
532                // No lockfile rebuilds somebody else's git history, so refuse.
533                if let Some(nested) = find_nested_git(&bd.path) {
534                    results.push(PruneResult {
535                        repo_path: repo_path.to_path_buf(),
536                        adapter_name: adapter.name().to_string(),
537                        bloat_dir: label,
538                        size_freed: 0,
539                        shared_bytes: 0,
540                        status: PruneStatus::DeleteError(format!(
541                            "`{}` contains a git repository at `{}` — refusing to \
542                             delete it. Move or remove that checkout yourself if it \
543                             holds nothing you need.",
544                            bd.path.display(),
545                            nested.display()
546                        )),
547                    });
548                    continue;
549                }
550
551                let size = bd.size_bytes;
552                // `remove_dir_all` is not atomic: one locked file — an antivirus scan,
553                // an editor's file watcher — aborts it half-way, leaving a directory
554                // that is neither usable nor gone. Retry once, because such locks are
555                // usually released within moments of being hit.
556                let delete = fs::remove_dir_all(&bd.path).or_else(|_| fs::remove_dir_all(&bd.path));
557                match delete {
558                    // "Not found" after a failed first attempt means the delete *did*
559                    // complete — treat both the same.
560                    Ok(()) => {
561                        results.push(PruneResult {
562                            repo_path: repo_path.to_path_buf(),
563                            adapter_name: adapter.name().to_string(),
564                            bloat_dir: label,
565                            size_freed: size,
566                            shared_bytes: bd.shared_bytes,
567                            status: PruneStatus::Pruned,
568                        });
569                    }
570                    Err(_) if !bd.path.exists() => {
571                        results.push(PruneResult {
572                            repo_path: repo_path.to_path_buf(),
573                            adapter_name: adapter.name().to_string(),
574                            bloat_dir: label,
575                            size_freed: size,
576                            shared_bytes: bd.shared_bytes,
577                            status: PruneStatus::Pruned,
578                        });
579                    }
580                    Err(e) => {
581                        // Say what state the failure left behind. A half-deleted
582                        // `node_modules` is corrupt whatever caused the abort, so the
583                        // honest report is "no longer usable, rebuild it" — and the
584                        // bytes already freed, so callers can record the partial pass
585                        // and `devp restore` knows what to rebuild.
586                        let remaining = crate::adapters::dir_size(&bd.path);
587                        let freed = size.saturating_sub(remaining);
588                        let message = if freed > 0 {
589                            format!(
590                                "{e} — `{}` was partially deleted ({} of {} remains) \
591                                 and is no longer usable. Close whatever holds it open, \
592                                 then run `devp restore` to rebuild it.",
593                                bd.path.display(),
594                                crate::output::format_bytes(remaining),
595                                crate::output::format_bytes(size)
596                            )
597                        } else {
598                            e.to_string()
599                        };
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: freed,
605                            shared_bytes: 0,
606                            status: PruneStatus::DeleteError(message),
607                        });
608                    }
609                }
610            }
611        }
612    }
613
614    // Nothing recognised, or recognised but nothing on disk to reclaim.
615    if results.is_empty() {
616        results.push(PruneResult {
617            repo_path: repo_path.to_path_buf(),
618            adapter_name: "-".to_string(),
619            bloat_dir: "-".to_string(),
620            size_freed: 0,
621            shared_bytes: 0,
622            status: PruneStatus::NoBloat,
623        });
624    }
625
626    results
627}
628
629/// The first git repository found anywhere inside `dir`, if there is one.
630///
631/// `.git` as a directory is a full repository; as a file it is a submodule or worktree
632/// gitlink. Either way the history it anchors lives (at least partly) in the tree that
633/// is about to be deleted, and no lockfile can rebuild that.
634fn find_nested_git(dir: &Path) -> Option<PathBuf> {
635    walkdir::WalkDir::new(dir)
636        .follow_links(false)
637        .into_iter()
638        .flatten()
639        .find(|e| e.file_name() == ".git")
640        .map(|e| e.into_path())
641}
642
643/// Every bloat directory in a repository, across every nested project.
644///
645/// Returns the distinct adapter names in play alongside the deduplicated directories,
646/// each labelled with its repository-relative path. Directories under `min_size_bytes`
647/// are omitted so that what `devp status` reports as reclaimable is what `devp run`
648/// would actually offer to delete.
649fn collect_bloat(
650    repo_path: &Path,
651    min_size_bytes: u64,
652    depth: usize,
653) -> (Vec<String>, Vec<BloatDir>) {
654    let mut adapter_names: Vec<String> = Vec::new();
655    let mut bloat: Vec<BloatDir> = Vec::new();
656    let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
657
658    for project in workspace::discover_to_depth(repo_path, depth) {
659        for adapter in &project.adapters {
660            let name = adapter.name();
661            if !adapter_names.iter().any(|existing| existing == name) {
662                adapter_names.push(name.to_string());
663            }
664            for bd in adapter.bloat_dirs(&project.path) {
665                if bd.size_bytes < min_size_bytes {
666                    continue;
667                }
668                if claimed.insert(bd.path.clone()) {
669                    bloat.push(BloatDir {
670                        name: workspace::relative_label(repo_path, &bd.path),
671                        ..bd
672                    });
673                }
674            }
675        }
676    }
677
678    (adapter_names, bloat)
679}
680
681/// Run a prune pass across all registered repositories.
682///
683/// Each repository's own idle threshold applies; everything else in `opts` — the
684/// adapter filter, the size floor, dry-run and force — is shared by the whole pass.
685pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
686    let mut all_results = Vec::new();
687
688    // Collect paths first to avoid borrow issues.
689    //
690    // Sorted, because `repositories` is a HashMap: without this the output of two
691    // identical runs lists the same repositories in a different order, which makes the
692    // summary hard to read and the JSON document impossible to diff.
693    let mut repos: Vec<(PathBuf, u64, bool)> = registry
694        .repositories
695        .iter()
696        .map(|(path, entry)| {
697            let idle_days = entry
698                .override_idle_days
699                .unwrap_or(registry.settings.idle_days);
700            (path.clone(), idle_days, entry.enabled)
701        })
702        .collect();
703    repos.sort_by(|a, b| a.0.cmp(&b.0));
704
705    for (path, idle_days, enabled) in repos {
706        if !enabled {
707            all_results.push(PruneResult {
708                repo_path: path.clone(),
709                adapter_name: "-".to_string(),
710                bloat_dir: "-".to_string(),
711                size_freed: 0,
712                shared_bytes: 0,
713                status: PruneStatus::Disabled,
714            });
715            continue;
716        }
717
718        let results = prune_repo_with(
719            &path,
720            &PruneOptions {
721                idle_days,
722                ..opts.clone()
723            },
724        );
725
726        let path_freed: u64 = results
727            .iter()
728            .filter(|r| matches!(r.status, PruneStatus::Pruned))
729            .map(|r| r.size_freed)
730            .sum();
731
732        if path_freed > 0 {
733            registry.mark_pruned(&path, path_freed);
734        }
735
736        all_results.extend(results);
737    }
738
739    all_results
740}
741
742/// Run a prune pass across all registered repositories with default options.
743pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
744    prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
745}
746
747/// Restore dependencies across every project in a tree.
748///
749/// Mirrors pruning: if `frontend/`, `services/api/` and `cli/` were each pruned, each is
750/// restored by its own manager. The returned label is the adapter name for a project at
751/// the root and `adapter (relative/path)` for a nested one.
752///
753/// Restore must reach at least as deep as the prune did. A repository configured to a
754/// depth of 10 and pruned at 10, then restored at the default 6, comes back with its
755/// deepest projects still empty — and nothing would have said so. `timeout` is the
756/// user's `command_timeout_secs` for the same reason: a full reinstall is the longest
757/// command this tool ever runs, and it used to be the only one that ignored the setting.
758pub fn restore_project_to_depth(
759    project_path: &Path,
760    global_depth: usize,
761    timeout: std::time::Duration,
762) -> Result<Vec<(String, Result<()>)>> {
763    let depth = workspace::resolve_depth(project_path, global_depth);
764    let projects = workspace::discover_to_depth(project_path, depth);
765
766    if projects.is_empty() {
767        anyhow::bail!(
768            "No recognized package manager found in {}",
769            project_path.display()
770        );
771    }
772
773    let mut results = Vec::new();
774    for project in &projects {
775        for adapter in &project.adapters {
776            let label = if project.relative == "." {
777                adapter.name().to_string()
778            } else {
779                format!("{} ({})", adapter.name(), project.relative)
780            };
781            results.push((label, adapter.restore(&project.path, timeout)));
782        }
783    }
784
785    Ok(results)
786}
787
788/// The project directory that owns a bloat directory, as a repository-relative label.
789///
790/// Every adapter puts its bloat directory immediately inside the project it belongs to —
791/// `node_modules`, `target`, `.venv`, `vendor` — so the owner is the label's parent.
792/// `"node_modules"` belongs to the repository root, `"frontend/node_modules"` to
793/// `frontend/`. Labels are `/`-separated on every platform; see
794/// [`workspace::relative_label`].
795fn owning_project(bloat_label: &str) -> &str {
796    match bloat_label.rsplit_once('/') {
797        Some((parent, _)) => parent,
798        None => ".",
799    }
800}
801
802/// Restore exactly the projects a previous pass emptied, and nothing else.
803///
804/// `deleted` is the `(bloat directory label, adapter name)` pairs recorded at prune time.
805/// [`restore_project_to_depth`] would reinstall every project in the tree; a repository
806/// where one of five projects was pruned does not want the other four rebuilt, which on a
807/// monorepo is the difference between one `npm ci` and five.
808///
809/// A recorded pair that no longer matches anything in the tree — the project was deleted,
810/// renamed, or its manifest removed since the prune — comes back as an `Err` under its
811/// own label rather than being dropped, because a restore that silently skips half its
812/// work is the failure mode this whole command exists to avoid.
813pub fn restore_deleted(
814    repo_path: &Path,
815    deleted: &[(String, String)],
816    global_depth: usize,
817    timeout: std::time::Duration,
818) -> Vec<(String, Result<()>)> {
819    let depth = workspace::resolve_depth(repo_path, global_depth);
820    let projects = workspace::discover_to_depth(repo_path, depth);
821
822    let mut results = Vec::new();
823    for (bloat_label, adapter_name) in deleted {
824        let wanted = owning_project(bloat_label);
825        let label = format!("{adapter_name} ({bloat_label})");
826        // The deleted directory's own name, so an adapter that supports several
827        // (venv's `.venv`/`venv`/`my_env`) rebuilds the one that was actually there.
828        let dir_name = bloat_label
829            .rsplit_once('/')
830            .map_or(bloat_label.as_str(), |(_, name)| name);
831
832        let found = projects
833            .iter()
834            .filter(|p| p.relative == wanted)
835            .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
836            .find(|(_, a)| a.name() == adapter_name);
837
838        if let Some((project, adapter)) = found {
839            results.push((
840                label,
841                adapter.restore_named(&project.path, dir_name, timeout),
842            ));
843            continue;
844        }
845
846        // Re-detection can fail *because* the prune succeeded: deleting a virtual
847        // environment removes the very `pyvenv.cfg` that venv detection looks for. The
848        // recorded adapter passed detection and lockfile verification at prune time, so
849        // when the project directory still exists, trust the record over a re-detect
850        // that is looking at the hole the prune left.
851        let project_dir = if wanted == "." {
852            repo_path.to_path_buf()
853        } else {
854            repo_path.join(wanted)
855        };
856        let recorded = crate::adapters::get_all_adapters()
857            .into_iter()
858            .find(|a| a.name() == adapter_name);
859        match recorded {
860            Some(adapter) if project_dir.is_dir() => {
861                results.push((
862                    label,
863                    adapter.restore_named(&project_dir, dir_name, timeout),
864                ));
865            }
866            _ => results.push((
867                label,
868                Err(anyhow::anyhow!(
869                    "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
870                     moved or removed since the prune. Restore it by hand if it still exists.",
871                    repo_path.display()
872                )),
873            )),
874        }
875    }
876
877    results
878}
879
880/// Reason why a repo was not selected as a prune candidate.
881#[derive(Debug, Clone, PartialEq)]
882pub enum SkipReason {
883    /// Has pruneable bloat — this IS a candidate.
884    Candidate,
885    /// Repo has been active recently.
886    Active,
887    /// Opted out: either registry-disabled OR `ignore.devprune.json` file present OR `.devprune.json` ignore config.
888    /// Both are treated identically.
889    Ignored,
890    /// No recognised package manager / bloat dirs found.
891    NoBloat,
892    /// Path no longer exists on disk.
893    PathMissing,
894    /// `.devprune.json` exists but does not parse, so nothing about this repo is known.
895    ConfigError(String),
896}
897
898impl std::fmt::Display for SkipReason {
899    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
900        match self {
901            SkipReason::Candidate => write!(f, "Candidate"),
902            SkipReason::Active => write!(f, "Active (not idle)"),
903            SkipReason::Ignored => write!(f, "Ignored"),
904            SkipReason::NoBloat => write!(f, "No bloat found"),
905            SkipReason::PathMissing => write!(f, "Path missing"),
906            SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
907        }
908    }
909}
910
911/// Full status entry for a single registered repository.
912#[derive(Debug, Clone)]
913pub struct RepoStatusEntry {
914    /// Repository path.
915    pub path: PathBuf,
916    /// Registry metadata.
917    pub entry: RepoEntry,
918    /// Why this repo was/wasn't selected as a prune candidate.
919    pub reason: SkipReason,
920    /// Adapter names detected (e.g. ["npm", "uv"]).
921    pub adapters: Vec<String>,
922    /// Bloat directories and sizes (empty if not a candidate).
923    pub bloat_dirs: Vec<BloatDir>,
924    /// Total reclaimable bytes.
925    pub reclaimable_bytes: u64,
926    /// Last git/file-system activity time.
927    pub last_activity: Option<DateTime<Utc>>,
928    /// Idle threshold that applies to this repo (days).
929    pub idle_days: u64,
930}
931
932/// Compute full status for ALL registered repositories.
933///
934/// Unlike `get_space_summary`, this includes every repo — active, disabled,
935/// ignored, or missing — with a human-readable reason for each.
936pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
937    let mut entries: Vec<RepoStatusEntry> = Vec::new();
938
939    for (path, reg_entry) in &registry.repositories {
940        let registry_idle_days = reg_entry
941            .override_idle_days
942            .unwrap_or(registry.settings.idle_days);
943
944        // Path missing? Checked before the config is read, because a directory that is
945        // gone has no config to read.
946        if !path.exists() {
947            entries.push(RepoStatusEntry {
948                path: path.clone(),
949                entry: reg_entry.clone(),
950                reason: SkipReason::PathMissing,
951                adapters: Vec::new(),
952                bloat_dirs: Vec::new(),
953                reclaimable_bytes: 0,
954                last_activity: None,
955                idle_days: registry_idle_days,
956            });
957            continue;
958        }
959
960        // The same refusal-to-guess the prune pass makes. Reading this with
961        // `load_from_repo` treated a broken file as "no config", so a repo that
962        // `devp run` would refuse to touch showed up in the dashboard as a healthy
963        // candidate with a reclaimable size next to it.
964        let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
965            Ok(cfg) => cfg,
966            Err(e) => {
967                entries.push(RepoStatusEntry {
968                    path: path.clone(),
969                    entry: reg_entry.clone(),
970                    reason: SkipReason::ConfigError(e),
971                    adapters: Vec::new(),
972                    bloat_dirs: Vec::new(),
973                    reclaimable_bytes: 0,
974                    last_activity: last_activity_time(path),
975                    idle_days: registry_idle_days,
976                });
977                continue;
978            }
979        };
980        let idle_days = per_repo_config
981            .as_ref()
982            .and_then(|c| c.override_idle_days)
983            .unwrap_or(registry_idle_days);
984
985        // Disabled in registry, ignore.devprune.json present, OR .devprune.json ignore=true
986        let is_ignored = !reg_entry.enabled
987            || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
988            || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
989        if is_ignored {
990            entries.push(RepoStatusEntry {
991                path: path.clone(),
992                entry: reg_entry.clone(),
993                reason: SkipReason::Ignored,
994                adapters: Vec::new(),
995                bloat_dirs: Vec::new(),
996                reclaimable_bytes: 0,
997                last_activity: last_activity_time(path),
998                idle_days,
999            });
1000            continue;
1001        }
1002
1003        // Activity check. One computation drives both the column and the decision —
1004        // they used to be computed separately, from different rules, so a repo with
1005        // uncommitted edits was correctly held back as "Active" while the column next
1006        // to it showed the last *commit*, months earlier.
1007        let activity = git::get_last_activity(path).ok().flatten();
1008        let activity_time = to_utc(activity);
1009        let is_idle = git::is_idle_at(activity, idle_days);
1010
1011        // Detect adapters & bloat across every project in the repository
1012        let min_size_bytes = per_repo_config
1013            .as_ref()
1014            .and_then(|c| c.min_size_mb)
1015            .unwrap_or(registry.settings.min_size_mb)
1016            .saturating_mul(BYTES_PER_MIB);
1017        // Same resolution order as the size floor just above: the repository's own
1018        // config first, the global setting otherwise. The dashboard and a run must walk
1019        // to the same depth or `status` will list projects `run` never sees.
1020        let depth = workspace::clamp_depth(
1021            per_repo_config
1022                .as_ref()
1023                .and_then(|c| c.scan_depth)
1024                .unwrap_or(registry.settings.scan_depth),
1025        );
1026        let (adapter_names, all_bloat) = collect_bloat(path, min_size_bytes, depth);
1027        let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1028
1029        let reason = if !is_idle {
1030            SkipReason::Active
1031        } else if all_bloat.is_empty() {
1032            SkipReason::NoBloat
1033        } else {
1034            SkipReason::Candidate
1035        };
1036
1037        entries.push(RepoStatusEntry {
1038            path: path.clone(),
1039            entry: reg_entry.clone(),
1040            reason,
1041            adapters: adapter_names,
1042            bloat_dirs: all_bloat,
1043            reclaimable_bytes: reclaimable,
1044            last_activity: activity_time,
1045            idle_days,
1046        });
1047    }
1048
1049    // Sort: candidates first, then by path name
1050    entries.sort_by(|a, b| {
1051        let a_cand = matches!(a.reason, SkipReason::Candidate);
1052        let b_cand = matches!(b.reason, SkipReason::Candidate);
1053        b_cand.cmp(&a_cand).then_with(|| a.path.cmp(&b.path))
1054    });
1055
1056    entries
1057}
1058
1059/// The `n` repositories with the most reclaimable space, or all of them when `top` is
1060/// `None`.
1061///
1062/// `devp status` lists every registered repository, which on a machine tracking a hundred
1063/// of them pushes the handful actually worth pruning off the screen. Selection is by
1064/// reclaimable bytes, descending; the survivors are then put back into the order
1065/// [`get_full_status`] produced, so a truncated dashboard reads like a shorter version of
1066/// the full one rather than a differently-sorted one.
1067pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1068    let Some(n) = top else {
1069        return repos.to_vec();
1070    };
1071
1072    let mut ranked: Vec<usize> = (0..repos.len()).collect();
1073    ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1074    ranked.truncate(n);
1075    ranked.sort_unstable();
1076    ranked.into_iter().map(|i| repos[i].clone()).collect()
1077}
1078
1079/// Compute crisp, disambiguated project names for a repository path.
1080///
1081/// Uses `.devprune.json` custom `project_name` if present. Otherwise defaults to folder name.
1082/// If multiple repositories share the exact same folder name, disambiguates by including parent folder.
1083pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1084    // A label, so a config that does not parse just falls through to the folder name —
1085    // the states that matter are reported by the caller.
1086    if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1087        .ok()
1088        .flatten()
1089    {
1090        if let Some(custom) = cfg.project_name {
1091            if !custom.trim().is_empty() {
1092                return custom;
1093            }
1094        }
1095    }
1096
1097    let folder_name = repo_path
1098        .file_name()
1099        .map(|n| n.to_string_lossy().to_string())
1100        .unwrap_or_else(|| crate::output::clean_path(repo_path));
1101
1102    // Check if duplicate folder names exist
1103    let duplicate_count = all_paths
1104        .iter()
1105        .filter(|p| {
1106            p.file_name()
1107                .map(|n| n.to_string_lossy().to_string())
1108                .as_deref()
1109                == Some(&folder_name)
1110        })
1111        .count();
1112
1113    if duplicate_count > 1 {
1114        if let Some(parent) = repo_path.parent() {
1115            if let Some(parent_name) = parent.file_name() {
1116                return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1117            }
1118        }
1119    }
1120
1121    folder_name
1122}
1123
1124/// Best-effort last activity time for a repo: the later of its last commit and the
1125/// newest source file mtime, which is the same value the idle check uses.
1126fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1127    to_utc(git::get_last_activity(path).ok().flatten())
1128}
1129
1130/// A `SystemTime` as the UTC timestamp the status entries carry.
1131fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1132    system_time.map(|st| {
1133        let duration = st
1134            .duration_since(SystemTime::UNIX_EPOCH)
1135            .unwrap_or_default();
1136        DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1137    })
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142    use super::*;
1143    use std::fs;
1144    use std::process::Command;
1145    use tempfile::TempDir;
1146
1147    /// Restore in these tests either fails before running anything or runs against an
1148    /// empty project; none of them should ever sit anywhere near this long.
1149    const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1150
1151    fn create_git_repo_with_commit(path: &Path) {
1152        fs::create_dir_all(path).unwrap();
1153        Command::new("git")
1154            .args(["init"])
1155            .current_dir(path)
1156            .output()
1157            .unwrap();
1158        fs::write(path.join("README.md"), "# Test").unwrap();
1159        Command::new("git")
1160            .args(["add", "."])
1161            .current_dir(path)
1162            .output()
1163            .unwrap();
1164        Command::new("git")
1165            .args([
1166                "-c",
1167                "user.name=Test",
1168                "-c",
1169                "user.email=test@test.com",
1170                "commit",
1171                "-m",
1172                "initial",
1173            ])
1174            .current_dir(path)
1175            .output()
1176            .unwrap();
1177    }
1178
1179    #[test]
1180    fn a_bloat_label_names_the_project_that_owns_it() {
1181        assert_eq!(owning_project("node_modules"), ".");
1182        assert_eq!(owning_project("frontend/node_modules"), "frontend");
1183        assert_eq!(
1184            owning_project("packages/@scope/app/.venv"),
1185            "packages/@scope/app"
1186        );
1187    }
1188
1189    #[test]
1190    fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1191        // Two npm projects, one of which was pruned. Restoring the whole tree would
1192        // reinstall both; only the recorded one may be attempted.
1193        let tmp = TempDir::new().unwrap();
1194        let root = tmp.path();
1195        for name in ["frontend", "docs"] {
1196            let dir = root.join(name);
1197            fs::create_dir_all(&dir).unwrap();
1198            fs::write(dir.join("package.json"), "{}").unwrap();
1199            fs::write(dir.join("package-lock.json"), "{}").unwrap();
1200        }
1201
1202        let deleted = vec![("frontend/node_modules".to_string(), "npm".to_string())];
1203        let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1204
1205        assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1206        assert_eq!(results[0].0, "npm (frontend/node_modules)");
1207    }
1208
1209    #[test]
1210    fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1211        // Recorded at prune time, gone by restore time. Reported, never dropped: a
1212        // restore that quietly skips half its work is the failure this command prevents.
1213        let tmp = TempDir::new().unwrap();
1214        let deleted = vec![("services/api/.venv".to_string(), "uv".to_string())];
1215        let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1216
1217        assert_eq!(results.len(), 1);
1218        assert_eq!(results[0].0, "uv (services/api/.venv)");
1219        let err = results[0].1.as_ref().unwrap_err().to_string();
1220        assert!(err.contains("services/api"), "names the missing project");
1221        assert!(err.contains("uv"), "names the adapter that owned it");
1222    }
1223
1224    #[test]
1225    fn test_prune_status_display() {
1226        assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1227        assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1228        assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1229    }
1230
1231    #[test]
1232    fn test_prune_repo_non_git() {
1233        let tmp = TempDir::new().unwrap();
1234        let results = prune_repo(tmp.path(), 15, false, false);
1235        assert!(results.is_empty());
1236    }
1237
1238    #[test]
1239    fn test_prune_repo_active_skipped() {
1240        let tmp = TempDir::new().unwrap();
1241        let repo = tmp.path().join("repo");
1242        create_git_repo_with_commit(&repo);
1243        // Just committed — active
1244        let results = prune_repo(&repo, 15, false, false);
1245        assert_eq!(results.len(), 1);
1246        assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1247    }
1248
1249    /// An unreadable `.devprune.json` must never fall back to defaults: the file may have
1250    /// said `"ignore": true`, and guessing would delete from a repo that opted out.
1251    #[test]
1252    fn test_unparseable_per_repo_config_skips_the_repo() {
1253        let tmp = TempDir::new().unwrap();
1254        let repo = tmp.path().join("repo");
1255        create_git_repo_with_commit(&repo);
1256        fs::create_dir(repo.join("target")).unwrap();
1257        fs::write(repo.join("target").join("dummy"), "data").unwrap();
1258        fs::write(
1259            repo.join("Cargo.toml"),
1260            "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1261        )
1262        .unwrap();
1263        fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1264        // Trailing comma — valid-looking, but not valid JSON.
1265        fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1266
1267        // Forced, non-dry-run: everything else would have this repo pruned.
1268        let results = prune_repo(&repo, 15, false, true);
1269
1270        assert_eq!(results.len(), 1);
1271        assert!(
1272            matches!(results[0].status, PruneStatus::ConfigError(_)),
1273            "expected ConfigError, got {:?}",
1274            results[0].status
1275        );
1276        assert!(repo.join("target").exists(), "target must survive");
1277    }
1278
1279    /// The dashboard and the prune pass have to agree about a broken config file.
1280    /// Reporting it as a healthy candidate with a size next to it invites the user to
1281    /// select a repository that `devp run` will then refuse to touch.
1282    #[test]
1283    fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1284        let tmp = TempDir::new().unwrap();
1285        let repo = tmp.path().join("repo");
1286        create_git_repo_with_commit(&repo);
1287        create_python_project(&repo);
1288        fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1289
1290        let mut registry = Registry::default();
1291        registry.add_repo(repo.clone());
1292
1293        let entries = get_full_status(&registry);
1294        assert_eq!(entries.len(), 1);
1295        assert!(
1296            matches!(entries[0].reason, SkipReason::ConfigError(_)),
1297            "expected ConfigError, got {:?}",
1298            entries[0].reason
1299        );
1300        assert_eq!(entries[0].reclaimable_bytes, 0);
1301    }
1302
1303    #[test]
1304    fn test_prune_repo_dry_run() {
1305        let tmp = TempDir::new().unwrap();
1306        let repo = tmp.path().join("repo");
1307        create_git_repo_with_commit(&repo);
1308        // Create target directory and Cargo.toml + Cargo.lock
1309        fs::create_dir(repo.join("target")).unwrap();
1310        fs::write(repo.join("target").join("dummy"), "data").unwrap();
1311        fs::write(
1312            repo.join("Cargo.toml"),
1313            "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2024\"",
1314        )
1315        .unwrap();
1316        fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1317        // Force + dry run → should report what WOULD be pruned
1318        let results = prune_repo(&repo, 15, true, true);
1319        let dry_run_results: Vec<_> = results
1320            .iter()
1321            .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
1322            .collect();
1323        assert!(!dry_run_results.is_empty());
1324        // target should still exist
1325        assert!(repo.join("target").exists());
1326    }
1327
1328    /// A Python project with a populated `requirements.txt` and a virtual environment.
1329    ///
1330    /// The venv adapter verifies its lockfile by reading files rather than by shelling
1331    /// out, so it is the one ecosystem that can be pruned for real inside a test.
1332    fn create_python_project(dir: &Path) {
1333        fs::create_dir_all(dir).unwrap();
1334        fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1335        let venv = dir.join(".venv");
1336        fs::create_dir_all(&venv).unwrap();
1337        fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1338        fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
1339    }
1340
1341    /// The `bloat_dir` labels of every result, sorted.
1342    fn labels(results: &[PruneResult]) -> Vec<String> {
1343        let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
1344        out.sort();
1345        out
1346    }
1347
1348    #[test]
1349    fn test_prune_finds_several_ecosystems_at_the_repo_root() {
1350        let tmp = TempDir::new().unwrap();
1351        let repo = tmp.path().join("repo");
1352        create_git_repo_with_commit(&repo);
1353
1354        fs::write(repo.join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
1355        fs::create_dir(repo.join("target")).unwrap();
1356        fs::write(repo.join("package.json"), "{}").unwrap();
1357        fs::write(repo.join("package-lock.json"), "{}").unwrap();
1358        fs::create_dir(repo.join("node_modules")).unwrap();
1359        create_python_project(&repo);
1360
1361        let results = prune_repo(&repo, 15, true, true);
1362        assert_eq!(labels(&results), vec![".venv", "node_modules", "target"]);
1363    }
1364
1365    #[test]
1366    fn test_prune_finds_ecosystems_at_different_depths() {
1367        let tmp = TempDir::new().unwrap();
1368        let repo = tmp.path().join("repo");
1369        create_git_repo_with_commit(&repo);
1370
1371        fs::create_dir_all(repo.join("frontend")).unwrap();
1372        fs::write(repo.join("frontend/package.json"), "{}").unwrap();
1373        fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
1374        fs::create_dir(repo.join("frontend/node_modules")).unwrap();
1375
1376        fs::create_dir_all(repo.join("tools/cli")).unwrap();
1377        fs::write(repo.join("tools/cli/Cargo.toml"), "[package]\nname = \"y\"").unwrap();
1378        fs::create_dir(repo.join("tools/cli/target")).unwrap();
1379
1380        create_python_project(&repo.join("services/api"));
1381
1382        let results = prune_repo(&repo, 15, true, true);
1383        assert_eq!(
1384            labels(&results),
1385            vec![
1386                "frontend/node_modules",
1387                "services/api/.venv",
1388                "tools/cli/target",
1389            ]
1390        );
1391    }
1392
1393    #[test]
1394    fn test_prune_deletes_only_the_selected_nested_directory() {
1395        let tmp = TempDir::new().unwrap();
1396        let repo = tmp.path().join("repo");
1397        create_git_repo_with_commit(&repo);
1398        create_python_project(&repo.join("a"));
1399        create_python_project(&repo.join("b"));
1400
1401        let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
1402
1403        assert_eq!(labels(&results), vec!["a/.venv"]);
1404        assert!(matches!(results[0].status, PruneStatus::Pruned));
1405        assert!(!repo.join("a/.venv").exists());
1406        assert!(repo.join("b/.venv").exists());
1407    }
1408
1409    #[test]
1410    fn test_prune_ignores_bloat_inside_a_nested_repository() {
1411        let tmp = TempDir::new().unwrap();
1412        let repo = tmp.path().join("repo");
1413        create_git_repo_with_commit(&repo);
1414        create_python_project(&repo.join("outer"));
1415
1416        // A submodule is its own repository with its own activity history — pruning it
1417        // as part of the parent would ignore that.
1418        let nested = repo.join("nested");
1419        create_git_repo_with_commit(&nested);
1420        create_python_project(&nested);
1421
1422        let results = prune_repo(&repo, 15, true, true);
1423        assert_eq!(labels(&results), vec!["outer/.venv"]);
1424    }
1425
1426    #[test]
1427    fn test_prune_repo_no_adapters() {
1428        let tmp = TempDir::new().unwrap();
1429        let repo = tmp.path().join("repo");
1430        create_git_repo_with_commit(&repo);
1431        // Force prune but no package manager files
1432        let results = prune_repo(&repo, 15, false, true);
1433        assert!(
1434            results
1435                .iter()
1436                .any(|r| matches!(r.status, PruneStatus::NoBloat))
1437        );
1438    }
1439
1440    #[test]
1441    fn test_prune_all_disabled() {
1442        let tmp = TempDir::new().unwrap();
1443        let _registry_path = tmp.path().join("registry.json");
1444
1445        let mut registry = Registry::default();
1446        let repo_path = PathBuf::from("/nonexistent/repo");
1447        registry.add_repo(repo_path.clone());
1448        registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
1449
1450        let results = prune_all(&mut registry, false, false);
1451        assert!(
1452            results
1453                .iter()
1454                .any(|r| matches!(r.status, PruneStatus::Disabled))
1455        );
1456    }
1457
1458    #[test]
1459    fn test_restore_project_no_adapters() {
1460        let tmp = TempDir::new().unwrap();
1461        let result = restore_project_to_depth(
1462            tmp.path(),
1463            crate::constants::DEFAULT_SCAN_DEPTH,
1464            TEST_TIMEOUT,
1465        );
1466        assert!(result.is_err());
1467    }
1468
1469    #[test]
1470    fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
1471        // Deleting a venv removes the very pyvenv.cfg that detection looks for, so
1472        // re-detection finds nothing. The recorded adapter must still be attempted —
1473        // not reported as "no longer a venv project".
1474        let tmp = TempDir::new().unwrap();
1475        let api = tmp.path().join("api");
1476        fs::create_dir_all(&api).unwrap();
1477        fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1478        // No .venv on disk — the prune already removed it.
1479
1480        let deleted = vec![("api/.venv".to_string(), "venv".to_string())];
1481        // A zero timeout kills the rebuild the moment it starts; the test is about
1482        // which branch routes, not whether python can build an environment here.
1483        let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
1484
1485        assert_eq!(results.len(), 1);
1486        assert_eq!(results[0].0, "venv (api/.venv)");
1487        if let Err(e) = &results[0].1 {
1488            assert!(
1489                !e.to_string().contains("no longer a"),
1490                "the recorded adapter must be attempted, got: {e}"
1491            );
1492        }
1493    }
1494
1495    #[test]
1496    fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
1497        // A vendored checkout inside the directory about to be deleted carries its own
1498        // history, which no lockfile rebuilds. The whole delete must be refused.
1499        let tmp = TempDir::new().unwrap();
1500        let repo = tmp.path().join("repo");
1501        create_git_repo_with_commit(&repo);
1502        create_python_project(&repo);
1503        fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
1504
1505        let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
1506
1507        assert_eq!(results.len(), 1);
1508        let PruneStatus::DeleteError(msg) = &results[0].status else {
1509            panic!("expected a refusal, got {:?}", results[0].status);
1510        };
1511        assert!(msg.contains("git repository"), "says why: {msg}");
1512        assert!(repo.join(".venv").exists(), "nothing may be deleted");
1513        assert_eq!(results[0].size_freed, 0);
1514    }
1515
1516    fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
1517        RepoStatusEntry {
1518            path: PathBuf::from(name),
1519            entry: RepoEntry::new(),
1520            reason: SkipReason::Candidate,
1521            adapters: Vec::new(),
1522            bloat_dirs: Vec::new(),
1523            reclaimable_bytes: reclaimable,
1524            last_activity: None,
1525            idle_days: 15,
1526        }
1527    }
1528
1529    #[test]
1530    fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
1531        let repos = [
1532            status_entry("small", 10),
1533            status_entry("big", 300),
1534            status_entry("mid", 200),
1535        ];
1536        let names: Vec<String> = take_top(&repos, Some(2))
1537            .iter()
1538            .map(|e| e.path.display().to_string())
1539            .collect();
1540        // Selection is by reclaimable bytes; the survivors come back in the order the
1541        // full dashboard had them, so a truncated list reads like a shorter version of
1542        // the full one rather than a differently-sorted one.
1543        assert_eq!(names, vec!["big", "mid"]);
1544    }
1545
1546    #[test]
1547    fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
1548        let repos = [status_entry("a", 1), status_entry("b", 2)];
1549        assert_eq!(take_top(&repos, None).len(), 2);
1550        assert_eq!(take_top(&repos, Some(10)).len(), 2);
1551        assert_eq!(take_top(&repos, Some(0)).len(), 0);
1552    }
1553
1554    #[test]
1555    fn test_restore_project_with_npm() {
1556        let tmp = TempDir::new().unwrap();
1557        fs::write(tmp.path().join("package.json"), "{}").unwrap();
1558        fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1559        let results = restore_project_to_depth(
1560            tmp.path(),
1561            crate::constants::DEFAULT_SCAN_DEPTH,
1562            TEST_TIMEOUT,
1563        );
1564        // Will fail because npm isn't available in test env, but shouldn't panic
1565        assert!(results.is_ok());
1566        let results = results.unwrap();
1567        assert!(!results.is_empty());
1568        assert_eq!(results[0].0, "npm");
1569    }
1570}