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::DeleteError(_)
163 | PruneStatus::ConfigError(_)
164 )
165 })
166 .count();
167
168 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
250fn 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
283fn 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
295fn 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
315fn 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
339fn 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
346fn 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
353fn 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
360fn 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 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 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(®istry));
414 let analysis = PruneOptions {
415 idle_days: 0, 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(®istry)),
422 allow_manifest_rewrite: resolve_manifest_rewrite(Some(®istry)),
423 command_timeout_secs: resolve_command_timeout(Some(®istry)),
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 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 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 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 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 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 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 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 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 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 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 if all_results
722 .iter()
723 .any(|r| matches!(r.status, PruneStatus::LockfileError(_)))
724 {
725 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 anyhow::bail!("{error_count} repositories could not be pruned.");
736 }
737
738 Ok(())
739}
740
741fn 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 _ => {}
767 }
768 }
769}
770
771fn 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
782fn 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}