1use std::path::{Path, PathBuf};
27
28use anyhow::{Context, Result};
29use chrono::Utc;
30
31use crate::adapters;
32use crate::commands::hook::{self, HookState};
33use crate::config::{PerRepoConfig, Registry};
34use crate::constants;
35use crate::daemon;
36use crate::engine::{self, BYTES_PER_MIB, SkipReason};
37use crate::output;
38use crate::scanner::{self, git};
39use crate::setup;
40use crate::workspace;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum Repair {
46 Twin,
48 SkillFile,
50 Hooks,
52 Scheduler,
54 UnlinkMissing,
56}
57
58#[derive(Default)]
61struct Findings {
62 warnings: Vec<String>,
63 problems: Vec<String>,
64 fixes: Vec<Repair>,
66 fixable_problems: Vec<usize>,
69 problem_repairs: Vec<Repair>,
73}
74
75impl Findings {
76 fn ok(&mut self, label: &str, detail: &str) {
78 println!(" {label:<22} {} {detail}", "✓".green());
79 }
80
81 fn warn(&mut self, label: &str, detail: &str) {
83 println!(" {label:<22} {} {detail}", "!".yellow());
84 self.warnings.push(format!("{label}: {detail}"));
85 }
86
87 fn problem(&mut self, label: &str, detail: &str) {
89 println!(" {label:<22} {} {detail}", "✗".red());
90 self.problems.push(format!("{label}: {detail}"));
91 }
92
93 fn fixable(&mut self, repair: Repair) {
95 if !self.fixes.contains(&repair) {
96 self.fixes.push(repair);
97 }
98 }
99
100 fn fixable_problem(&mut self, repair: Repair) {
105 self.fixable(repair);
106 if !self.problem_repairs.contains(&repair) {
107 self.problem_repairs.push(repair);
108 }
109 if let Some(last) = self.problems.len().checked_sub(1) {
110 if !self.fixable_problems.contains(&last) {
111 self.fixable_problems.push(last);
112 }
113 }
114 }
115
116 fn note(&self, label: &str, detail: &str) {
118 println!(" {label:<22} {detail}");
119 }
120
121 fn section(&self, title: &str) {
122 println!();
123 println!("{}", title.bold());
124 }
125}
126
127use colored::Colorize as _;
129
130pub fn run(path: Option<&str>, fix: bool) -> Result<()> {
137 match path {
138 Some(p) => check_repository(p),
139 None => check_installation(fix),
140 }
141}
142
143fn verdict(f: &Findings, all_clear: &str, headline: Option<&str>) -> Result<()> {
149 f.section("Verdict");
150
151 if let Some(line) = headline {
152 println!(" {line}");
153 println!();
154 }
155
156 if f.problems.is_empty() && f.warnings.is_empty() {
157 output::print_success(all_clear);
158 return Ok(());
159 }
160
161 for w in &f.warnings {
162 println!(" {} {w}", "!".yellow());
163 }
164 for p in &f.problems {
165 println!(" {} {p}", "✗".red());
166 }
167
168 if !f.fixes.is_empty() {
169 println!();
170 println!(
171 " {} of these can be repaired automatically — run `devp doctor --fix`.",
172 f.fixes.len()
173 );
174 }
175
176 println!();
177 println!(" Troubleshooting: {}", constants::TROUBLESHOOTING_URL);
178
179 if f.problems.is_empty() {
180 println!();
181 output::print_info(&format!(
182 "{} {} — nothing broken.",
183 f.warnings.len(),
184 output::plural(f.warnings.len(), "warning", "warnings")
185 ));
186 return Ok(());
187 }
188
189 anyhow::bail!(
190 "{} {} found.",
191 f.problems.len(),
192 output::plural(f.problems.len(), "problem", "problems")
193 )
194}
195
196fn check_installation(fix: bool) -> Result<()> {
201 output::print_header("dev-prune doctor");
202 let mut f = Findings::default();
203
204 check_binary(&mut f);
205 let registry = check_configuration(&mut f);
206 check_integrations(&mut f, registry.as_ref());
207 check_package_managers(&mut f, registry.as_ref());
208 check_registry_health(&mut f, registry.as_ref());
209 check_release_state(&mut f, registry.as_ref());
210
211 if fix && !f.fixes.is_empty() {
212 return apply_repairs(&f, registry.as_ref());
213 }
214 if fix {
215 return verdict(&f, "Everything checks out — nothing to repair.", None);
219 }
220 verdict(&f, "Everything checks out.", None)
221}
222
223fn apply_repairs(f: &Findings, registry: Option<&Registry>) -> Result<()> {
243 f.section("Repairs");
244
245 let ok = |label: &str, detail: &str| println!(" {label:<22} {} {detail}", "✓".green());
246 let skipped = |label: &str, detail: &str| println!(" {label:<22} {} {detail}", "!".yellow());
247 let failed_line = |label: &str, detail: &str| println!(" {label:<22} {} {detail}", "✗".red());
248
249 let chain = registry
250 .map(|r| r.settings.auto_hooks_chain)
251 .unwrap_or(false);
252 let interval = registry
253 .map(|r| r.settings.check_interval_days)
254 .unwrap_or(constants::DEFAULT_CHECK_INTERVAL_DAYS);
255 let installs_off = setup::no_auto_setup_requested();
256
257 let mut repaired = 0usize;
258 let mut failures = 0usize;
259 let mut attention = 0usize;
260 let mut skipped_problems = 0usize;
264
265 for repair in &f.fixes {
266 let is_problem = f.problem_repairs.contains(repair);
267 let (label, manual) = match repair {
268 Repair::Twin => ("Binary pair", "run `dev-prune setup` yourself"),
269 Repair::SkillFile => ("SKILL.md", "run `devp skill` yourself"),
270 Repair::Hooks => ("Git hooks", "run `devp hook install` yourself"),
271 Repair::Scheduler => ("Scheduler", "run `devp daemon install` yourself"),
272 Repair::UnlinkMissing => {
273 match crate::commands::link::run_unlink_missing() {
274 Ok(()) => repaired += 1,
275 Err(e) => {
276 failed_line("Registry", &format!("{e:#}"));
277 failures += 1;
278 }
279 }
280 continue;
281 }
282 };
283 if installs_off && matches!(repair, Repair::Twin | Repair::Hooks | Repair::Scheduler) {
286 skipped(
287 label,
288 &format!("{} is set — {manual}", setup::ENV_NO_AUTO_SETUP),
289 );
290 attention += 1;
291 if is_problem {
292 skipped_problems += 1;
293 }
294 continue;
295 }
296 let outcome = match repair {
297 Repair::Twin => setup::ensure_alias(),
298 Repair::SkillFile => setup::ensure_skill_file(),
299 Repair::Hooks => setup::ensure_hooks(chain),
300 Repair::Scheduler => setup::ensure_daemon(interval),
301 Repair::UnlinkMissing => unreachable!("handled above"),
302 };
303 match outcome {
304 setup::Outcome::Installed => {
305 ok(label, "repaired");
306 repaired += 1;
307 }
308 setup::Outcome::AlreadyPresent => {
309 ok(label, "already in place");
310 repaired += 1;
311 }
312 setup::Outcome::Skipped(why) => {
313 skipped(label, &why);
314 attention += 1;
315 if is_problem {
316 skipped_problems += 1;
317 }
318 }
319 setup::Outcome::Failed(why) => {
320 failed_line(label, &why);
321 failures += 1;
322 }
323 }
324 }
325
326 f.section("Verdict");
327 let unfixable: Vec<&String> = f
328 .problems
329 .iter()
330 .enumerate()
331 .filter(|(i, _)| !f.fixable_problems.contains(i))
332 .map(|(_, p)| p)
333 .collect();
334 for p in &unfixable {
335 println!(" {} {p} (not auto-repairable)", "✗".red());
336 }
337 if !unfixable.is_empty() {
338 println!();
339 println!(" Troubleshooting: {}", constants::TROUBLESHOOTING_URL);
340 }
341 println!();
342 output::print_info(&format!(
343 "{repaired} repaired, {attention} skipped, {failures} failed. \
344 Run `devp doctor` to confirm."
345 ));
346
347 let unresolved = failures + skipped_problems + unfixable.len();
348 if unresolved > 0 {
349 anyhow::bail!(
350 "{unresolved} {} could not be repaired.",
351 output::plural(unresolved, "finding", "findings")
352 );
353 }
354 Ok(())
355}
356
357fn check_binary(f: &mut Findings) {
358 f.section("Installation");
359 f.note("Version", constants::VERSION);
360
361 let Ok(exe) = std::env::current_exe() else {
362 f.warn("Executable", "the running binary's own path is unavailable");
363 return;
364 };
365 f.note("Executable", &output::clean_path(&exe));
366
367 let Some(dir) = exe.parent() else { return };
368
369 let running = if exe
374 .file_stem()
375 .and_then(|s| s.to_str())
376 .is_some_and(|stem| stem.eq_ignore_ascii_case("devp"))
377 {
378 "devp"
379 } else {
380 "dev-prune"
381 };
382 let twin_stem = if running == "devp" {
383 "dev-prune"
384 } else {
385 "devp"
386 };
387 let twin = dir.join(if cfg!(windows) {
388 format!("{twin_stem}.exe")
389 } else {
390 twin_stem.to_string()
391 });
392 if !twin.exists() {
393 f.warn(
394 twin_stem,
395 &format!("not installed next to {running} — run `{running} setup`"),
396 );
397 f.fixable(Repair::Twin);
399 } else if same_binary(&exe, &twin) {
400 f.ok(twin_stem, &output::clean_path(&twin));
401 } else {
402 f.warn(
405 twin_stem,
406 &format!(
407 "{} is not the same binary as {} — one of the pair is stale and \
408 silently runs a different version. `dev-prune setup` refreshes `devp` \
409 from the canonical `dev-prune`.",
410 output::clean_path(&twin),
411 output::clean_path(&exe)
412 ),
413 );
414 if running == "dev-prune" {
418 f.fixable(Repair::Twin);
419 }
420 }
421
422 let sep = if cfg!(windows) { ';' } else { ':' };
423 let on_path = std::env::var("PATH")
424 .unwrap_or_default()
425 .split(sep)
426 .any(|p| !p.is_empty() && same_dir(Path::new(p), dir));
427 if on_path {
428 f.ok("PATH", &output::clean_path(dir));
429 } else {
430 f.problem(
431 "PATH",
432 &format!(
433 "{} is not on PATH — `devp` will not resolve in a new shell",
434 output::clean_path(dir)
435 ),
436 );
437 }
438}
439
440fn same_dir(entry: &Path, dir: &Path) -> bool {
447 if entry == dir {
448 return true;
449 }
450 cfg!(windows) && {
451 let norm = |p: &Path| {
452 p.to_string_lossy()
453 .trim_end_matches(['\\', '/'])
454 .to_lowercase()
455 };
456 norm(entry) == norm(dir)
457 }
458}
459
460fn same_binary(a: &Path, b: &Path) -> bool {
466 let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
467 return false;
468 };
469 if ma.len() != mb.len() {
470 return false;
471 }
472 matches!((std::fs::read(a), std::fs::read(b)), (Ok(ba), Ok(bb)) if ba == bb)
473}
474
475fn check_configuration(f: &mut Findings) -> Option<Registry> {
480 f.section("Configuration");
481
482 let dir = match Registry::config_dir() {
483 Ok(d) => d,
484 Err(e) => {
485 f.problem("Config directory", &format!("cannot be resolved: {e}"));
486 return None;
487 }
488 };
489 f.note("Config directory", &output::clean_path(&dir));
490 if std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE).is_ok() {
491 f.note(
492 "",
493 &format!("(set by {})", constants::ENV_CONFIG_DIR_OVERRIDE),
494 );
495 }
496
497 let path = dir.join(constants::REGISTRY_FILENAME);
498 if !path.exists() {
499 f.ok("registry.json", "not created yet — defaults apply");
502 return Some(Registry::default());
503 }
504
505 let registry = match Registry::load_from(&path) {
506 Ok(r) => r,
507 Err(e) => {
508 f.problem(
509 "registry.json",
510 &format!(
511 "{} — dev-prune refuses to guess at a config it cannot read. \
512 Fix the syntax, or delete the file to start from defaults.",
513 root_cause(&e)
514 ),
515 );
516 return None;
517 }
518 };
519
520 f.ok(
521 "registry.json",
522 &format!(
523 "readable — {} {} registered",
524 registry.repo_count(),
525 output::plural(registry.repo_count(), "repository", "repositories")
526 ),
527 );
528
529 let invalid = crate::commands::config::invalid_settings(®istry.settings);
530 if invalid.is_empty() {
531 f.ok(
532 "Settings",
533 &format!(
534 "all {} within range",
535 crate::commands::config::setting_count()
536 ),
537 );
538 } else {
539 for (key, why) in &invalid {
540 f.problem("Settings", &format!("{key}: {why}"));
541 }
542 }
543
544 Some(registry)
545}
546
547fn check_integrations(f: &mut Findings, registry: Option<&Registry>) {
548 f.section("Integrations");
549
550 match setup::skill_path() {
551 Ok(p) if p.exists() => f.ok("SKILL.md", &output::clean_path(&p)),
552 _ => {
553 f.warn("SKILL.md", "not exported — run `devp skill`");
554 f.fixable(Repair::SkillFile);
555 }
556 }
557
558 if crate::commands::icon::is_registered() {
559 f.ok("File icons", "registered with the file manager");
560 } else {
561 f.warn("File icons", "not registered — run `devp icon`");
562 }
563
564 if !hook::git_available() {
565 f.warn(
566 "Git hooks",
567 "git is not on PATH, so repositories cannot auto-register",
568 );
569 } else {
570 match hook::state() {
571 Ok(HookState::Active) => check_hook_target(f, "active"),
572 Ok(HookState::Absent) => f.warn("Git hooks", "not installed — run `devp hook install`"),
573 Ok(HookState::Chained { previous, drifted }) if drifted.is_empty() => {
574 check_hook_target(f, &format!("active, chained to `{previous}`"))
575 }
576 Ok(HookState::Chained { previous, drifted }) => {
577 f.warn(
578 "Git hooks",
579 &format!(
580 "chained to `{previous}`, but {} not forwarded ({}) — \
581 re-run `devp hook install --chain`",
582 drifted.len(),
583 drifted.join(", ")
584 ),
585 );
586 f.fixable(Repair::Hooks);
589 }
590 Ok(HookState::Foreign(p)) => f.warn(
591 "Git hooks",
592 &format!(
593 "core.hooksPath belongs to `{p}` — install in front of it with \
594 `devp hook install --chain`"
595 ),
596 ),
597 Err(e) => f.warn("Git hooks", &format!("state unknown ({e})")),
598 }
599 }
600
601 match daemon::daemon_status() {
602 Ok(daemon::DaemonStatus::Installed) => check_scheduler_target(f),
603 Ok(daemon::DaemonStatus::NotInstalled) => f.warn(
604 "Scheduler",
605 "not installed — nothing prunes on its own. `devp daemon install` adds it.",
606 ),
607 Ok(daemon::DaemonStatus::Unknown(why)) => f.warn("Scheduler", &why),
608 Err(e) => f.warn("Scheduler", &format!("state unknown ({e})")),
609 }
610
611 if let Some(r) = registry {
612 f.note(
613 "Automatic setup",
614 &format!(
615 "auto_setup={} auto_hooks={} auto_daemon={}",
616 r.settings.auto_setup, r.settings.auto_hooks, r.settings.auto_daemon
617 ),
618 );
619 }
620
621 if let Some(why) = setup::unattended_environment() {
624 f.note("", &format!("unattended installation is off because {why}"));
625 }
626 if setup::no_auto_setup_requested() {
629 f.note(
630 "",
631 &format!(
632 "{} is set — nothing installs by itself. `devp setup` still works.",
633 setup::ENV_NO_AUTO_SETUP
634 ),
635 );
636 }
637}
638
639fn report_integration_target(
653 f: &mut Findings,
654 label: &str,
655 installed: &str,
656 recorded: Option<std::path::PathBuf>,
657 repair: &str,
658) -> bool {
659 match recorded {
660 None => f.ok(label, installed),
663 Some(path) if path.is_file() => f.ok(
664 label,
665 &format!("{installed} — {}", output::clean_path(&path)),
666 ),
667 Some(path) => {
668 f.problem(
669 label,
670 &format!(
671 "registered, but `{}` no longer exists — it never runs. {repair}",
672 output::clean_path(&path)
673 ),
674 );
675 return true;
676 }
677 }
678 false
679}
680
681fn check_scheduler_target(f: &mut Findings) {
682 if report_integration_target(
683 f,
684 "Scheduler",
685 "installed",
686 daemon::registered_exe_path(),
687 "Re-register it with `devp daemon install`.",
688 ) {
689 f.fixable_problem(Repair::Scheduler);
690 }
691}
692
693fn check_hook_target(f: &mut Findings, installed: &str) {
694 if report_integration_target(
695 f,
696 "Git hooks",
697 installed,
698 hook::registered_exe_path(),
699 "Rewrite them with `devp hook install`.",
700 ) {
701 f.fixable_problem(Repair::Hooks);
702 }
703}
704
705fn check_package_managers(f: &mut Findings, registry: Option<&Registry>) {
712 f.section("Package managers");
713
714 let (needed, required): (Vec<String>, bool) = match registry {
718 Some(r) if r.repo_count() > 0 => {
719 let mut names: Vec<String> = engine::get_full_status(r)
720 .into_iter()
721 .flat_map(|e| e.adapters)
722 .collect();
723 names.sort();
724 names.dedup();
725 (names, true)
726 }
727 _ => (
728 adapters::get_all_adapters()
729 .iter()
730 .map(|a| a.name().to_string())
731 .collect(),
732 false,
733 ),
734 };
735
736 if needed.is_empty() {
737 f.note(
738 "",
739 "no package managers are needed by the registered repositories",
740 );
741 return;
742 }
743 if !required {
744 f.note("", "nothing is registered yet, so this is the full list");
745 }
746
747 for status in adapters::scan_required_binaries(&needed) {
748 match (status.available, status.version) {
749 (true, Some(v)) => f.ok(&status.name, &v),
750 (true, None) => f.ok(&status.name, "available"),
751 (false, _) if required => f.warn(
752 &status.name,
753 "not on PATH — projects using it cannot be verified, pruned or restored",
754 ),
755 (false, _) => f.note(&status.name, "not installed"),
756 }
757 }
758
759 if required && needed.iter().any(|n| n == "venv") && !adapters::binary_available("python") {
762 f.warn(
763 "python",
764 "not on PATH — `devp restore` cannot rebuild a plain virtual environment",
765 );
766 }
767}
768
769fn check_registry_health(f: &mut Findings, registry: Option<&Registry>) {
770 f.section("Registered repositories");
771
772 let Some(registry) = registry else { return };
773 if registry.repo_count() == 0 {
774 f.note("", "none yet — `devp init ~/Code` or `devp link .`");
775 return;
776 }
777
778 let entries = engine::get_full_status(registry);
779 let count = |want: &SkipReason| {
780 entries
781 .iter()
782 .filter(|e| std::mem::discriminant(&e.reason) == std::mem::discriminant(want))
783 .count()
784 };
785 let reclaimable: u64 = entries.iter().map(|e| e.reclaimable_bytes).sum();
786
787 f.note(
788 "Total",
789 &format!(
790 "{} registered, {} reclaimable",
791 entries.len(),
792 output::format_bytes(reclaimable)
793 ),
794 );
795 f.note(
796 "Breakdown",
797 &format!(
798 "{} candidates, {} active, {} ignored, {} with no bloat",
799 count(&SkipReason::Candidate),
800 count(&SkipReason::Active),
801 count(&SkipReason::Ignored),
802 count(&SkipReason::NoBloat),
803 ),
804 );
805
806 let missing: Vec<&Path> = entries
811 .iter()
812 .filter(|e| matches!(e.reason, SkipReason::PathMissing))
813 .map(|e| e.path.as_path())
814 .collect();
815
816 match missing.len() {
817 0 => {}
818 1 => {
819 f.warn(
820 "Missing",
821 &format!(
822 "{} no longer exists — `devp unlink {}`",
823 output::clean_path(missing[0]),
824 output::clean_path(missing[0])
825 ),
826 );
827 f.fixable(Repair::UnlinkMissing);
828 }
829 n => {
830 f.warn(
831 "Missing",
832 &format!(
833 "{n} registered paths no longer exist, starting with {} \
834 — `devp unlink --missing` clears all of them",
835 output::clean_path(missing[0])
836 ),
837 );
838 f.fixable(Repair::UnlinkMissing);
839 }
840 }
841
842 for entry in &entries {
845 if let SkipReason::ConfigError(e) = &entry.reason {
846 f.problem(
847 "Unreadable config",
848 &format!("{}: {e}", output::clean_path(&entry.path)),
849 );
850 }
851 }
852}
853
854fn check_release_state(f: &mut Findings, registry: Option<&Registry>) {
855 f.section("Release check");
856
857 let Some(registry) = registry else { return };
858 if !registry.settings.update_check {
859 f.note(
860 "update_check",
861 "off — dev-prune opens no network connection",
862 );
863 return;
864 }
865
866 f.note(
867 "update_check",
868 &format!(
869 "on, every {} {}",
870 registry.settings.update_check_interval_days,
871 output::plural(
872 registry.settings.update_check_interval_days as usize,
873 "day",
874 "days"
875 )
876 ),
877 );
878
879 match registry.last_update_check {
880 Some(at) => f.note(
881 "Last checked",
882 &format!(
883 "{} ({} days ago)",
884 at.format("%Y-%m-%d %H:%M UTC"),
885 (Utc::now() - at).num_days()
886 ),
887 ),
888 None => f.note("Last checked", "never"),
889 }
890
891 match registry.latest_known_version.as_deref() {
892 Some(latest) if latest.trim_start_matches('v') != constants::VERSION => f.warn(
893 "Latest release",
894 &format!("{latest} is available — `devp update` shows how to upgrade"),
895 ),
896 Some(latest) => f.ok("Latest release", &format!("{latest} — up to date")),
897 None => f.note("Latest release", "not known yet"),
898 }
899}
900
901fn check_repository(path_str: &str) -> Result<()> {
906 let path = Path::new(path_str)
907 .canonicalize()
908 .with_context(|| format!("Path not found: {path_str}"))?;
909
910 output::print_header(&format!("dev-prune doctor ({})", output::clean_path(&path)));
911 let mut f = Findings::default();
912
913 let registry = Registry::load().unwrap_or_default();
916
917 let ctx = check_repo_basics(&mut f, &path, ®istry);
918 let projects = check_repo_projects(&mut f, &path, &ctx);
919 let headline = repo_verdict(&ctx, &projects);
920
921 verdict(
922 &f,
923 &format!("{} is in good shape.", output::clean_path(&ctx.path)),
924 Some(&headline),
925 )
926}
927
928struct RepoContext {
930 path: PathBuf,
931 is_git: bool,
932 registered: bool,
933 opted_out: Option<String>,
934 config_broken: bool,
935 idle: bool,
936 idle_days: u64,
937 min_size_bytes: u64,
938 depth: usize,
939}
940
941fn check_repo_basics(f: &mut Findings, path: &Path, registry: &Registry) -> RepoContext {
942 f.section("Repository");
943
944 let is_git = scanner::is_git_repo(path);
945 if is_git {
946 f.ok("Git repository", "yes");
947 } else {
948 f.problem(
949 "Git repository",
950 "no — dev-prune only ever touches Git repositories",
951 );
952 }
953
954 let key = crate::config::canonical_key(path);
955 let entry = registry.repositories.get(&key);
956 match entry {
957 Some(e) if e.enabled => f.ok(
958 "Registered",
959 &format!("yes, since {}", e.added_at.format("%Y-%m-%d")),
960 ),
961 Some(e) => f.warn(
962 "Registered",
963 &format!(
964 "yes since {}, but disabled — `devp config {} --update`",
965 e.added_at.format("%Y-%m-%d"),
966 output::clean_path(path)
967 ),
968 ),
969 None => f.warn(
970 "Registered",
971 "no — a prune pass will not visit it. `devp link .` registers it.",
972 ),
973 }
974 if let Some(at) = entry.and_then(|e| e.last_pruned_at) {
975 f.note("Last pruned", &at.format("%Y-%m-%d %H:%M UTC").to_string());
976 }
977
978 let (per_repo, config_broken) = match PerRepoConfig::load_with_diagnostics(path) {
980 Ok(Some(cfg)) => {
981 f.ok(constants::PER_REPO_CONFIG_FILE, &describe_overrides(&cfg));
982 (Some(cfg), false)
983 }
984 Ok(None) => {
985 f.note(
986 constants::PER_REPO_CONFIG_FILE,
987 "absent — global settings apply",
988 );
989 (None, false)
990 }
991 Err(e) => {
992 f.problem(
993 constants::PER_REPO_CONFIG_FILE,
994 &format!("{e} — the repository is skipped entirely until this parses"),
995 );
996 (None, true)
997 }
998 };
999
1000 let mut opted_out = None;
1001 if path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
1002 opted_out = Some(format!("{} is present", constants::DEVPRUNE_IGNORE_FILE));
1003 } else if per_repo.as_ref().is_some_and(|c| c.ignore) {
1004 opted_out = Some(format!(
1005 "\"ignore\": true in {}",
1006 constants::PER_REPO_CONFIG_FILE
1007 ));
1008 } else if entry.is_some_and(|e| !e.enabled) {
1009 opted_out = Some("disabled in the registry".to_string());
1010 }
1011 match &opted_out {
1012 Some(why) => f.note("Opt-out", why),
1013 None => f.note("Opt-out", "none"),
1014 }
1015
1016 let idle_days = per_repo
1019 .as_ref()
1020 .and_then(|c| c.override_idle_days)
1021 .or_else(|| entry.and_then(|e| e.override_idle_days))
1022 .unwrap_or(registry.settings.idle_days);
1023
1024 let activity = git::get_last_activity(path).ok().flatten();
1025 let idle = git::is_idle_at(activity, idle_days);
1026 match activity {
1027 Some(t) => {
1028 let days = chrono::DateTime::<Utc>::from(t);
1029 let ago = (Utc::now() - days).num_days();
1030 let detail = format!(
1031 "{} ({ago} {} ago), threshold {idle_days}",
1032 days.format("%Y-%m-%d"),
1033 output::plural(ago.unsigned_abs() as usize, "day", "days")
1034 );
1035 if idle {
1036 f.ok("Activity", &format!("{detail} — idle"));
1037 } else {
1038 f.note("Activity", &format!("{detail} — active"));
1039 }
1040 }
1041 None => f.note(
1042 "Activity",
1043 &format!("no commits or source edits found, threshold {idle_days}"),
1044 ),
1045 }
1046
1047 let min_size_mb = per_repo
1048 .as_ref()
1049 .and_then(|c| c.min_size_mb)
1050 .unwrap_or(registry.settings.min_size_mb);
1051 f.note(
1052 "Size floor",
1053 &if min_size_mb == 0 {
1054 "none — every recognised directory counts".to_string()
1055 } else {
1056 format!("{min_size_mb} MiB")
1057 },
1058 );
1059
1060 let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
1061 f.note("Scan depth", &format!("{depth} levels below the root"));
1062
1063 RepoContext {
1064 path: path.to_path_buf(),
1065 is_git,
1066 registered: entry.is_some(),
1067 opted_out,
1068 config_broken,
1069 idle,
1070 idle_days,
1071 min_size_bytes: min_size_mb.saturating_mul(BYTES_PER_MIB),
1072 depth,
1073 }
1074}
1075
1076struct ProjectReport {
1078 prunable: bool,
1080 has_bloat: bool,
1082}
1083
1084fn check_repo_projects(f: &mut Findings, path: &Path, ctx: &RepoContext) -> Vec<ProjectReport> {
1085 f.section("Projects");
1086
1087 if ctx.config_broken {
1088 f.note(
1089 "",
1090 "not scanned — the configuration above has to parse first",
1091 );
1092 return Vec::new();
1093 }
1094
1095 let projects = workspace::discover_to_depth(path, ctx.depth);
1096 if projects.is_empty() {
1097 f.note(
1098 "",
1099 &format!(
1100 "no recognised package-manager project within {} levels. \
1101 Raise it with `devp config set scan_depth N`.",
1102 ctx.depth
1103 ),
1104 );
1105 return Vec::new();
1106 }
1107
1108 let mut reports = Vec::new();
1109 for project in &projects {
1110 for adapter in &project.adapters {
1111 println!();
1112 println!(" {} ({})", project.relative.bold(), adapter.name());
1113
1114 let missing: Vec<&str> = adapter
1117 .lockfiles()
1118 .iter()
1119 .copied()
1120 .filter(|n| !project.path.join(n).exists())
1121 .collect();
1122 match (adapter.lockfiles().is_empty(), missing.is_empty()) {
1123 (true, _) => f.note(" Lockfile", "no single file identifies this manager"),
1124 (false, true) => f.ok(
1125 " Lockfile",
1126 &format!("{} present", adapter.lockfiles().join(", ")),
1127 ),
1128 (false, false) if missing.len() == adapter.lockfiles().len() => f.problem(
1131 " Lockfile",
1132 &format!(
1133 "{} missing — nothing can prove the directory is rebuildable, \
1134 so it will never be pruned",
1135 missing.join(" / ")
1136 ),
1137 ),
1138 (false, false) => f.ok(
1139 " Lockfile",
1140 &format!(
1141 "{} present",
1142 adapter
1143 .lockfiles()
1144 .iter()
1145 .filter(|n| !missing.contains(n))
1146 .copied()
1147 .collect::<Vec<_>>()
1148 .join(", ")
1149 ),
1150 ),
1151 }
1152
1153 let bloat = adapter.bloat_dirs(&project.path);
1154 if bloat.is_empty() {
1155 f.note(" Bloat", "nothing installed — nothing to reclaim");
1156 reports.push(ProjectReport {
1157 prunable: false,
1158 has_bloat: false,
1159 });
1160 continue;
1161 }
1162
1163 let mut prunable = false;
1164 for bd in &bloat {
1165 let label = workspace::relative_label(path, &bd.path);
1166 let size = output::format_bytes(bd.size_bytes);
1167
1168 if std::fs::symlink_metadata(&bd.path)
1169 .map(|m| m.file_type().is_symlink())
1170 .unwrap_or(false)
1171 {
1172 f.warn(
1173 " Bloat",
1174 &format!(
1175 "{label} ({size}) is a symlink — refused, because the storage \
1176 it points at is not this repository's to delete"
1177 ),
1178 );
1179 } else if bd.size_bytes < ctx.min_size_bytes {
1180 f.warn(
1181 " Bloat",
1182 &format!("{label} ({size}) is below the size floor — left alone"),
1183 );
1184 } else {
1185 f.ok(" Bloat", &format!("{label} ({size})"));
1186 prunable = true;
1187 }
1188 }
1189 reports.push(ProjectReport {
1190 prunable,
1191 has_bloat: true,
1192 });
1193 }
1194 }
1195
1196 reports
1197}
1198
1199fn repo_verdict(ctx: &RepoContext, projects: &[ProjectReport]) -> String {
1204 let clean = output::clean_path(&ctx.path);
1205 let no = |detail: String| format!("{} Would `devp run` prune this? {detail}", "✗".red());
1206
1207 if !ctx.is_git {
1208 no("No — not a Git repository. Nothing else is even checked.".to_string())
1209 } else if ctx.config_broken {
1210 no(format!(
1211 "No — `{}` does not parse, and dev-prune will not guess at a config it \
1212 cannot read.",
1213 constants::PER_REPO_CONFIG_FILE
1214 ))
1215 } else if let Some(why) = &ctx.opted_out {
1216 no(format!("No — opted out: {why}."))
1217 } else if !ctx.registered {
1218 no(format!(
1219 "Not in a full pass — it is not registered. `devp link {clean}` adds it; \
1220 `devp run {clean}` prunes it once without registering."
1221 ))
1222 } else if !ctx.idle {
1223 no(format!(
1224 "No — active within the last {} {}. `devp --ignore-idle run {clean}` overrides \
1225 exactly that check and nothing else.",
1226 ctx.idle_days,
1227 output::plural(ctx.idle_days as usize, "day", "days")
1228 ))
1229 } else if projects.is_empty() {
1230 no("No — no package-manager project was found to prune.".to_string())
1231 } else if !projects.iter().any(|p| p.has_bloat) {
1232 no("No — every project here is already clean.".to_string())
1233 } else if !projects.iter().any(|p| p.prunable) {
1234 no("No — everything found is symlinked or below the size floor. See above.".to_string())
1235 } else {
1236 format!(
1237 "{} Would `devp run` prune this? Yes — subject to each lockfile verifying. \
1238 `devp run {clean} --dry-run` lists what would go.",
1239 "✓".green()
1240 )
1241 }
1242}
1243
1244fn describe_overrides(cfg: &PerRepoConfig) -> String {
1246 let mut parts = Vec::new();
1247 if let Some(name) = &cfg.project_name {
1248 parts.push(format!("name={name}"));
1249 }
1250 if let Some(days) = cfg.override_idle_days {
1251 parts.push(format!("idle_days={days}"));
1252 }
1253 if let Some(mb) = cfg.min_size_mb {
1254 parts.push(format!("min_size_mb={mb}"));
1255 }
1256 if let Some(depth) = cfg.scan_depth {
1257 parts.push(format!("scan_depth={depth}"));
1258 }
1259 if cfg.ignore {
1260 parts.push("ignore=true".to_string());
1261 }
1262 if cfg.disable_hooks {
1263 parts.push("disable_hooks=true".to_string());
1264 }
1265 if cfg.disable_daemon {
1266 parts.push("disable_daemon=true".to_string());
1267 }
1268 if parts.is_empty() {
1269 "parses; overrides nothing".to_string()
1270 } else {
1271 format!("parses; {}", parts.join(", "))
1272 }
1273}
1274
1275fn root_cause(e: &anyhow::Error) -> String {
1280 e.chain().last().map(|c| c.to_string()).unwrap_or_default()
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285 use super::*;
1286 use tempfile::TempDir;
1287
1288 #[test]
1289 fn an_integration_pointing_at_a_deleted_binary_is_a_problem_not_a_warning() {
1290 let mut f = Findings::default();
1293 report_integration_target(
1294 &mut f,
1295 "Scheduler",
1296 "installed",
1297 Some(PathBuf::from("/nonexistent/dev-prune")),
1298 "Re-register it.",
1299 );
1300 assert_eq!(f.warnings.len(), 0);
1301 assert_eq!(f.problems.len(), 1);
1302 assert!(f.problems[0].contains("no longer exists"));
1303 }
1304
1305 #[test]
1306 fn an_integration_whose_binary_is_present_passes() {
1307 let tmp = TempDir::new().unwrap();
1308 let exe = tmp.path().join("dev-prune");
1309 std::fs::write(&exe, b"binary").unwrap();
1310
1311 let mut f = Findings::default();
1312 report_integration_target(&mut f, "Scheduler", "installed", Some(exe), "Re-register.");
1313 assert!(f.problems.is_empty() && f.warnings.is_empty());
1314 }
1315
1316 #[test]
1317 fn an_unreadable_entry_is_not_reported_as_broken() {
1318 let mut f = Findings::default();
1321 report_integration_target(&mut f, "Scheduler", "installed", None, "Re-register.");
1322 assert!(f.problems.is_empty() && f.warnings.is_empty());
1323 }
1324
1325 #[test]
1326 fn overrides_are_listed_by_name() {
1327 let mut cfg = PerRepoConfig::default();
1328 assert_eq!(describe_overrides(&cfg), "parses; overrides nothing");
1329
1330 cfg.override_idle_days = Some(30);
1331 cfg.ignore = true;
1332 assert_eq!(
1333 describe_overrides(&cfg),
1334 "parses; idle_days=30, ignore=true"
1335 );
1336 }
1337
1338 #[test]
1341 fn a_zero_floor_is_reported_as_an_override() {
1342 let cfg = PerRepoConfig {
1343 min_size_mb: Some(0),
1344 ..PerRepoConfig::default()
1345 };
1346 assert_eq!(describe_overrides(&cfg), "parses; min_size_mb=0");
1347 }
1348
1349 #[test]
1350 fn a_repository_that_is_not_a_git_repo_is_the_first_thing_reported() {
1351 let dir = TempDir::new().unwrap();
1352 let ctx = RepoContext {
1353 path: dir.path().to_path_buf(),
1354 is_git: false,
1355 registered: false,
1356 opted_out: Some("ignore.devprune.json is present".to_string()),
1357 config_broken: true,
1358 idle: true,
1359 idle_days: 15,
1360 min_size_bytes: 0,
1361 depth: 6,
1362 };
1363 let line = repo_verdict(&ctx, &[]);
1366 assert!(line.contains("not a Git repository"), "{line}");
1367 }
1368
1369 #[test]
1370 fn warnings_alone_do_not_fail_the_command() {
1371 let mut f = Findings::default();
1372 f.warn("Scheduler", "not installed");
1373 assert!(verdict(&f, "fine", None).is_ok());
1374
1375 f.problem("PATH", "missing");
1376 assert!(verdict(&f, "fine", None).is_err());
1377 }
1378
1379 #[test]
1380 #[cfg(windows)]
1381 fn a_path_entry_matches_regardless_of_case_and_trailing_separator() {
1382 let dir = Path::new(r"C:\Users\Someone\AppData\Roaming\dev-prune\bin");
1385 assert!(same_dir(
1386 Path::new(r"c:\users\someone\appdata\roaming\dev-prune\bin\"),
1387 dir
1388 ));
1389 assert!(!same_dir(Path::new(r"C:\Windows"), dir));
1390 }
1391
1392 #[test]
1393 #[cfg(not(windows))]
1394 fn a_path_entry_on_unix_is_matched_exactly() {
1395 assert!(same_dir(
1396 Path::new("/usr/local/bin"),
1397 Path::new("/usr/local/bin")
1398 ));
1399 assert!(!same_dir(
1400 Path::new("/USR/local/bin"),
1401 Path::new("/usr/local/bin")
1402 ));
1403 }
1404
1405 #[test]
1406 fn a_stale_twin_is_told_apart_from_a_current_one() {
1407 let dir = TempDir::new().unwrap();
1408 let a = dir.path().join("dev-prune");
1409 let b = dir.path().join("devp");
1410 std::fs::write(&a, b"version two").unwrap();
1411 std::fs::write(&b, b"version two").unwrap();
1412 assert!(same_binary(&a, &b));
1413
1414 std::fs::write(&b, b"version one").unwrap();
1416 assert!(!same_binary(&a, &b));
1417
1418 std::fs::write(&b, b"short").unwrap();
1419 assert!(!same_binary(&a, &b));
1420 assert!(!same_binary(&a, &dir.path().join("missing")));
1421 }
1422}