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