Skip to main content

dev_prune/
engine.rs

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