1use 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
23pub struct RunArgs<'a> {
28 pub target_path: Option<&'a str>,
30 pub dry_run: bool,
32 pub force: bool,
34 pub yes: bool,
36 pub daemon: bool,
38 pub only: Option<&'a str>,
40 pub skip: Option<&'a str>,
42 pub min_size_mb: Option<u64>,
44 pub except: Option<&'a str>,
46 pub json: bool,
48}
49
50pub fn run(args: RunArgs<'_>) -> Result<()> {
55 let filter = AdapterFilter::new(args.only, args.skip)?;
56
57 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
79fn 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
109fn 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 anyhow::bail!("{clean} is not a Git repository — dev-prune only prunes Git repos.");
123 }
124
125 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 let error_count = results
157 .iter()
158 .filter(|r| {
159 matches!(
160 r.status,
161 PruneStatus::LockfileError(_)
162 | PruneStatus::ActivityCheckError(_)
163 | PruneStatus::DeleteError(_)
164 | PruneStatus::ConfigError(_)
165 )
166 })
167 .count();
168
169 record_targeted_prune(&path, &results, args.dry_run);
174
175 if args.json {
176 json::emit(&json::run_document(&results, args.dry_run))?;
177 if error_count > 0 {
178 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
179 }
180 return Ok(());
181 }
182
183 output::print_header(&format!("dev-prune Targeted Run ({clean})"));
184 if let Some(desc) = filter.describe() {
185 output::print_info(&format!("Adapter filter: {desc}"));
186 }
187
188 if results.is_empty() {
189 output::print_info(&format!("No pruneable bloat directories found in {clean}."));
190 return Ok(());
191 }
192
193 let mut total_freed = 0;
194 for result in results {
195 match &result.status {
196 PruneStatus::Pruned => {
197 total_freed += result.size_freed;
198 output::print_success(&format!(
199 "{} → {} ({}) — {}{}",
200 output::clean_path(&result.repo_path),
201 result.bloat_dir,
202 output::format_bytes(result.size_freed),
203 result.adapter_name,
204 output::shared_note(result.shared_bytes, &result.adapter_name)
205 ));
206 }
207 PruneStatus::SkippedDryRun => {
208 output::print_info(&format!(
209 " • {} → {} ({}) [{}] (Dry Run){}",
210 output::clean_path(&result.repo_path),
211 result.bloat_dir,
212 output::format_bytes(result.size_freed),
213 result.adapter_name,
214 output::shared_note(result.shared_bytes, &result.adapter_name)
215 ));
216 }
217 PruneStatus::SkippedActive => {
218 output::print_info(&format!(
219 "{clean} is currently active (not idle). Use `devp --ignore-idle run` to override."
220 ));
221 }
222 PruneStatus::LockfileError(e) => {
223 output::print_error(&format!("{clean} lockfile sync failed:\n {}", e.trim()));
224 }
225 PruneStatus::ActivityCheckError(e) => {
226 output::print_error(&format!(
227 "{clean} skipped — its activity could not be determined:\n {}",
228 e.trim()
229 ));
230 }
231 PruneStatus::DeleteError(e) => {
232 output::print_error(&format!("{clean} delete error: {e}"));
233 }
234 PruneStatus::ConfigError(e) => {
235 output::print_error(&format!(
236 "{clean} skipped — its .devprune.json could not be read:\n {}\n \
237 Fix it, or run `devp config {clean} --update` to reset it.",
238 e.trim()
239 ));
240 }
241 PruneStatus::SkippedSymlink(e) => {
242 output::print_warning(&format!("{clean} → {}", e.trim()));
243 }
244 _ => {}
245 }
246 }
247
248 if !args.dry_run && total_freed > 0 {
249 output::print_success(&format!(
250 "Freed: {} in {clean}",
251 output::format_bytes(total_freed)
252 ));
253 }
254
255 if error_count > 0 {
256 anyhow::bail!("{error_count} directories in {clean} could not be pruned.");
257 }
258
259 Ok(())
260}
261
262fn record_targeted_prune(path: &std::path::Path, results: &[PruneResult], dry_run: bool) {
268 if dry_run {
269 return;
270 }
271
272 let pruned: Vec<crate::config::PrunedDir> = results
275 .iter()
276 .filter(|r| {
277 matches!(r.status, PruneStatus::Pruned)
278 || (matches!(r.status, PruneStatus::DeleteError(_)) && r.size_freed > 0)
279 })
280 .map(|r| crate::config::PrunedDir {
281 repo_path: r.repo_path.clone(),
282 bloat_dir: r.bloat_dir.clone(),
283 adapter: r.adapter_name.clone(),
284 size_freed: r.size_freed,
285 })
286 .collect();
287
288 if pruned.is_empty() {
289 return;
290 }
291
292 let freed: u64 = pruned.iter().map(|d| d.size_freed).sum();
293 if let Ok(mut registry) = Registry::load() {
294 registry.mark_pruned(path, freed);
295 registry.record_prune(pruned);
296 let _ = registry.save();
297 }
298}
299
300fn resolve_min_size(args: &RunArgs<'_>, registry: Option<&Registry>) -> u64 {
305 let mb = args
306 .min_size_mb
307 .or_else(|| registry.map(|r| r.settings.min_size_mb))
308 .unwrap_or(constants::DEFAULT_MIN_SIZE_MB);
309 mb.saturating_mul(engine::BYTES_PER_MIB)
310}
311
312fn parse_except(spec: Option<&str>) -> Vec<String> {
319 spec.map(|s| {
320 s.split(',')
321 .map(|part| {
322 crate::config::expand_tilde(part.trim())
323 .trim_end_matches(['/', '\\'])
324 .to_lowercase()
325 })
326 .filter(|part| !part.is_empty())
327 .collect()
328 })
329 .unwrap_or_default()
330}
331
332fn is_excepted(repo_path: &Path, except: &[String]) -> bool {
339 if except.is_empty() {
340 return false;
341 }
342 let full = output::clean_path(repo_path)
343 .to_lowercase()
344 .replace('\\', "/");
345 let name = repo_path
346 .file_name()
347 .map(|n| n.to_string_lossy().to_lowercase())
348 .unwrap_or_default();
349
350 except.iter().any(|want| {
351 let want = want.replace('\\', "/");
352 name == want || full == want || full.ends_with(&format!("/{want}"))
353 })
354}
355
356fn resolve_scan_depth(registry: Option<&Registry>) -> usize {
358 registry
359 .map(|r| r.settings.scan_depth)
360 .unwrap_or(constants::DEFAULT_SCAN_DEPTH)
361}
362
363fn resolve_command_timeout(registry: Option<&Registry>) -> u64 {
365 registry
366 .map(|r| r.settings.command_timeout_secs)
367 .unwrap_or(constants::DEFAULT_COMMAND_TIMEOUT_SECS)
368}
369
370fn resolve_manifest_rewrite(registry: Option<&Registry>) -> bool {
372 registry
373 .map(|r| r.settings.allow_manifest_rewrite)
374 .unwrap_or(constants::DEFAULT_ALLOW_MANIFEST_REWRITE)
375}
376
377fn run_registry(args: &RunArgs<'_>, filter: &AdapterFilter) -> Result<()> {
379 if !args.json {
380 if args.dry_run {
381 output::print_header("dev-prune run (DRY RUN)");
382 } else {
383 output::print_header("dev-prune run");
384 }
385 }
386
387 let mut registry = Registry::load()?;
388
389 if !args.json && crate::commands::update::notify_if_outdated(&mut registry) {
392 let _ = registry.save();
393 }
394
395 if registry.repo_count() == 0 {
396 if args.json {
397 return json::emit(&json::run_document(&[], args.dry_run));
398 }
399 output::print_warning("No repositories registered. Run `dev-prune init` first.");
400 return Ok(());
401 }
402
403 let except = parse_except(args.except);
407 if !except.is_empty() {
408 let unmatched: Vec<&String> = except
409 .iter()
410 .filter(|want| {
411 !registry
412 .repositories
413 .keys()
414 .any(|p| is_excepted(p, std::slice::from_ref(*want)))
415 })
416 .collect();
417 if !unmatched.is_empty() {
418 anyhow::bail!(
419 "`--except` names no registered repository: {}\n \
420 Run `devp status` to see the registered names.",
421 unmatched
422 .iter()
423 .map(|s| s.as_str())
424 .collect::<Vec<_>>()
425 .join(", ")
426 );
427 }
428 }
429
430 let min_size_bytes = resolve_min_size(args, Some(®istry));
431 let analysis = PruneOptions {
432 idle_days: 0, dry_run: true,
434 force: args.force,
435 only_dirs: None,
436 adapters: filter.clone(),
437 min_size_bytes,
438 scan_depth: resolve_scan_depth(Some(®istry)),
439 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
440 command_timeout_secs: resolve_command_timeout(Some(®istry)),
441 };
442
443 if !args.json {
444 output::print_info(&format!(
445 "Scanning {} registered repositories for prune candidates...",
446 registry.repo_count()
447 ));
448 if let Some(desc) = filter.describe() {
449 output::print_info(&format!("Adapter filter: {desc}"));
450 }
451 if min_size_bytes > 0 {
452 output::print_info(&format!(
453 "Size floor: ignoring directories under {}",
454 output::format_bytes(min_size_bytes)
455 ));
456 }
457 }
458
459 let mut candidates: Vec<PruneResult> = Vec::new();
468 let mut blocked: Vec<PruneResult> = Vec::new();
469 let mut linked: Vec<PruneResult> = Vec::new();
470 let mut missing: Vec<PruneResult> = Vec::new();
471 for result in engine::prune_all_with(&mut registry, &analysis) {
472 if is_excepted(&result.repo_path, &except) {
476 continue;
477 }
478 match result.status {
479 PruneStatus::SkippedDryRun => candidates.push(result),
480 PruneStatus::ConfigError(_)
481 | PruneStatus::LockfileError(_)
482 | PruneStatus::ActivityCheckError(_)
483 | PruneStatus::DeleteError(_) => blocked.push(result),
484 PruneStatus::SkippedSymlink(_) => linked.push(result),
487 PruneStatus::PathMissing => missing.push(result),
490 _ => {}
491 }
492 }
493
494 if !args.json && !except.is_empty() {
495 output::print_info(&format!("Leaving alone: {}", except.join(", ")));
496 }
497
498 if args.daemon {
499 let before = candidates.len();
500 candidates.retain(|c| {
501 match crate::config::PerRepoConfig::load_with_diagnostics(&c.repo_path) {
505 Ok(Some(cfg)) => !cfg.disable_daemon,
506 Ok(None) => true,
507 Err(_) => false,
508 }
509 });
510 let skipped = before - candidates.len();
511 if skipped > 0 && !args.json {
512 output::print_info(&format!(
513 "Skipped {skipped} bloat directories in repositories that set `disable_daemon`."
514 ));
515 }
516 }
517
518 if args.dry_run {
520 if args.json {
521 json::emit(&json::run_document(
522 &[candidates, blocked, linked, missing].concat(),
523 true,
524 ))?;
525 return Ok(());
526 }
527 if candidates.is_empty() && blocked.is_empty() && linked.is_empty() && missing.is_empty() {
528 output::print_info("No idle repositories or pruneable bloat directories found.");
529 return Ok(());
530 }
531 if !candidates.is_empty() {
532 report_candidates(&candidates);
533 }
534 let total: u64 = candidates.iter().map(|c| c.size_freed).sum();
535 output::print_header("Summary (Dry Run)");
536 output::print_info(&format!(
537 "Would free {} across {} bloat directories.",
538 output::format_bytes(total),
539 candidates.len()
540 ));
541 report_blocked(&blocked);
544 report_linked(&linked);
545 report_missing(&missing);
546 return Ok(());
547 }
548
549 if candidates.is_empty() {
550 if args.json {
551 json::emit(&json::run_document(
552 &[blocked.clone(), linked, missing].concat(),
553 false,
554 ))?;
555 return fail_if_blocked(&blocked);
556 }
557 if blocked.is_empty() && linked.is_empty() && missing.is_empty() {
558 output::print_info("No idle repositories or pruneable bloat directories found.");
559 return Ok(());
560 }
561 output::print_info("No pruneable bloat directories found.");
562 report_blocked(&blocked);
563 report_linked(&linked);
564 report_missing(&missing);
565 return fail_if_blocked(&blocked);
566 }
567
568 let total_reclaimable: u64 = candidates.iter().map(|c| c.size_freed).sum();
569
570 if !args.json {
571 report_binaries(&candidates);
572 report_candidates(&candidates);
573 output::print_info(&format!(
574 "Total Reclaimable Space: {}",
575 output::format_bytes(total_reclaimable)
576 ));
577 report_blocked(&blocked);
578 report_linked(&linked);
579 report_missing(&missing);
580 }
581
582 let target_candidates: Vec<PruneResult> = if args.json
585 || args.yes
586 || !registry.settings.require_confirmation
587 {
588 candidates
589 } else if io::stdout().is_terminal() {
590 eprintln!();
591 eprintln!(
592 " Loading interactive selector... (↑↓ navigate, Space toggle, Enter confirm, q cancel)"
593 );
594 eprintln!();
595 let selected = tui::selection_view::select_candidates_tui(&candidates)?;
596 if selected.is_empty() {
597 output::print_info("Prune pass cancelled by user (0 candidates selected).");
598 return Ok(());
599 }
600 selected
601 } else {
602 if !io::stdin().is_terminal() {
606 anyhow::bail!(
607 "Deleting {} directories ({}) needs confirmation, and there is no \
608 terminal to ask on. Re-run with `--yes` to confirm, or `--dry-run` \
609 to only analyse.",
610 candidates.len(),
611 output::format_bytes(total_reclaimable)
612 );
613 }
614 println!();
615 output::print_warning("CAUTION: Deleting bloat directories cannot be undone directly.");
616 output::print_info(
617 "Note: You can re-install missing dependencies anytime using `dev-prune restore`.",
618 );
619 eprint!(
622 "Proceed with deletion of {} directories ({})? [y/N]: ",
623 candidates.len(),
624 output::format_bytes(total_reclaimable)
625 );
626 io::stderr().flush()?;
627
628 let mut input = String::new();
629 io::stdin().read_line(&mut input)?;
630 let trimmed = input.trim().to_lowercase();
631 if trimmed != "y" && trimmed != "yes" {
632 output::print_info("Prune pass aborted by user.");
633 return Ok(());
634 }
635 candidates
636 };
637
638 if !args.json {
639 let selected_total_bytes: u64 = target_candidates.iter().map(|c| c.size_freed).sum();
640 output::print_header(&format!(
641 "Executing Progressive Deletion ({} repos, {})",
642 target_candidates.len(),
643 output::format_bytes(selected_total_bytes)
644 ));
645 }
646
647 let mut selection: Vec<(std::path::PathBuf, Vec<String>)> = Vec::new();
653 for candidate in &target_candidates {
654 match selection
655 .iter_mut()
656 .find(|(p, _)| *p == candidate.repo_path)
657 {
658 Some((_, dirs)) => dirs.push(candidate.bloat_dir.clone()),
659 None => selection.push((
660 candidate.repo_path.clone(),
661 vec![candidate.bloat_dir.clone()],
662 )),
663 }
664 }
665
666 let mut error_count = blocked.len();
670 let mut all_results: Vec<PruneResult> = blocked;
671 all_results.extend(linked);
672 all_results.extend(missing);
673 let mut total_freed: u64 = 0;
674 let mut pruned_count = 0;
675 let mut pruned_dirs: Vec<crate::config::PrunedDir> = Vec::new();
676 let pass_at = chrono::Utc::now();
679
680 for (repo_path, dirs) in &selection {
681 let recorded_before = pruned_dirs.len();
682 let idle_days = registry
687 .repositories
688 .get(repo_path)
689 .and_then(|e| e.override_idle_days)
690 .unwrap_or(registry.settings.idle_days);
691 let single_results = engine::prune_repo_with(
692 repo_path,
693 &PruneOptions {
694 idle_days,
695 dry_run: false,
696 force: args.force,
697 only_dirs: Some(dirs.clone()),
698 adapters: filter.clone(),
699 min_size_bytes: 0,
700 scan_depth: analysis.scan_depth,
701 allow_manifest_rewrite: analysis.allow_manifest_rewrite,
702 command_timeout_secs: analysis.command_timeout_secs,
703 },
704 );
705 for result in single_results {
706 match &result.status {
707 PruneStatus::Pruned => {
708 total_freed += result.size_freed;
709 pruned_count += 1;
710 registry.mark_pruned(&result.repo_path, result.size_freed);
711 pruned_dirs.push(crate::config::PrunedDir {
712 repo_path: result.repo_path.clone(),
713 bloat_dir: result.bloat_dir.clone(),
714 adapter: result.adapter_name.clone(),
715 size_freed: result.size_freed,
716 });
717 if !args.json {
718 output::print_success(&format!(
719 "{} → {} ({}) — {}{}",
720 output::clean_path(&result.repo_path),
721 result.bloat_dir,
722 output::format_bytes(result.size_freed),
723 result.adapter_name,
724 output::shared_note(result.shared_bytes, &result.adapter_name)
725 ));
726 }
727 }
728 PruneStatus::LockfileError(e) => {
729 error_count += 1;
730 if !args.json {
731 report_lockfile_failure(&result, e);
732 }
733 }
734 PruneStatus::ActivityCheckError(e) => {
735 error_count += 1;
736 if !args.json {
737 output::print_error(&format!(
738 "{} skipped — its activity could not be determined:\n {}",
739 output::clean_path(&result.repo_path),
740 e.trim()
741 ));
742 }
743 }
744 PruneStatus::DeleteError(e) => {
745 error_count += 1;
746 if result.size_freed > 0 {
751 pruned_dirs.push(crate::config::PrunedDir {
752 repo_path: result.repo_path.clone(),
753 bloat_dir: result.bloat_dir.clone(),
754 adapter: result.adapter_name.clone(),
755 size_freed: result.size_freed,
756 });
757 }
758 if !args.json {
759 output::print_error(&format!(
760 "{} → delete failed: {}",
761 output::clean_path(&result.repo_path),
762 e,
763 ));
764 }
765 }
766 PruneStatus::ConfigError(e) => {
767 error_count += 1;
768 if !args.json {
769 let clean_p = output::clean_path(&result.repo_path);
770 output::print_error(&format!(
771 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
772 e.trim()
773 ));
774 output::print_info(&format!(
775 " Fix command: devp config {clean_p} --update"
776 ));
777 }
778 }
779 PruneStatus::SkippedActive if !args.json => {
782 output::print_info(&format!(
783 "{} became active since the analysis — left alone. \
784 Use `--ignore-idle` to prune it anyway.",
785 output::clean_path(&result.repo_path)
786 ));
787 }
788 _ => {}
789 }
790 all_results.push(result);
791 }
792
793 if pruned_dirs.len() > recorded_before {
799 registry.record_prune_progress(pass_at, pruned_dirs.clone());
800 let _ = registry.save();
801 }
802 }
803
804 registry.record_prune_progress(pass_at, pruned_dirs);
805 registry.save()?;
806
807 if args.json {
808 json::emit(&json::run_document(&all_results, false))?;
809 if error_count > 0 {
812 anyhow::bail!("{error_count} repositories could not be pruned.");
813 }
814 return Ok(());
815 }
816
817 output::print_header("Summary");
818 output::print_success(&format!(
819 "Freed: {} across {pruned_count} directories",
820 output::format_bytes(total_freed)
821 ));
822
823 if error_count > 0 {
824 output::print_warning(&format!("{error_count} repos were not pruned."));
825
826 if all_results
830 .iter()
831 .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
832 {
833 output::print_info(
837 "Lockfile verification cannot be bypassed: without a lockfile the deleted \
838 dependencies could not be reinstalled. Run the fix command shown above for \
839 each repo, then re-run `devp run`.",
840 );
841 }
842 anyhow::bail!("{error_count} repositories could not be pruned.");
844 }
845
846 Ok(())
847}
848
849fn report_blocked(blocked: &[PruneResult]) {
853 if blocked.is_empty() {
854 return;
855 }
856 output::print_header("Repositories That Could Not Be Examined");
857 for result in blocked {
858 let clean_p = output::clean_path(&result.repo_path);
859 match &result.status {
860 PruneStatus::ConfigError(e) => {
861 output::print_error(&format!(
862 "{clean_p} skipped — its .devprune.json could not be read:\n {}",
863 e.trim()
864 ));
865 output::print_info(&format!(
866 " Fix command: devp config {clean_p} --update"
867 ));
868 }
869 PruneStatus::LockfileError(e) => report_lockfile_failure(result, e),
870 PruneStatus::ActivityCheckError(e) => {
871 output::print_error(&format!(
872 "{clean_p} skipped — its activity could not be determined:\n {}",
873 e.trim()
874 ));
875 }
876 PruneStatus::DeleteError(e) => {
877 output::print_error(&format!("{clean_p} → delete failed: {e}"));
878 }
879 _ => {}
881 }
882 }
883}
884
885fn report_linked(linked: &[PruneResult]) {
891 for result in linked {
892 if let PruneStatus::SkippedSymlink(e) = &result.status {
893 output::print_warning(&format!(
894 "{} → {}",
895 output::clean_path(&result.repo_path),
896 e.trim()
897 ));
898 }
899 }
900}
901
902fn report_missing(missing: &[PruneResult]) {
908 for result in missing {
909 output::print_warning(&format!(
910 "{} no longer exists — `devp unlink --missing` clears such entries.",
911 output::clean_path(&result.repo_path)
912 ));
913 }
914}
915
916fn fail_if_blocked(blocked: &[PruneResult]) -> Result<()> {
921 if blocked.is_empty() {
922 return Ok(());
923 }
924 anyhow::bail!("{} repositories could not be examined.", blocked.len());
925}
926
927fn report_binaries(candidates: &[PruneResult]) {
929 let adapter_names: Vec<String> = candidates.iter().map(|c| c.adapter_name.clone()).collect();
930 let binary_statuses = adapters::scan_required_binaries(&adapter_names);
931 if binary_statuses.is_empty() {
932 return;
933 }
934 output::print_header("Required Ecosystem Binaries Pre-Check");
935 for b in &binary_statuses {
936 if b.available {
937 output::print_success(&format!(
938 " {} — available ({})",
939 b.name,
940 b.version.as_deref().unwrap_or("detected")
941 ));
942 } else {
943 output::print_warning(&format!(
944 " {} — missing (lockfile fallback active)",
945 b.name
946 ));
947 }
948 }
949}
950
951fn report_candidates(candidates: &[PruneResult]) {
952 output::print_header("Prune Candidates & Space Savings Calculation");
953 for candidate in candidates {
954 output::print_info(&format!(
955 " • {} → {} ({}) [{}]{}",
956 output::clean_path(&candidate.repo_path),
957 candidate.bloat_dir,
958 output::format_bytes(candidate.size_freed),
959 candidate.adapter_name,
960 output::shared_note(candidate.shared_bytes, &candidate.adapter_name)
961 ));
962 }
963}
964
965fn report_lockfile_failure(result: &PruneResult, error: &str) {
966 let clean_p = output::clean_path(&result.repo_path);
967 let sync_cmd_help =
968 json::lockfile_fix_command(&result.adapter_name).unwrap_or("check the adapter's docs");
969
970 #[cfg(windows)]
971 let manual_cmd = format!("cd \"{}\"; {}", clean_p, sync_cmd_help);
972 #[cfg(not(windows))]
973 let manual_cmd = format!("cd \"{}\" && {}", clean_p, sync_cmd_help);
974
975 output::print_error(&format!(
976 "{} → {} lockfile sync failed:\n {}",
977 clean_p,
978 result.adapter_name,
979 error.trim(),
980 ));
981 output::print_info(&format!(" Fix command: {}", manual_cmd));
982 output::print_info(&format!(
983 " Troubleshooting: {}",
984 constants::TROUBLESHOOTING_URL
985 ));
986}