Skip to main content

dev_prune/commands/
run.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for the `dev-prune run` command.
5//
6// Executes a full prune pass across all registered repositories.
7// Supports pre-deletion analysis, optimized ecosystem binary pre-checks,
8// interactive TUI multi-selection, progressive deletion, shell-specific
9// troubleshooting, and interactive error fallback.
10
11use anyhow::Result;
12use std::io::{self, IsTerminal, Write};
13use std::path::Path;
14
15use crate::adapters;
16use crate::config::Registry;
17use crate::constants;
18use crate::engine::{self, AdapterFilter, PruneOptions, PruneResult, PruneStatus};
19use crate::json;
20use crate::output;
21use crate::tui;
22
23/// Everything `devp run` was asked to do.
24///
25/// A struct rather than nine positional parameters: the call site in `run_cli` reads as
26/// a list of names, and adding a flag does not silently shift an argument.
27pub struct RunArgs<'a> {
28    /// Optional single workspace to act on instead of the whole registry.
29    pub target_path: Option<&'a str>,
30    /// Report sizes and stop.
31    pub dry_run: bool,
32    /// Bypass the idle threshold. Lockfile verification still applies.
33    pub force: bool,
34    /// Skip the confirmation prompt.
35    pub yes: bool,
36    /// This is the scheduled background pass.
37    pub daemon: bool,
38    /// Comma-separated adapters to act on exclusively.
39    pub only: Option<&'a str>,
40    /// Comma-separated adapters to leave alone.
41    pub skip: Option<&'a str>,
42    /// Size floor in MiB, overriding the configured `min_size_mb`.
43    pub min_size_mb: Option<u64>,
44    /// Comma-separated repositories to leave completely alone this pass.
45    pub except: Option<&'a str>,
46    /// Emit one JSON document instead of the human report.
47    pub json: bool,
48}
49
50/// Run the `run` command — prune all registered repos or a specific target directory (`devp run .`).
51///
52/// `daemon` marks the scheduled background pass; repositories that set `disable_daemon`
53/// in `.devprune.json` are excluded from it but remain pruneable by hand.
54pub fn run(args: RunArgs<'_>) -> Result<()> {
55    let filter = AdapterFilter::new(args.only, args.skip)?;
56
57    // In JSON mode there is no one to answer a prompt and no terminal to draw a selector
58    // in, so deletion has to have been authorised on the command line. Failing loudly
59    // beats either silently deleting or silently doing nothing.
60    if args.json && !args.dry_run && !args.yes {
61        anyhow::bail!(
62            "`--json` cannot ask for confirmation. Pass `--dry-run` to analyse, or `--yes` to delete."
63        );
64    }
65
66    if !args.json {
67        output::print_banner();
68        if args.force {
69            print_ignore_idle_notice();
70        }
71    }
72
73    if let Some(target_str) = args.target_path {
74        return run_targeted(&args, &filter, target_str);
75    }
76    run_registry(&args, &filter)
77}
78
79/// What `--ignore-idle` does and, more usefully, what it does not.
80///
81/// Printed whenever the idle check is bypassed, because that is the moment someone is
82/// most likely to be working around a problem rather than solving it — and the problem
83/// they hit is almost always one of the three below. Suppressed in JSON mode, where the
84/// document is the contract and prose on stdout would corrupt it.
85fn print_ignore_idle_notice() {
86    output::print_warning(
87        "Idle check bypassed — repositories you are working in right now are fair game.",
88    );
89    println!(
90        "  Still enforced: lockfile verification, `ignore.devprune.json`, `\"ignore\": true`,"
91    );
92    println!(
93        "  symlinked directories, and nested repositories. This flag does not turn those off."
94    );
95    println!();
96    println!("  If you reached for this because something would not prune, it is usually:");
97    println!("    • \"lockfile verification failed\"  → run the fix command printed next to it;");
98    println!("      it regenerates the lockfile so the reinstall is guaranteed to work.");
99    println!("    • nothing listed at all            → the project is deeper than `scan_depth`,");
100    println!("      or under `min_size_mb`. Try `devp status` to see what dev-prune can see.");
101    println!("    • \"could not be examined\"          → `.devprune.json` has a syntax error.");
102    println!();
103    println!("  Still stuck? Point your AI assistant at the bundled skill — `devp skill`");
104    println!("  exports a SKILL.md that teaches it this tool, exit codes and all. It has");
105    println!("  read the manual more recently than either of us.");
106    println!();
107}
108
109/// `devp run <PATH>` — one workspace, no registry, no selector.
110fn run_targeted(args: &RunArgs<'_>, filter: &AdapterFilter, target_str: &str) -> Result<()> {
111    let raw = std::path::Path::new(target_str);
112    let path = if raw.exists() {
113        raw.canonicalize().unwrap_or_else(|_| raw.to_path_buf())
114    } else {
115        raw.to_path_buf()
116    };
117
118    let clean = output::clean_path(&path);
119    if !crate::scanner::is_git_repo(&path) {
120        // Returning Ok here made `devp run <path>` exit 0 on a path it refused to
121        // touch, which is invisible to any script or CI step checking the status.
122        anyhow::bail!("{clean} is not a Git repository — dev-prune only prunes Git repos.");
123    }
124
125    // A targeted run still respects the configured idle threshold. Passing 0 here
126    // would make every repo look idle and silently defeat the guard — `--ignore-idle` is
127    // the documented way to prune a repo you are actively working in.
128    let registry = Registry::load().ok();
129    let idle_days = registry
130        .as_ref()
131        .map(|r| {
132            r.repositories
133                .get(&path)
134                .and_then(|e| e.override_idle_days)
135                .unwrap_or(r.settings.idle_days)
136        })
137        .unwrap_or(constants::DEFAULT_IDLE_DAYS);
138
139    let opts = PruneOptions {
140        idle_days,
141        dry_run: args.dry_run,
142        force: args.force,
143        only_dirs: None,
144        adapters: filter.clone(),
145        min_size_bytes: resolve_min_size(args, registry.as_ref()),
146        scan_depth: resolve_scan_depth(registry.as_ref()),
147        allow_manifest_rewrite: resolve_manifest_rewrite(registry.as_ref()),
148        command_timeout_secs: resolve_command_timeout(registry.as_ref()),
149    };
150
151    let results = engine::prune_repo_with(&path, &opts);
152
153    // A directory that could not be verified or deleted is a failure of the command,
154    // whichever output mode asked for it. `devp run <path>` used to exit 0 after a
155    // lockfile or delete error, which a script or CI step has no way to notice.
156    let error_count = results
157        .iter()
158        .filter(|r| {
159            matches!(
160                r.status,
161                PruneStatus::LockfileError(_)
162                    | PruneStatus::DeleteError(_)
163                    | PruneStatus::ConfigError(_)
164            )
165        })
166        .count();
167
168    // Recorded before the output branches, so `--json` and the human report leave the
169    // same registry behind. A targeted run used to update neither the lifetime totals nor
170    // anything `restore` could read: `devp run .` freed two gigabytes and `devp status`
171    // still said nothing had ever been pruned.
172    record_targeted_prune(&path, &results, args.dry_run);
173
174    if args.json {
175        json::emit(&json::run_document(&results, args.dry_run))?;
176        if error_count > 0 {
177            anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
178        }
179        return Ok(());
180    }
181
182    output::print_header(&format!("dev-prune Targeted Run ({clean})"));
183    if let Some(desc) = filter.describe() {
184        output::print_info(&format!("Adapter filter: {desc}"));
185    }
186
187    if results.is_empty() {
188        output::print_info(&format!("No pruneable bloat directories found in {clean}."));
189        return Ok(());
190    }
191
192    let mut total_freed = 0;
193    for result in results {
194        match &result.status {
195            PruneStatus::Pruned => {
196                total_freed += result.size_freed;
197                output::print_success(&format!(
198                    "{} → {} ({}) — {}",
199                    output::clean_path(&result.repo_path),
200                    result.bloat_dir,
201                    output::format_bytes(result.size_freed),
202                    result.adapter_name
203                ));
204            }
205            PruneStatus::SkippedDryRun => {
206                output::print_info(&format!(
207                    "  • {} → {} ({}) [{}] (Dry Run)",
208                    output::clean_path(&result.repo_path),
209                    result.bloat_dir,
210                    output::format_bytes(result.size_freed),
211                    result.adapter_name
212                ));
213            }
214            PruneStatus::SkippedActive => {
215                output::print_info(&format!(
216                    "{clean} is currently active (not idle). Use `devp --ignore-idle run` to override."
217                ));
218            }
219            PruneStatus::LockfileError(e) => {
220                output::print_error(&format!("{clean} lockfile sync failed:\n    {}", e.trim()));
221            }
222            PruneStatus::DeleteError(e) => {
223                output::print_error(&format!("{clean} delete error: {e}"));
224            }
225            PruneStatus::ConfigError(e) => {
226                output::print_error(&format!(
227                    "{clean} skipped — its .devprune.json could not be read:\n    {}\n    \
228                     Fix it, or run `devp config {clean} --update` to reset it.",
229                    e.trim()
230                ));
231            }
232            _ => {}
233        }
234    }
235
236    if !args.dry_run && total_freed > 0 {
237        output::print_success(&format!(
238            "Freed: {} in {clean}",
239            output::format_bytes(total_freed)
240        ));
241    }
242
243    if error_count > 0 {
244        anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
245    }
246
247    Ok(())
248}
249
250/// Persist what a targeted run deleted: the lifetime totals and the `--last-run` record.
251///
252/// Silent on every failure. The directories are already gone by the time this is called,
253/// and a registry that could not be written is not a reason to report the prune itself as
254/// failed — it only costs the user `devp restore --last-run` for this one pass.
255fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
256    if dry_run {
257        return;
258    }
259
260    let pruned: Vec<crate::config::PrunedDir> = results
261        .iter()
262        .filter(|r| matches!(r.status, PruneStatus::Pruned))
263        .map(|r| crate::config::PrunedDir {
264            repo_path: r.repo_path.clone(),
265            bloat_dir: r.bloat_dir.clone(),
266            adapter: r.adapter_name.clone(),
267            size_freed: r.size_freed,
268        })
269        .collect();
270
271    if pruned.is_empty() {
272        return;
273    }
274
275    let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
276    if let Ok(mut registry) = Registry::load() {
277        registry.mark_pruned(path, freed);
278        registry.record_prune(pruned);
279        let _ = registry.save();
280    }
281}
282
283/// The size floor for this pass: `--min-size` if given, otherwise the global setting.
284///
285/// A per-repository `min_size_mb` still wins over both — that decision belongs to the
286/// repository and is applied inside the engine.
287fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
288    let mb = args
289        .min_size_mb
290        .or_else(|| registry.map(|r| r.settings.min_size_mb))
291        .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
292    mb.saturating_mul(engine::BYTES_PER_MIB)
293}
294
295/// Repositories named by `--except`, as a set of lowercased names and path fragments.
296///
297/// Empty when the flag was not passed.
298///
299/// Each entry is tilde-expanded first, because a comma-separated list arrives as one
300/// argument and no shell expands a `~` sitting in the middle of it — not even bash.
301fn parse_except(spec: Option<&str>) -> Vec<String> {
302    spec.map(|s| {
303        s.split(',')
304            .map(|part| {
305                crate::config::expand_tilde(part.trim())
306                    .trim_end_matches(['/', '\\'])
307                    .to_lowercase()
308            })
309            .filter(|part| !part.is_empty())
310            .collect()
311    })
312    .unwrap_or_default()
313}
314
315/// Whether `--except` names this repository.
316///
317/// Matched three ways because there are three things a user reasonably types: the folder
318/// name (`api`), a path fragment (`work/api`), or the full path they see in `devp status`.
319/// Case-insensitive, and `/` and `\` are treated as the same separator, so the flag
320/// behaves the same in PowerShell and in bash.
321fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
322    if except.is_empty() {
323        return false;
324    }
325    let full = output::clean_path(repo_path)
326        .to_lowercase()
327        .replace('\\', "/");
328    let name = repo_path
329        .file_name()
330        .map(|n| n.to_string_lossy().to_lowercase())
331        .unwrap_or_default();
332
333    except.iter().any(|want| {
334        let want = want.replace('\\', "/");
335        name == want || full == want || full.ends_with(&format!("/{want}"))
336    })
337}
338
339/// The global scan depth, falling back to the default when there is no registry yet.
340fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
341    registry
342        .map(|r| r.settings.scan_depth)
343        .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
344}
345
346/// The ceiling on any one package-manager command, in seconds.
347fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
348    registry
349        .map(|r| r.settings.command_timeout_secs)
350        .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
351}
352
353/// Whether an adapter may run its lockfile-rewriting sync command.
354fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
355    registry
356        .map(|r| r.settings.allow_manifest_rewrite)
357        .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
358}
359
360/// `devp run` — the full pass over every registered repository.
361fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
362    if !args.json {
363        if args.dry_run {
364            output::print_header("dev-prune run (DRY RUN)");
365        } else {
366            output::print_header("dev-prune run");
367        }
368    }
369
370    let mut registry = Registry::load()?;
371
372    // Suppressed in JSON mode: the document is a contract, and a version notice printed
373    // into it would corrupt the output.
374    if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
375        let _ = registry.save();
376    }
377
378    if registry.repo_count() == 0 {
379        if args.json {
380            return json::emit(&json::run_document(&[], args.dry_run));
381        }
382        output::print_warning("No repositories registered. Run `dev-prune init` first.");
383        return Ok(());
384    }
385
386    // Validated against the registry *before* anything is analysed. A name that matches
387    // nothing is a typo, and the cost of a silent typo here is the one repository the
388    // user was trying to protect getting pruned — so it is an error, not a no-op.
389    let except = parse_except(args.except);
390    if !except.is_empty() {
391        let unmatched: Vec<&String> = except
392            .iter()
393            .filter(|want| {
394                !registry
395                    .repositories
396                    .keys()
397                    .any(|p| is_excepted(p, std::slice::from_ref(*want)))
398            })
399            .collect();
400        if !unmatched.is_empty() {
401            anyhow::bail!(
402                "`--except` names no registered repository: {}\n  \
403                 Run `devp status` to see the registered names.",
404                unmatched
405                    .iter()
406                    .map(|s| s.as_str())
407                    .collect::<Vec<_>>()
408                    .join(", ")
409            );
410        }
411    }
412
413    let min_size_bytes = resolve_min_size(args, Some(&registry));
414    let analysis = PruneOptions {
415        idle_days: 0, // replaced per repository from the registry
416        dry_run: true,
417        force: args.force,
418        only_dirs: None,
419        adapters: filter.clone(),
420        min_size_bytes,
421        scan_depth: resolve_scan_depth(Some(&registry)),
422        allow_manifest_rewrite: resolve_manifest_rewrite(Some(&registry)),
423        command_timeout_secs: resolve_command_timeout(Some(&registry)),
424    };
425
426    if !args.json {
427        output::print_info(&format!(
428            "Scanning {} registered repositories for prune candidates...",
429            registry.repo_count()
430        ));
431        if let Some(desc) = filter.describe() {
432            output::print_info(&format!("Adapter filter: {desc}"));
433        }
434        if min_size_bytes > 0 {
435            output::print_info(&format!(
436                "Size floor: ignoring directories under {}",
437                output::format_bytes(min_size_bytes)
438            ));
439        }
440    }
441
442    // Pre-run analysis (dry-run mode first to compute exact savings)
443    //
444    // Two lists come out of it, and both are reported. A repository the analysis refused
445    // to examine — an unreadable `.devprune.json`, most often — used to be dropped here
446    // along with every other non-candidate state, so a pass that had quietly skipped it
447    // still ended on "No idle repositories or pruneable bloat directories found." and
448    // exit 0. The execution loop further down knows how to report these states, but it
449    // only ever sees selected candidates, so it never got the chance.
450    let mut candidates: Vec<PruneResult> = Vec::new();
451    let mut blocked: Vec<PruneResult> = Vec::new();
452    for result in engine::prune_all_with(&mut registry, &analysis) {
453        // An excepted repository leaves the pass entirely — including its failures. The
454        // user said not to touch it, so a broken config in there is not this run's
455        // problem and must not fail an otherwise clean exit code.
456        if is_excepted(&result.repo_path, &except) {
457            continue;
458        }
459        match result.status {
460            PruneStatus::SkippedDryRun => candidates.push(result),
461            PruneStatus::ConfigError(_)
462            | PruneStatus::LockfileError(_)
463            | PruneStatus::DeleteError(_) => blocked.push(result),
464            _ => {}
465        }
466    }
467
468    if !args.json && !except.is_empty() {
469        output::print_info(&format!("Leaving alone: {}", except.join(", ")));
470    }
471
472    if args.daemon {
473        let before = candidates.len();
474        candidates.retain(|c| {
475            // An unreadable config drops the candidate. The engine already refuses such a
476            // repository outright, so this cannot fire today; if that ever changes, the
477            // unattended pass must not be the code path that guesses.
478            match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
479                Ok(Some(cfg)) => !cfg.disable_daemon,
480                Ok(None) => true,
481                Err(_) => false,
482            }
483        });
484        let skipped = before - candidates.len();
485        if skipped > 0 && !args.json {
486            output::print_info(&format!(
487                "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
488            ));
489        }
490    }
491
492    // A dry run stops here in both output modes: sizes are known, nothing was verified.
493    if args.dry_run {
494        if args.json {
495            json::emit(&json::run_document(&[candidates, blocked].concat(), true))?;
496            return Ok(());
497        }
498        if candidates.is_empty() && blocked.is_empty() {
499            output::print_info("No idle repositories or pruneable bloat directories found.");
500            return Ok(());
501        }
502        if !candidates.is_empty() {
503            report_candidates(&candidates);
504        }
505        let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
506        output::print_header("Summary (Dry Run)");
507        output::print_info(&format!(
508            "Would free {} across {} bloat directories.",
509            output::format_bytes(total),
510            candidates.len()
511        ));
512        // Reported, but not an error: a dry run's job is to say what it found, and it
513        // found this too.
514        report_blocked(&blocked);
515        return Ok(());
516    }
517
518    if candidates.is_empty() {
519        if args.json {
520            json::emit(&json::run_document(&blocked, false))?;
521            return fail_if_blocked(&blocked);
522        }
523        if blocked.is_empty() {
524            output::print_info("No idle repositories or pruneable bloat directories found.");
525            return Ok(());
526        }
527        output::print_info("No pruneable bloat directories found.");
528        report_blocked(&blocked);
529        return fail_if_blocked(&blocked);
530    }
531
532    let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
533
534    if !args.json {
535        report_binaries(&candidates);
536        report_candidates(&candidates);
537        output::print_info(&format!(
538            "Total Reclaimable Space: {}",
539            output::format_bytes(total_reclaimable)
540        ));
541        report_blocked(&blocked);
542    }
543
544    // Determine target candidates to prune (either interactive TUI selection or all).
545    // `--json` short-circuits both: it was already required to carry `--yes`.
546    let target_candidates: Vec<PruneResult> = if args.json
547        || args.yes
548        || !registry.settings.require_confirmation
549    {
550        candidates
551    } else if io::stdout().is_terminal() {
552        eprintln!();
553        eprintln!(
554            "  Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
555        );
556        eprintln!();
557        let selected = tui::selection_view::select_candidates_tui(&candidates)?;
558        if selected.is_empty() {
559            output::print_info("Prune pass cancelled by user (0 candidates selected).");
560            return Ok(());
561        }
562        selected
563    } else {
564        println!();
565        output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
566        output::print_info(
567            "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
568        );
569        print!(
570            "Proceed with deletion of {} directories ({})? [y/N]: ",
571            candidates.len(),
572            output::format_bytes(total_reclaimable)
573        );
574        io::stdout().flush()?;
575
576        let mut input = String::new();
577        io::stdin().read_line(&mut input)?;
578        let trimmed = input.trim().to_lowercase();
579        if trimmed != "y" && trimmed != "yes" {
580            output::print_info("Prune pass aborted by user.");
581            return Ok(());
582        }
583        candidates
584    };
585
586    if !args.json {
587        let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
588        output::print_header(&format!(
589            "Executing Progressive Deletion ({} repos, {})",
590            target_candidates.len(),
591            output::format_bytes(selected_total_bytes)
592        ));
593    }
594
595    // Execute deletion ONLY on the selected bloat directories.
596    //
597    // The selector works per bloat directory, so group the selection by repo and pass
598    // the chosen directory names down — pruning the whole repo would delete dirs the
599    // user explicitly unticked.
600    let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
601    for candidate in &target_candidates {
602        match selection
603            .iter_mut()
604            .find(|(p, _)| *p == candidate.repo_path)
605        {
606            Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
607            None => selection.push((
608                candidate.repo_path.clone(),
609                vec![candidate.bloat_dir.clone()],
610            )),
611        }
612    }
613
614    // Seeded with what the analysis pass could not get past. Those repositories belong in
615    // the document and in the exit code exactly as much as a failure from the loop below.
616    let mut error_count = blocked.len();
617    let mut all_results: Vec<PruneResult> = blocked;
618    let mut total_freed: u64 = 0;
619    let mut pruned_count = 0;
620    let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
621
622    for (repo_path, dirs) in &selection {
623        // Candidates were already filtered through the idle check during the dry-run
624        // analysis pass, so re-checking here would only re-walk the tree.
625        let single_results = engine::prune_repo_with(
626            repo_path,
627            &PruneOptions {
628                idle_days: 0,
629                dry_run: false,
630                force: true,
631                only_dirs: Some(dirs.clone()),
632                adapters: filter.clone(),
633                min_size_bytes: 0,
634                scan_depth: analysis.scan_depth,
635                allow_manifest_rewrite: analysis.allow_manifest_rewrite,
636                command_timeout_secs: analysis.command_timeout_secs,
637            },
638        );
639        for result in single_results {
640            match &result.status {
641                PruneStatus::Pruned => {
642                    total_freed += result.size_freed;
643                    pruned_count += 1;
644                    registry.mark_pruned(&result.repo_path, result.size_freed);
645                    pruned_dirs.push(crate::config::PrunedDir {
646                        repo_path: result.repo_path.clone(),
647                        bloat_dir: result.bloat_dir.clone(),
648                        adapter: result.adapter_name.clone(),
649                        size_freed: result.size_freed,
650                    });
651                    if !args.json {
652                        output::print_success(&format!(
653                            "{} → {} ({}) — {}",
654                            output::clean_path(&result.repo_path),
655                            result.bloat_dir,
656                            output::format_bytes(result.size_freed),
657                            result.adapter_name,
658                        ));
659                    }
660                }
661                PruneStatus::LockfileError(e) => {
662                    error_count += 1;
663                    if !args.json {
664                        report_lockfile_failure(&result, e);
665                    }
666                }
667                PruneStatus::DeleteError(e) => {
668                    error_count += 1;
669                    if !args.json {
670                        output::print_error(&format!(
671                            "{} → delete failed: {}",
672                            output::clean_path(&result.repo_path),
673                            e,
674                        ));
675                    }
676                }
677                PruneStatus::ConfigError(e) => {
678                    error_count += 1;
679                    if !args.json {
680                        let clean_p = output::clean_path(&result.repo_path);
681                        output::print_error(&format!(
682                            "{clean_p} skipped — its .devprune.json could not be read:\n    {}",
683                            e.trim()
684                        ));
685                        output::print_info(&format!(
686                            "  Fix command:       devp config {clean_p} --update"
687                        ));
688                    }
689                }
690                _ => {}
691            }
692            all_results.push(result);
693        }
694    }
695
696    registry.record_prune(pruned_dirs);
697    registry.save()?;
698
699    if args.json {
700        json::emit(&json::run_document(&all_results, false))?;
701        // The document already carries `summary.errors`; a non-zero exit keeps the
702        // shell contract identical in both output modes.
703        if error_count > 0 {
704            anyhow::bail!("{error_count} repositories could not be pruned.");
705        }
706        return Ok(());
707    }
708
709    output::print_header("Summary");
710    output::print_success(&format!(
711        "Freed: {} across {pruned_count} directories",
712        output::format_bytes(total_freed)
713    ));
714
715    if error_count > 0 {
716        output::print_warning(&format!("{error_count} repos were not pruned."));
717
718        // Only when a lockfile was actually the problem. `error_count` also counts
719        // unreadable configs and failed deletions, and a lecture about lockfiles in front
720        // of a JSON syntax error sends the user to the wrong file.
721        if all_results
722            .iter()
723            .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
724        {
725            // Lockfile enforcement is not overridable — `--ignore-idle` only bypasses the idle
726            // check. Without a lockfile a deleted dependency tree cannot be rebuilt, so
727            // point at the fix instead of offering an override that does not exist.
728            output::print_info(
729                "Lockfile verification cannot be bypassed: without a lockfile the deleted \
730                 dependencies could not be reinstalled. Run the fix command shown above for \
731                 each repo, then re-run `devp run`.",
732            );
733        }
734        // Exit non-zero so a scheduled or scripted run surfaces the failure.
735        anyhow::bail!("{error_count} repositories could not be pruned.");
736    }
737
738    Ok(())
739}
740
741/// Report the repositories the analysis pass could not get past, with the fix for each.
742///
743/// Silent for an empty list, so callers do not have to guard it.
744fn report_blocked(blocked: &[PruneResult]) {
745    if blocked.is_empty() {
746        return;
747    }
748    output::print_header("Repositories That Could Not Be Examined");
749    for result in blocked {
750        let clean_p = output::clean_path(&result.repo_path);
751        match &result.status {
752            PruneStatus::ConfigError(e) => {
753                output::print_error(&format!(
754                    "{clean_p} skipped — its .devprune.json could not be read:\n    {}",
755                    e.trim()
756                ));
757                output::print_info(&format!(
758                    "  Fix command:       devp config {clean_p} --update"
759                ));
760            }
761            PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
762            PruneStatus::DeleteError(e) => {
763                output::print_error(&format!("{clean_p} → delete failed: {e}"));
764            }
765            // `blocked` is built from exactly the three arms above.
766            _ => {}
767        }
768    }
769}
770
771/// Turn a non-empty blocked list into the process's failure exit.
772///
773/// A pass that skipped a repository the user asked it to handle has not succeeded, and a
774/// scheduled or scripted run has to be able to see that.
775fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
776    if blocked.is_empty() {
777        return Ok(());
778    }
779    anyhow::bail!("{} repositories could not be examined.", blocked.len());
780}
781
782/// Report which ecosystem binaries the pass will need and whether they are present.
783fn report_binaries(candidates: &[PruneResult]) {
784    let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
785    let binary_statuses = adapters::scan_required_binaries(&adapter_names);
786    if binary_statuses.is_empty() {
787        return;
788    }
789    output::print_header("Required Ecosystem Binaries Pre-Check");
790    for b in &binary_statuses {
791        if b.available {
792            output::print_success(&format!(
793                "  {} — available ({})",
794                b.name,
795                b.version.as_deref().unwrap_or("detected")
796            ));
797        } else {
798            output::print_warning(&format!(
799                "  {} — missing (lockfile fallback active)",
800                b.name
801            ));
802        }
803    }
804}
805
806fn report_candidates(candidates: &[PruneResult]) {
807    output::print_header("Prune Candidates & Space Savings Calculation");
808    for candidate in candidates {
809        output::print_info(&format!(
810            "  • {} → {} ({}) [{}]",
811            output::clean_path(&candidate.repo_path),
812            candidate.bloat_dir,
813            output::format_bytes(candidate.size_freed),
814            candidate.adapter_name
815        ));
816    }
817}
818
819fn report_lockfile_failure(result: &PruneResult, error: &str) {
820    let clean_p = output::clean_path(&result.repo_path);
821    let sync_cmd_help =
822        json::lockfile_fix_command(&result.adapter_name).unwrap_or("check the adapter's docs");
823
824    #[cfg(windows)]
825    let manual_cmd = format!("cd \"{}\"; {}", clean_p, sync_cmd_help);
826    #[cfg(not(windows))]
827    let manual_cmd = format!("cd \"{}\" && {}", clean_p, sync_cmd_help);
828
829    output::print_error(&format!(
830        "{} → {} lockfile sync failed:\n    {}",
831        clean_p,
832        result.adapter_name,
833        error.trim(),
834    ));
835    output::print_info(&format!("  Fix command:       {}", manual_cmd));
836    output::print_info(&format!(
837        "  Troubleshooting:   {}",
838        constants::TROUBLESHOOTING_URL
839    ));
840}