1use std::cmp::Ordering;
27use std::path::{Path, PathBuf};
28
29use anyhow::{Context, Result};
30use chrono::Utc;
31
32use crate::adapters;
33use crate::channel::Channel;
34use crate::commands::hook::{self, HookState};
35use crate::config::{PerRepoConfig, Registry};
36use crate::constants;
37use crate::daemon;
38use crate::engine::{self, BYTES_PER_MIB, SkipReason};
39use crate::output;
40use crate::scanner::{self, git};
41use crate::setup;
42use crate::workspace;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum Repair {
48 Twin,
50 SkillFile,
52 Hooks,
54 Scheduler,
56 UnlinkMissing,
58 RepoConfigs,
60}
61
62#[derive(Default)]
65struct Findings {
66 warnings: Vec<String>,
67 problems: Vec<String>,
68 fixes: Vec<Repair>,
70 fixable_problems: Vec<usize>,
73 problem_repairs: Vec<Repair>,
77}
78
79impl Findings {
80 fn ok(&mut self, label: &str, detail: &str) {
82 println!(" {label:<22} {} {detail}", "✓".green());
83 }
84
85 fn warn(&mut self, label: &str, detail: &str) {
87 println!(" {label:<22} {} {detail}", "!".yellow());
88 self.warnings.push(format!("{label}: {detail}"));
89 }
90
91 fn problem(&mut self, label: &str, detail: &str) {
93 println!(" {label:<22} {} {detail}", "✗".red());
94 self.problems.push(format!("{label}: {detail}"));
95 }
96
97 fn fixable(&mut self, repair: Repair) {
99 if !self.fixes.contains(&repair) {
100 self.fixes.push(repair);
101 }
102 }
103
104 fn fixable_problem(&mut self, repair: Repair) {
109 self.fixable(repair);
110 if !self.problem_repairs.contains(&repair) {
111 self.problem_repairs.push(repair);
112 }
113 if let Some(last) = self.problems.len().checked_sub(1)
114 && !self.fixable_problems.contains(&last)
115 {
116 self.fixable_problems.push(last);
117 }
118 }
119
120 fn note(&self, label: &str, detail: &str) {
122 println!(" {label:<22} {detail}");
123 }
124
125 fn section(&self, title: &str) {
126 println!();
127 println!("{}", title.bold());
128 }
129}
130
131use colored::Colorize as _;
133
134pub fn run(path: Option<&str>, fix: bool) -> Result<()> {
141 match path {
142 Some(p) => check_repository(p),
143 None => check_installation(fix),
144 }
145}
146
147fn verdict(f: &Findings, all_clear: &str, headline: Option<&str>) -> Result<()> {
153 f.section("Verdict");
154
155 if let Some(line) = headline {
156 println!(" {line}");
157 println!();
158 }
159
160 if f.problems.is_empty() && f.warnings.is_empty() {
161 output::print_success(all_clear);
162 return Ok(());
163 }
164
165 for w in &f.warnings {
166 println!(" {} {w}", "!".yellow());
167 }
168 for p in &f.problems {
169 println!(" {} {p}", "✗".red());
170 }
171
172 if !f.fixes.is_empty() {
173 println!();
174 println!(
175 " {} of these can be repaired automatically — run `devp doctor --fix`.",
176 f.fixes.len()
177 );
178 }
179
180 println!();
181 println!(" Troubleshooting: {}", constants::TROUBLESHOOTING_URL);
182
183 if f.problems.is_empty() {
184 println!();
185 output::print_info(&format!(
186 "{} {} — nothing broken.",
187 f.warnings.len(),
188 output::plural(f.warnings.len(), "warning", "warnings")
189 ));
190 return Ok(());
191 }
192
193 anyhow::bail!(
194 "{} {} found.",
195 f.problems.len(),
196 output::plural(f.problems.len(), "problem", "problems")
197 )
198}
199
200fn check_installation(fix: bool) -> Result<()> {
205 output::print_header("dev-prune doctor");
206 let mut f = Findings::default();
207
208 check_binary(&mut f);
209 check_install_channel(&mut f);
210 check_other_copies(&mut f);
211 let registry = check_configuration(&mut f);
212 check_integrations(&mut f, registry.as_ref());
213 check_package_managers(&mut f, registry.as_ref());
214 check_registry_health(&mut f, registry.as_ref());
215 check_release_state(&mut f, registry.as_ref());
216
217 if fix && !f.fixes.is_empty() {
218 return apply_repairs(&f, registry.as_ref());
219 }
220 if fix {
221 return verdict(&f, "Everything checks out — nothing to repair.", None);
225 }
226 verdict(&f, "Everything checks out.", None)
227}
228
229fn apply_repairs(f: &Findings, registry: Option<&Registry>) -> Result<()> {
249 f.section("Repairs");
250
251 let ok = |label: &str, detail: &str| println!(" {label:<22} {} {detail}", "✓".green());
252 let skipped = |label: &str, detail: &str| println!(" {label:<22} {} {detail}", "!".yellow());
253 let failed_line = |label: &str, detail: &str| println!(" {label:<22} {} {detail}", "✗".red());
254
255 let chain = registry
256 .map(|r| r.settings.auto_hooks_chain)
257 .unwrap_or(false);
258 let interval = registry
259 .map(|r| r.settings.check_interval_days)
260 .unwrap_or(constants::DEFAULT_CHECK_INTERVAL_DAYS);
261 let installs_off = setup::no_auto_setup_requested();
262
263 let mut repaired = 0usize;
264 let mut failures = 0usize;
265 let mut attention = 0usize;
266 let mut skipped_problems = 0usize;
270
271 for repair in &f.fixes {
272 let is_problem = f.problem_repairs.contains(repair);
273 let (label, manual) = match repair {
274 Repair::Twin => ("Binary pair", "run `dev-prune setup` yourself"),
275 Repair::SkillFile => ("SKILL.md", "run `devp skill` yourself"),
276 Repair::Hooks => ("Git hooks", "run `devp hook install` yourself"),
277 Repair::Scheduler => ("Scheduler", "run `devp daemon install` yourself"),
278 Repair::UnlinkMissing => {
279 match crate::commands::link::run_unlink_missing() {
280 Ok(()) => repaired += 1,
281 Err(e) => {
282 failed_line("Registry", &format!("{e:#}"));
283 failures += 1;
284 }
285 }
286 continue;
287 }
288 Repair::RepoConfigs => {
289 match heal_repo_configs() {
290 Ok(healed) => {
291 ok(
292 "Repo configs",
293 &format!(
294 "{healed} unreadable `.devprune.json` {} replaced with defaults — \
295 the broken originals are kept beside them as \
296 `.devprune.json.broken`",
297 output::plural(healed, "file", "files")
298 ),
299 );
300 repaired += 1;
301 }
302 Err(e) => {
303 failed_line("Repo configs", &format!("{e:#}"));
304 failures += 1;
305 }
306 }
307 continue;
308 }
309 };
310 if installs_off && matches!(repair, Repair::Twin | Repair::Hooks | Repair::Scheduler) {
313 skipped(
314 label,
315 &format!("{} is set — {manual}", setup::ENV_NO_AUTO_SETUP),
316 );
317 attention += 1;
318 if is_problem {
319 skipped_problems += 1;
320 }
321 continue;
322 }
323 let outcome = match repair {
324 Repair::Twin => setup::ensure_alias(),
325 Repair::SkillFile => setup::ensure_skill_file(),
326 Repair::Hooks => setup::ensure_hooks(chain),
327 Repair::Scheduler => setup::ensure_daemon(interval),
328 Repair::UnlinkMissing | Repair::RepoConfigs => unreachable!("handled above"),
329 };
330 match outcome {
331 setup::Outcome::Installed => {
332 ok(label, "repaired");
333 repaired += 1;
334 }
335 setup::Outcome::AlreadyPresent => {
336 ok(label, "already in place");
337 repaired += 1;
338 }
339 setup::Outcome::Skipped(why) => {
340 skipped(label, &why);
341 attention += 1;
342 if is_problem {
343 skipped_problems += 1;
344 }
345 }
346 setup::Outcome::Failed(why) => {
347 failed_line(label, &why);
348 failures += 1;
349 }
350 }
351 }
352
353 f.section("Verdict");
354 let unfixable: Vec<&String> = f
355 .problems
356 .iter()
357 .enumerate()
358 .filter(|(i, _)| !f.fixable_problems.contains(i))
359 .map(|(_, p)| p)
360 .collect();
361 for p in &unfixable {
362 println!(" {} {p} (not auto-repairable)", "✗".red());
363 }
364 if !unfixable.is_empty() {
365 println!();
366 println!(" Troubleshooting: {}", constants::TROUBLESHOOTING_URL);
367 }
368 println!();
369 output::print_info(&format!(
370 "{repaired} repaired, {attention} skipped, {failures} failed. \
371 Run `devp doctor` to confirm."
372 ));
373
374 let unresolved = failures + skipped_problems + unfixable.len();
375 if unresolved > 0 {
376 anyhow::bail!(
377 "{unresolved} {} could not be repaired.",
378 output::plural(unresolved, "finding", "findings")
379 );
380 }
381 Ok(())
382}
383
384fn check_binary(f: &mut Findings) {
385 f.section("Installation");
386 f.note("Version", constants::VERSION);
387
388 match crate::native_arch_if_emulated() {
392 Some(native) => f.warn(
393 "Architecture",
394 &format!(
395 "this is the {} build, but the machine is {native} — reinstall to get the \
396 native one: `devp update`",
397 std::env::consts::ARCH
398 ),
399 ),
400 None => f.ok("Architecture", std::env::consts::ARCH),
401 }
402
403 let Ok(exe) = std::env::current_exe() else {
404 f.warn("Executable", "the running binary's own path is unavailable");
405 return;
406 };
407 f.note("Executable", &output::clean_path(&exe));
408
409 let Some(dir) = exe.parent() else { return };
410
411 let running = if exe
416 .file_stem()
417 .and_then(|s| s.to_str())
418 .is_some_and(|stem| stem.eq_ignore_ascii_case("devp"))
419 {
420 "devp"
421 } else {
422 "dev-prune"
423 };
424 let twin_stem = if running == "devp" {
425 "dev-prune"
426 } else {
427 "devp"
428 };
429 let twin = dir.join(if cfg!(windows) {
430 format!("{twin_stem}.exe")
431 } else {
432 twin_stem.to_string()
433 });
434 if !twin.exists() {
435 if crate::channel::Channel::detect() == crate::channel::Channel::Npm {
442 f.ok(twin_stem, "provided by npm as a command of its own");
443 } else {
444 f.warn(
445 twin_stem,
446 &format!("not installed next to {running} — run `{running} setup`"),
447 );
448 f.fixable(Repair::Twin);
450 }
451 } else if same_binary(&exe, &twin) {
452 f.ok(twin_stem, &output::clean_path(&twin));
453 } else {
454 f.warn(
457 twin_stem,
458 &format!(
459 "{} is not the same binary as {} — one of the pair is stale and \
460 silently runs a different version. `dev-prune setup` refreshes `devp` \
461 from the canonical `dev-prune`.",
462 output::clean_path(&twin),
463 output::clean_path(&exe)
464 ),
465 );
466 if running == "dev-prune" {
470 f.fixable(Repair::Twin);
471 }
472 }
473
474 let sep = if cfg!(windows) { ';' } else { ':' };
475 let on_path = std::env::var("PATH")
476 .unwrap_or_default()
477 .split(sep)
478 .any(|p| !p.is_empty() && same_dir(Path::new(p), dir));
479 if on_path {
480 f.ok("PATH", &output::clean_path(dir));
481 } else {
482 f.warn(
486 "PATH",
487 &format!(
488 "{} is not on PATH — `devp` will not resolve in a new shell. \
489 `dev-prune setup` adds it.",
490 output::clean_path(dir)
491 ),
492 );
493 }
494}
495
496fn check_install_channel(f: &mut Findings) {
504 let channel = Channel::detect();
505 let detail = match (channel.upgrade_command(), channel.uninstall_command()) {
506 (Some(upgrade), Some(uninstall)) => {
507 format!(
508 "{} — upgrade `{upgrade}`, remove `{uninstall}`",
509 channel.label()
510 )
511 }
512 (Some(upgrade), None) => format!("{} — upgrade `{upgrade}`", channel.label()),
515 _ => format!(
516 "{} — `devp update --install` still upgrades it in place",
517 channel.label()
518 ),
519 };
520 f.ok("Install channel", &detail);
521
522 if channel == Channel::Installer
525 && let Some(receipt) = crate::receipt::load()
526 {
527 f.ok("Install receipt", &crate::receipt::summary(&receipt));
528 }
529}
530
531fn check_other_copies(f: &mut Findings) {
546 let mine = std::env::current_exe().ok();
547 let managed_dir = setup::managed_exe_path()
548 .ok()
549 .and_then(|p| p.parent().map(Path::to_path_buf));
550
551 let search = copy_search_dirs(
552 &std::env::var("PATH").unwrap_or_default(),
553 dirs::home_dir().as_deref(),
554 );
555 let copies = binaries_in(&search, managed_dir.as_deref());
556
557 let stale: Vec<String> = copies
562 .iter()
563 .filter(|path| mine.as_deref() != Some(path.as_path()))
564 .filter_map(|path| {
565 let version = setup::binary_version(path)?;
570 let ours = setup::parse_version(constants::VERSION)?;
571 (version != ours).then(|| {
572 let channel = Channel::detect_at(path, managed_dir.as_deref());
573 stale_copy_line(path, version, channel)
574 })
575 })
576 .collect();
577
578 if stale.is_empty() {
579 f.ok("Other copies", "none on PATH running a different version");
580 return;
581 }
582 f.warn(
583 "Other copies",
584 &format!(
585 "{} — whichever comes first on PATH is the one `devp` runs, and \
586 `devp update --install` only replaces the managed copy.",
587 stale.join("; ")
588 ),
589 );
590}
591
592fn stale_copy_line(path: &Path, version: (u64, u64, u64), channel: Channel) -> String {
600 let (major, minor, patch) = version;
601 let remedy = match channel.uninstall_command() {
602 Some(cmd) => format!("from {}, remove with `{cmd}`", channel.label()),
603 None if channel == Channel::Installer => {
607 "left by the install script, remove with `devp uninstall`".to_string()
608 }
609 None => "no package manager owns it; delete the file yourself".to_string(),
610 };
611 format!(
612 "{} (v{major}.{minor}.{patch}, {remedy})",
613 output::clean_path(path)
614 )
615}
616
617fn copy_search_dirs(path_var: &str, home: Option<&Path>) -> Vec<PathBuf> {
631 let sep = if cfg!(windows) { ';' } else { ':' };
632 let mut dirs: Vec<PathBuf> = path_var
633 .split(sep)
634 .filter(|p| !p.is_empty())
635 .map(PathBuf::from)
636 .collect();
637
638 dirs.extend(crate::channel::install_dirs(home));
639 dirs
640}
641
642fn binaries_in(dirs: &[PathBuf], skip_dir: Option<&Path>) -> Vec<PathBuf> {
650 let names: [&str; 2] = if cfg!(windows) {
651 ["dev-prune.exe", "devp.exe"]
652 } else {
653 ["dev-prune", "devp"]
654 };
655 let mut found: Vec<PathBuf> = Vec::new();
656 for dir in dirs {
657 if skip_dir.is_some_and(|skip| same_dir(dir, skip)) {
658 continue;
659 }
660 for name in names {
661 let candidate = dir.join(name);
662 if candidate.is_file()
667 && !found.iter().any(|seen| {
668 seen == &candidate
669 || (seen.parent() == candidate.parent() && same_binary(seen, &candidate))
670 })
671 {
672 found.push(candidate);
673 }
674 }
675 }
676 found
677}
678
679fn same_dir(entry: &Path, dir: &Path) -> bool {
686 if entry == dir {
687 return true;
688 }
689 cfg!(windows) && {
690 let norm = |p: &Path| {
691 p.to_string_lossy()
692 .trim_end_matches(['\\', '/'])
693 .to_lowercase()
694 };
695 norm(entry) == norm(dir)
696 }
697}
698
699fn same_binary(a: &Path, b: &Path) -> bool {
705 let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
706 return false;
707 };
708 if ma.len() != mb.len() {
709 return false;
710 }
711 matches!((std::fs::read(a), std::fs::read(b)), (Ok(ba), Ok(bb)) if ba == bb)
712}
713
714fn check_configuration(f: &mut Findings) -> Option<Registry> {
719 f.section("Configuration");
720
721 let dir = match Registry::config_dir() {
722 Ok(d) => d,
723 Err(e) => {
724 f.problem("Config directory", &format!("cannot be resolved: {e}"));
725 return None;
726 }
727 };
728 f.note("Config directory", &output::clean_path(&dir));
729 if std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE).is_ok() {
730 f.note(
731 "",
732 &format!("(set by {})", constants::ENV_CONFIG_DIR_OVERRIDE),
733 );
734 }
735
736 let path = dir.join(constants::REGISTRY_FILENAME);
737 if !path.exists() {
738 f.ok("registry.json", "not created yet — defaults apply");
741 return Some(Registry::default());
742 }
743
744 let registry = match Registry::load_from(&path) {
745 Ok(r) => r,
746 Err(e) => {
747 f.problem(
748 "registry.json",
749 &format!(
750 "{} — dev-prune refuses to guess at a config it cannot read. \
751 Fix the syntax, or delete the file to start from defaults.",
752 root_cause(&e)
753 ),
754 );
755 return None;
756 }
757 };
758
759 f.ok(
760 "registry.json",
761 &format!(
762 "readable — {} {} registered",
763 registry.repo_count(),
764 output::plural(registry.repo_count(), "repository", "repositories")
765 ),
766 );
767
768 let invalid = crate::commands::config::invalid_settings(®istry.settings);
769 if invalid.is_empty() {
770 f.ok(
771 "Settings",
772 &format!(
773 "all {} within range",
774 crate::commands::config::setting_count()
775 ),
776 );
777 } else {
778 for (key, why) in &invalid {
779 f.problem("Settings", &format!("{key}: {why}"));
780 }
781 }
782
783 Some(registry)
784}
785
786fn check_integrations(f: &mut Findings, registry: Option<&Registry>) {
787 f.section("Integrations");
788
789 match setup::skill_path() {
790 Ok(p) if p.exists() => f.ok("SKILL.md", &output::clean_path(&p)),
791 _ => {
792 f.warn("SKILL.md", "not exported — run `devp skill`");
793 f.fixable(Repair::SkillFile);
794 }
795 }
796
797 if crate::commands::icon::is_registered() {
798 f.ok("File icons", "registered with the file manager");
799 } else {
800 f.warn("File icons", "not registered — run `devp icon`");
801 }
802
803 if !hook::git_available() {
804 f.warn(
805 "Git hooks",
806 "git is not on PATH, so repositories cannot auto-register",
807 );
808 } else {
809 match hook::state() {
810 Ok(HookState::Active) if hook::shims_incomplete() => {
816 f.warn(
817 "Git hooks",
818 concat!(
819 "active, but installed without passthrough shims — ",
820 "every repository's own `.git/hooks` is being ignored ",
821 "machine-wide. `devp hook install` rewrites them to forward."
822 ),
823 );
824 f.fixable(Repair::Hooks);
825 }
826 Ok(HookState::Active) => check_hook_target(f, "active"),
827 Ok(HookState::Absent) => f.warn("Git hooks", "not installed — run `devp hook install`"),
828 Ok(HookState::Chained { previous, drifted }) if drifted.is_empty() => {
829 check_hook_target(f, &format!("active, chained to `{previous}`"))
830 }
831 Ok(HookState::Chained { previous, drifted }) => {
832 f.warn(
833 "Git hooks",
834 &format!(
835 "chained to `{previous}`, but {} not forwarded ({}) — \
836 re-run `devp hook install --chain`",
837 drifted.len(),
838 drifted.join(", ")
839 ),
840 );
841 f.fixable(Repair::Hooks);
844 }
845 Ok(HookState::Foreign(p)) => f.warn(
846 "Git hooks",
847 &format!(
848 "core.hooksPath belongs to `{p}` — install in front of it with \
849 `devp hook install --chain`"
850 ),
851 ),
852 Err(e) => f.warn("Git hooks", &format!("state unknown ({e})")),
853 }
854 }
855
856 match daemon::daemon_status() {
857 Ok(daemon::DaemonStatus::Installed) => check_scheduler_target(f),
858 Ok(daemon::DaemonStatus::NotInstalled) => f.warn(
859 "Scheduler",
860 "not installed — nothing prunes on its own. `devp daemon install` adds it.",
861 ),
862 Ok(daemon::DaemonStatus::Unknown(why)) => f.warn("Scheduler", &why),
863 Err(e) => f.warn("Scheduler", &format!("state unknown ({e})")),
864 }
865
866 if let Some(r) = registry {
867 f.note(
868 "Automatic setup",
869 &format!(
870 "auto_setup={} auto_hooks={} auto_daemon={}",
871 r.settings.auto_setup, r.settings.auto_hooks, r.settings.auto_daemon
872 ),
873 );
874 }
875
876 if let Some(why) = setup::unattended_environment() {
879 f.note("", &format!("unattended installation is off because {why}"));
880 }
881 if setup::no_auto_setup_requested() {
884 f.note(
885 "",
886 &format!(
887 "{} is set — nothing installs by itself. `devp setup` still works.",
888 setup::ENV_NO_AUTO_SETUP
889 ),
890 );
891 }
892}
893
894fn report_integration_target(
908 f: &mut Findings,
909 label: &str,
910 installed: &str,
911 recorded: Option<std::path::PathBuf>,
912 repair: &str,
913) -> bool {
914 match recorded {
915 None => f.ok(label, installed),
918 Some(path) if path.is_file() => f.ok(
919 label,
920 &format!("{installed} — {}", output::clean_path(&path)),
921 ),
922 Some(path) => {
923 f.problem(
924 label,
925 &format!(
926 "registered, but `{}` no longer exists — it never runs. {repair}",
927 output::clean_path(&path)
928 ),
929 );
930 return true;
931 }
932 }
933 false
934}
935
936fn check_scheduler_target(f: &mut Findings) {
937 if report_integration_target(
938 f,
939 "Scheduler",
940 "installed",
941 daemon::registered_exe_path(),
942 "Re-register it with `devp daemon install`.",
943 ) {
944 f.fixable_problem(Repair::Scheduler);
945 }
946}
947
948fn check_hook_target(f: &mut Findings, installed: &str) {
949 if report_integration_target(
950 f,
951 "Git hooks",
952 installed,
953 hook::registered_exe_path(),
954 "Rewrite them with `devp hook install`.",
955 ) {
956 f.fixable_problem(Repair::Hooks);
957 }
958}
959
960fn check_package_managers(f: &mut Findings, registry: Option<&Registry>) {
967 f.section("Package managers");
968
969 let (needed, required): (Vec<String>, bool) = match registry {
973 Some(r) if r.repo_count() > 0 => {
974 let mut names: Vec<String> = engine::get_full_status(r)
975 .into_iter()
976 .flat_map(|e| e.adapters)
977 .collect();
978 names.sort();
979 names.dedup();
980 (names, true)
981 }
982 _ => (
983 adapters::get_all_adapters()
984 .iter()
985 .map(|a| a.name().to_string())
986 .collect(),
987 false,
988 ),
989 };
990
991 if needed.is_empty() {
992 f.note(
993 "",
994 "no package managers are needed by the registered repositories",
995 );
996 return;
997 }
998 if !required {
999 f.note("", "nothing is registered yet, so this is the full list");
1000 }
1001
1002 for status in adapters::scan_required_binaries(&needed) {
1003 match (status.available, status.version) {
1004 (true, Some(v)) => f.ok(&status.name, &v),
1005 (true, None) => f.ok(&status.name, "available"),
1006 (false, _) if required => {
1007 let detail =
1008 "not on PATH — projects using it cannot be verified, pruned or restored";
1009 match adapters::install_hint(&status.name) {
1010 Some(hint) => f.warn(&status.name, &format!("{detail}. Install it: {hint}")),
1011 None => f.warn(&status.name, detail),
1012 }
1013 }
1014 (false, _) => f.note(&status.name, "not installed"),
1015 }
1016 }
1017
1018 if required && needed.iter().any(|n| n == "venv") && !adapters::binary_available("python") {
1021 f.warn(
1022 "python",
1023 "not on PATH — `devp restore` cannot rebuild a plain virtual environment. \
1024 Install it: https://www.python.org/downloads/",
1025 );
1026 }
1027}
1028
1029fn check_registry_health(f: &mut Findings, registry: Option<&Registry>) {
1030 f.section("Registered repositories");
1031
1032 let Some(registry) = registry else { return };
1033 if registry.repo_count() == 0 {
1034 f.note("", "none yet — `devp init ~/Code` or `devp link .`");
1035 return;
1036 }
1037
1038 let entries = engine::get_full_status(registry);
1039 let count = |want: &SkipReason| {
1040 entries
1041 .iter()
1042 .filter(|e| std::mem::discriminant(&e.reason) == std::mem::discriminant(want))
1043 .count()
1044 };
1045 let reclaimable: u64 = entries.iter().map(|e| e.reclaimable_bytes).sum();
1046
1047 f.note(
1048 "Total",
1049 &format!(
1050 "{} registered, {} reclaimable",
1051 entries.len(),
1052 output::format_bytes(reclaimable)
1053 ),
1054 );
1055 f.note(
1056 "Breakdown",
1057 &format!(
1058 "{} candidates, {} active, {} ignored, {} with no bloat",
1059 count(&SkipReason::Candidate),
1060 count(&SkipReason::Active),
1061 count(&SkipReason::Ignored),
1062 count(&SkipReason::NoBloat),
1063 ),
1064 );
1065
1066 let missing: Vec<&Path> = entries
1071 .iter()
1072 .filter(|e| matches!(e.reason, SkipReason::PathMissing))
1073 .map(|e| e.path.as_path())
1074 .collect();
1075
1076 match missing.len() {
1077 0 => {}
1078 1 => {
1079 f.warn(
1080 "Missing",
1081 &format!(
1082 "{} no longer exists — `devp unlink {}`",
1083 output::clean_path(missing[0]),
1084 output::clean_path(missing[0])
1085 ),
1086 );
1087 f.fixable(Repair::UnlinkMissing);
1088 }
1089 n => {
1090 f.warn(
1091 "Missing",
1092 &format!(
1093 "{n} registered paths no longer exist, starting with {} \
1094 — `devp unlink --missing` clears all of them",
1095 output::clean_path(missing[0])
1096 ),
1097 );
1098 f.fixable(Repair::UnlinkMissing);
1099 }
1100 }
1101
1102 for entry in &entries {
1105 if let SkipReason::ConfigError(e) = &entry.reason {
1106 f.problem(
1107 "Unreadable config",
1108 &format!("{}: {e}", output::clean_path(&entry.path)),
1109 );
1110 f.fixable_problem(Repair::RepoConfigs);
1111 }
1112 }
1113}
1114
1115fn check_release_state(f: &mut Findings, registry: Option<&Registry>) {
1116 f.section("Release check");
1117
1118 let Some(registry) = registry else { return };
1119
1120 if registry.settings.version_lock {
1123 f.note(
1124 "version_lock",
1125 &format!(
1126 "on — this copy stays at v{}. auto_update, `devp update --install`, \
1127 `devp install --channel` and the install scripts all stand down until \
1128 `devp config set version_lock false`",
1129 constants::VERSION
1130 ),
1131 );
1132 }
1133
1134 if !registry.settings.update_check {
1135 f.note(
1136 "update_check",
1137 "off — dev-prune opens no network connection",
1138 );
1139 return;
1140 }
1141
1142 f.note(
1143 "update_check",
1144 &format!(
1145 "on, every {} {}",
1146 registry.settings.update_check_interval_days,
1147 output::plural(
1148 registry.settings.update_check_interval_days as usize,
1149 "day",
1150 "days"
1151 )
1152 ),
1153 );
1154
1155 match registry.last_update_check {
1156 Some(at) => f.note(
1157 "Last checked",
1158 &format!(
1159 "{} ({} days ago)",
1160 at.format("%Y-%m-%d %H:%M UTC"),
1161 (Utc::now() - at).num_days()
1162 ),
1163 ),
1164 None => f.note("Last checked", "never"),
1165 }
1166
1167 match registry.latest_known_version.as_deref() {
1172 Some(latest) => {
1173 let latest_core = latest.trim_start_matches('v');
1174 match super::update::compare_versions(constants::VERSION, latest_core) {
1175 Some(Ordering::Less) if registry.settings.version_lock => f.note(
1179 "Latest release",
1180 &format!(
1181 "{latest} is available, and version_lock is holding this copy at v{}",
1182 constants::VERSION
1183 ),
1184 ),
1185 Some(Ordering::Less) => f.warn(
1186 "Latest release",
1187 &format!("{latest} is available — `devp update` shows how to upgrade"),
1188 ),
1189 Some(Ordering::Greater) => f.ok(
1190 "Latest release",
1191 &format!("{latest} — this build is newer than the last published one"),
1192 ),
1193 Some(Ordering::Equal) => f.ok("Latest release", &format!("{latest} — up to date")),
1194 None => f.note(
1195 "Latest release",
1196 &format!("{latest} — could not be compared to {}", constants::VERSION),
1197 ),
1198 }
1199 }
1200 None => f.note("Latest release", "not known yet"),
1201 }
1202}
1203
1204fn check_repository(path_str: &str) -> Result<()> {
1209 let path = Path::new(path_str)
1210 .canonicalize()
1211 .with_context(|| format!("Path not found: {path_str}"))?;
1212
1213 output::print_header(&format!("dev-prune doctor ({})", output::clean_path(&path)));
1214 let mut f = Findings::default();
1215
1216 let registry = Registry::load().unwrap_or_default();
1219
1220 let ctx = check_repo_basics(&mut f, &path, ®istry);
1221 let projects = check_repo_projects(&mut f, &path, &ctx);
1222 let headline = repo_verdict(&ctx, &projects);
1223
1224 verdict(
1225 &f,
1226 &format!("{} is in good shape.", output::clean_path(&ctx.path)),
1227 Some(&headline),
1228 )
1229}
1230
1231struct RepoContext {
1233 path: PathBuf,
1234 is_git: bool,
1235 registered: bool,
1236 opted_out: Option<String>,
1237 config_broken: bool,
1238 idle: bool,
1239 idle_days: u64,
1240 min_size_bytes: u64,
1241 depth: usize,
1242}
1243
1244fn check_repo_basics(f: &mut Findings, path: &Path, registry: &Registry) -> RepoContext {
1245 f.section("Repository");
1246
1247 let is_git = scanner::is_git_repo(path);
1248 if is_git {
1249 f.ok("Git repository", "yes");
1250 } else {
1251 f.problem(
1252 "Git repository",
1253 "no — dev-prune only ever touches Git repositories",
1254 );
1255 }
1256
1257 let key = crate::config::canonical_key(path);
1258 let entry = registry.repositories.get(&key);
1259 match entry {
1260 Some(e) if e.enabled => f.ok(
1261 "Registered",
1262 &format!("yes, since {}", e.added_at.format("%Y-%m-%d")),
1263 ),
1264 Some(e) => f.warn(
1265 "Registered",
1266 &format!(
1267 "yes since {}, but disabled — `devp config {} --update`",
1268 e.added_at.format("%Y-%m-%d"),
1269 output::clean_path(path)
1270 ),
1271 ),
1272 None => f.warn(
1273 "Registered",
1274 "no — a prune pass will not visit it. `devp link .` registers it.",
1275 ),
1276 }
1277 if let Some(at) = entry.and_then(|e| e.last_pruned_at) {
1278 f.note("Last pruned", &at.format("%Y-%m-%d %H:%M UTC").to_string());
1279 }
1280
1281 let (per_repo, config_broken) = match PerRepoConfig::load_with_diagnostics(path) {
1283 Ok(Some(cfg)) => {
1284 f.ok(constants::PER_REPO_CONFIG_FILE, &describe_overrides(&cfg));
1285 (Some(cfg), false)
1286 }
1287 Ok(None) => {
1288 f.note(
1289 constants::PER_REPO_CONFIG_FILE,
1290 "absent — global settings apply",
1291 );
1292 (None, false)
1293 }
1294 Err(e) => {
1295 f.problem(
1296 constants::PER_REPO_CONFIG_FILE,
1297 &format!("{e} — the repository is skipped entirely until this parses"),
1298 );
1299 (None, true)
1300 }
1301 };
1302
1303 let mut opted_out = None;
1304 if path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
1305 opted_out = Some(format!("{} is present", constants::DEVPRUNE_IGNORE_FILE));
1306 } else if per_repo.as_ref().is_some_and(|c| c.ignore) {
1307 opted_out = Some(format!(
1308 "\"ignore\": true in {}",
1309 constants::PER_REPO_CONFIG_FILE
1310 ));
1311 } else if entry.is_some_and(|e| !e.enabled) {
1312 opted_out = Some("disabled in the registry".to_string());
1313 }
1314 match &opted_out {
1315 Some(why) => f.note("Opt-out", why),
1316 None => f.note("Opt-out", "none"),
1317 }
1318
1319 let idle_days = per_repo
1322 .as_ref()
1323 .and_then(|c| c.override_idle_days)
1324 .or_else(|| entry.and_then(|e| e.override_idle_days))
1325 .unwrap_or(registry.settings.idle_days);
1326
1327 let activity = git::get_last_activity(path).ok().flatten();
1328 let idle = git::is_idle_at(activity, idle_days);
1329 match activity {
1330 Some(t) => {
1331 let days = chrono::DateTime::<Utc>::from(t);
1332 let ago = (Utc::now() - days).num_days();
1333 let detail = format!(
1334 "{} ({ago} {} ago), threshold {idle_days}",
1335 days.format("%Y-%m-%d"),
1336 output::plural(ago.unsigned_abs() as usize, "day", "days")
1337 );
1338 if idle {
1339 f.ok("Activity", &format!("{detail} — idle"));
1340 } else {
1341 f.note("Activity", &format!("{detail} — active"));
1342 }
1343 }
1344 None => f.note(
1345 "Activity",
1346 &format!("no commits or source edits found, threshold {idle_days}"),
1347 ),
1348 }
1349
1350 let min_size_mb = per_repo
1351 .as_ref()
1352 .and_then(|c| c.min_size_mb)
1353 .unwrap_or(registry.settings.min_size_mb);
1354 f.note(
1355 "Size floor",
1356 &if min_size_mb == 0 {
1357 "none — every recognised directory counts".to_string()
1358 } else {
1359 format!("{min_size_mb} MiB")
1360 },
1361 );
1362
1363 let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
1364 f.note("Scan depth", &format!("{depth} levels below the root"));
1365
1366 RepoContext {
1367 path: path.to_path_buf(),
1368 is_git,
1369 registered: entry.is_some(),
1370 opted_out,
1371 config_broken,
1372 idle,
1373 idle_days,
1374 min_size_bytes: min_size_mb.saturating_mul(BYTES_PER_MIB),
1375 depth,
1376 }
1377}
1378
1379struct ProjectReport {
1381 prunable: bool,
1383 has_bloat: bool,
1385}
1386
1387fn check_repo_projects(f: &mut Findings, path: &Path, ctx: &RepoContext) -> Vec<ProjectReport> {
1388 f.section("Projects");
1389
1390 if ctx.config_broken {
1391 f.note(
1392 "",
1393 "not scanned — the configuration above has to parse first",
1394 );
1395 return Vec::new();
1396 }
1397
1398 let projects = workspace::discover_to_depth(path, ctx.depth);
1399 if projects.is_empty() {
1400 f.note(
1401 "",
1402 &format!(
1403 "no recognised package-manager project within {} levels. \
1404 Raise it with `devp config set scan_depth N`.",
1405 ctx.depth
1406 ),
1407 );
1408 return Vec::new();
1409 }
1410
1411 let mut reports = Vec::new();
1412 for project in &projects {
1413 for adapter in &project.adapters {
1414 println!();
1415 println!(" {} ({})", project.relative.bold(), adapter.name());
1416
1417 let missing: Vec<&str> = adapter
1420 .lockfiles()
1421 .iter()
1422 .copied()
1423 .filter(|n| !project.path.join(n).exists())
1424 .collect();
1425 match (adapter.lockfiles().is_empty(), missing.is_empty()) {
1426 (true, _) => f.note(" Lockfile", "no single file identifies this manager"),
1427 (false, true) => f.ok(
1428 " Lockfile",
1429 &format!("{} present", adapter.lockfiles().join(", ")),
1430 ),
1431 (false, false) if missing.len() == adapter.lockfiles().len() => f.problem(
1434 " Lockfile",
1435 &format!(
1436 "{} missing — nothing can prove the directory is rebuildable, \
1437 so it will never be pruned",
1438 missing.join(" / ")
1439 ),
1440 ),
1441 (false, false) => f.ok(
1442 " Lockfile",
1443 &format!(
1444 "{} present",
1445 adapter
1446 .lockfiles()
1447 .iter()
1448 .filter(|n| !missing.contains(n))
1449 .copied()
1450 .collect::<Vec<_>>()
1451 .join(", ")
1452 ),
1453 ),
1454 }
1455
1456 let bloat = adapter.bloat_dirs(&project.path);
1457 if bloat.is_empty() {
1458 f.note(" Bloat", "nothing installed — nothing to reclaim");
1459 reports.push(ProjectReport {
1460 prunable: false,
1461 has_bloat: false,
1462 });
1463 continue;
1464 }
1465
1466 let mut prunable = false;
1467 for bd in &bloat {
1468 let label = workspace::relative_label(path, &bd.path);
1469 let size = output::format_bytes(bd.size_bytes);
1470
1471 if std::fs::symlink_metadata(&bd.path)
1472 .map(|m| m.file_type().is_symlink())
1473 .unwrap_or(false)
1474 {
1475 f.warn(
1476 " Bloat",
1477 &format!(
1478 "{label} ({size}) is a symlink — refused, because the storage \
1479 it points at is not this repository's to delete"
1480 ),
1481 );
1482 } else if bd.size_bytes < ctx.min_size_bytes {
1483 f.warn(
1484 " Bloat",
1485 &format!("{label} ({size}) is below the size floor — left alone"),
1486 );
1487 } else {
1488 f.ok(" Bloat", &format!("{label} ({size})"));
1489 prunable = true;
1490 }
1491 }
1492 reports.push(ProjectReport {
1493 prunable,
1494 has_bloat: true,
1495 });
1496 }
1497 }
1498
1499 reports
1500}
1501
1502fn repo_verdict(ctx: &RepoContext, projects: &[ProjectReport]) -> String {
1507 let clean = output::clean_path(&ctx.path);
1508 let no = |detail: String| format!("{} Would `devp run` prune this? {detail}", "✗".red());
1509
1510 if !ctx.is_git {
1511 no("No — not a Git repository. Nothing else is even checked.".to_string())
1512 } else if ctx.config_broken {
1513 no(format!(
1514 "No — `{}` does not parse, and dev-prune will not guess at a config it \
1515 cannot read.",
1516 constants::PER_REPO_CONFIG_FILE
1517 ))
1518 } else if let Some(why) = &ctx.opted_out {
1519 no(format!("No — opted out: {why}."))
1520 } else if !ctx.registered {
1521 no(format!(
1522 "Not in a full pass — it is not registered. `devp link {clean}` adds it; \
1523 `devp run {clean}` prunes it once without registering."
1524 ))
1525 } else if !ctx.idle {
1526 no(format!(
1527 "No — active within the last {} {}. `devp --ignore-idle run {clean}` overrides \
1528 exactly that check and nothing else.",
1529 ctx.idle_days,
1530 output::plural(ctx.idle_days as usize, "day", "days")
1531 ))
1532 } else if projects.is_empty() {
1533 no("No — no package-manager project was found to prune.".to_string())
1534 } else if !projects.iter().any(|p| p.has_bloat) {
1535 no("No — every project here is already clean.".to_string())
1536 } else if !projects.iter().any(|p| p.prunable) {
1537 no("No — everything found is symlinked or below the size floor. See above.".to_string())
1538 } else {
1539 format!(
1540 "{} Would `devp run` prune this? Yes — subject to each lockfile verifying. \
1541 `devp run {clean} --dry-run` lists what would go.",
1542 "✓".green()
1543 )
1544 }
1545}
1546
1547fn describe_overrides(cfg: &PerRepoConfig) -> String {
1549 let mut parts = Vec::new();
1550 if let Some(name) = &cfg.project_name {
1551 parts.push(format!("name={name}"));
1552 }
1553 if let Some(days) = cfg.override_idle_days {
1554 parts.push(format!("idle_days={days}"));
1555 }
1556 if let Some(mb) = cfg.min_size_mb {
1557 parts.push(format!("min_size_mb={mb}"));
1558 }
1559 if let Some(depth) = cfg.scan_depth {
1560 parts.push(format!("scan_depth={depth}"));
1561 }
1562 if cfg.ignore {
1563 parts.push("ignore=true".to_string());
1564 }
1565 if cfg.disable_hooks {
1566 parts.push("disable_hooks=true".to_string());
1567 }
1568 if cfg.disable_daemon {
1569 parts.push("disable_daemon=true".to_string());
1570 }
1571 if parts.is_empty() {
1572 "parses; overrides nothing".to_string()
1573 } else {
1574 format!("parses; {}", parts.join(", "))
1575 }
1576}
1577
1578fn root_cause(e: &anyhow::Error) -> String {
1583 e.chain().last().map(|c| c.to_string()).unwrap_or_default()
1584}
1585
1586fn heal_repo_configs() -> Result<usize> {
1595 let registry = Registry::load()?;
1596 let mut healed = 0usize;
1597 for repo in registry.repositories.keys() {
1598 if !repo.exists() || PerRepoConfig::load_with_diagnostics(repo).is_ok() {
1599 continue;
1600 }
1601 let file = repo.join(constants::PER_REPO_CONFIG_FILE);
1602 let mut backup_name = format!("{}.broken", constants::PER_REPO_CONFIG_FILE);
1603 let mut n = 1;
1604 while repo.join(&backup_name).exists() {
1605 n += 1;
1606 backup_name = format!("{}.broken-{n}", constants::PER_REPO_CONFIG_FILE);
1607 }
1608 let backup = repo.join(&backup_name);
1609 std::fs::rename(&file, &backup).with_context(|| {
1610 format!(
1611 "could not move the broken config aside: {}",
1612 output::clean_path(&file)
1613 )
1614 })?;
1615 PerRepoConfig::default()
1616 .save_to_repo(repo)
1617 .with_context(|| {
1618 format!(
1619 "could not write a default config in {}",
1620 output::clean_path(repo)
1621 )
1622 })?;
1623 let _ = crate::config::ensure_in_git_exclude(repo, &backup_name);
1624 output::print_info(&format!(
1625 "{}: broken config kept as `{}`, defaults written",
1626 output::clean_path(repo),
1627 backup_name
1628 ));
1629 healed += 1;
1630 }
1631 Ok(healed)
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636 use super::*;
1637 use tempfile::TempDir;
1638
1639 #[test]
1640 fn a_stale_copy_names_the_command_that_removes_it() {
1641 let line = stale_copy_line(
1644 Path::new("/usr/local/bin/dev-prune"),
1645 (1, 6, 0),
1646 Channel::Cargo,
1647 );
1648 assert!(line.contains("v1.6.0"), "{line}");
1649 assert!(line.contains("cargo uninstall dev-prune"), "{line}");
1650 }
1651
1652 #[test]
1653 fn a_copy_from_the_install_script_is_removed_by_devp_itself() {
1654 let line = stale_copy_line(
1657 Path::new("/home/a/.dev-prune/bin/dev-prune"),
1658 (1, 5, 0),
1659 Channel::Installer,
1660 );
1661 assert!(line.contains("devp uninstall"), "{line}");
1662 }
1663
1664 #[test]
1665 fn a_copy_nothing_owns_says_so_rather_than_naming_a_command() {
1666 let line = stale_copy_line(Path::new("/opt/dev-prune"), (0, 9, 1), Channel::Unknown);
1669 assert!(line.contains("delete the file yourself"), "{line}");
1670 assert!(!line.contains("uninstall dev-prune"), "{line}");
1671 }
1672
1673 #[test]
1674 fn an_integration_pointing_at_a_deleted_binary_is_a_problem_not_a_warning() {
1675 let mut f = Findings::default();
1678 report_integration_target(
1679 &mut f,
1680 "Scheduler",
1681 "installed",
1682 Some(PathBuf::from("/nonexistent/dev-prune")),
1683 "Re-register it.",
1684 );
1685 assert_eq!(f.warnings.len(), 0);
1686 assert_eq!(f.problems.len(), 1);
1687 assert!(f.problems[0].contains("no longer exists"));
1688 }
1689
1690 #[test]
1691 fn an_integration_whose_binary_is_present_passes() {
1692 let tmp = TempDir::new().unwrap();
1693 let exe = tmp.path().join("dev-prune");
1694 std::fs::write(&exe, b"binary").unwrap();
1695
1696 let mut f = Findings::default();
1697 report_integration_target(&mut f, "Scheduler", "installed", Some(exe), "Re-register.");
1698 assert!(f.problems.is_empty() && f.warnings.is_empty());
1699 }
1700
1701 #[test]
1702 fn an_unreadable_entry_is_not_reported_as_broken() {
1703 let mut f = Findings::default();
1706 report_integration_target(&mut f, "Scheduler", "installed", None, "Re-register.");
1707 assert!(f.problems.is_empty() && f.warnings.is_empty());
1708 }
1709
1710 #[test]
1711 fn overrides_are_listed_by_name() {
1712 let mut cfg = PerRepoConfig::default();
1713 assert_eq!(describe_overrides(&cfg), "parses; overrides nothing");
1714
1715 cfg.override_idle_days = Some(30);
1716 cfg.ignore = true;
1717 assert_eq!(
1718 describe_overrides(&cfg),
1719 "parses; idle_days=30, ignore=true"
1720 );
1721 }
1722
1723 #[test]
1726 fn a_zero_floor_is_reported_as_an_override() {
1727 let cfg = PerRepoConfig {
1728 min_size_mb: Some(0),
1729 ..PerRepoConfig::default()
1730 };
1731 assert_eq!(describe_overrides(&cfg), "parses; min_size_mb=0");
1732 }
1733
1734 #[test]
1735 fn a_repository_that_is_not_a_git_repo_is_the_first_thing_reported() {
1736 let dir = TempDir::new().unwrap();
1737 let ctx = RepoContext {
1738 path: dir.path().to_path_buf(),
1739 is_git: false,
1740 registered: false,
1741 opted_out: Some("ignore.devprune.json is present".to_string()),
1742 config_broken: true,
1743 idle: true,
1744 idle_days: 15,
1745 min_size_bytes: 0,
1746 depth: 6,
1747 };
1748 let line = repo_verdict(&ctx, &[]);
1751 assert!(line.contains("not a Git repository"), "{line}");
1752 }
1753
1754 #[test]
1755 fn warnings_alone_do_not_fail_the_command() {
1756 let mut f = Findings::default();
1757 f.warn("Scheduler", "not installed");
1758 assert!(verdict(&f, "fine", None).is_ok());
1759
1760 f.problem("PATH", "missing");
1761 assert!(verdict(&f, "fine", None).is_err());
1762 }
1763
1764 #[test]
1765 #[cfg(windows)]
1766 fn a_path_entry_matches_regardless_of_case_and_trailing_separator() {
1767 let dir = Path::new(r"C:\Users\Someone\AppData\Roaming\dev-prune\bin");
1770 assert!(same_dir(
1771 Path::new(r"c:\users\someone\appdata\roaming\dev-prune\bin\"),
1772 dir
1773 ));
1774 assert!(!same_dir(Path::new(r"C:\Windows"), dir));
1775 }
1776
1777 #[test]
1778 #[cfg(not(windows))]
1779 fn a_path_entry_on_unix_is_matched_exactly() {
1780 assert!(same_dir(
1781 Path::new("/usr/local/bin"),
1782 Path::new("/usr/local/bin")
1783 ));
1784 assert!(!same_dir(
1785 Path::new("/USR/local/bin"),
1786 Path::new("/usr/local/bin")
1787 ));
1788 }
1789
1790 #[test]
1791 fn a_stale_twin_is_told_apart_from_a_current_one() {
1792 let dir = TempDir::new().unwrap();
1793 let a = dir.path().join("dev-prune");
1794 let b = dir.path().join("devp");
1795 std::fs::write(&a, b"version two").unwrap();
1796 std::fs::write(&b, b"version two").unwrap();
1797 assert!(same_binary(&a, &b));
1798
1799 std::fs::write(&b, b"version one").unwrap();
1801 assert!(!same_binary(&a, &b));
1802
1803 std::fs::write(&b, b"short").unwrap();
1804 assert!(!same_binary(&a, &b));
1805 assert!(!same_binary(&a, &dir.path().join("missing")));
1806 }
1807
1808 #[test]
1809 fn the_channel_directories_are_searched_even_when_they_are_not_on_path() {
1810 let home = Path::new(if cfg!(windows) {
1813 "C:\\home\\u"
1814 } else {
1815 "/home/u"
1816 });
1817 let dirs = copy_search_dirs("", Some(home));
1818 let joined = dirs
1819 .iter()
1820 .map(|d| d.to_string_lossy().to_lowercase())
1821 .collect::<Vec<_>>()
1822 .join("|");
1823 for marker in ["cargo", "uv", "pipx"] {
1824 assert!(
1825 joined.contains(marker),
1826 "{marker} directory missing from {joined}"
1827 );
1828 }
1829 }
1830
1831 #[test]
1832 fn path_entries_are_searched_and_empty_ones_dropped() {
1833 let sep = if cfg!(windows) { ";" } else { ":" };
1834 let a = if cfg!(windows) { "C:\\a" } else { "/a" };
1835 let b = if cfg!(windows) { "C:\\b" } else { "/b" };
1836 let dirs = copy_search_dirs(&format!("{a}{sep}{sep}{b}"), None);
1837 assert_eq!(dirs, vec![PathBuf::from(a), PathBuf::from(b)]);
1838 }
1839
1840 #[test]
1841 fn the_managed_directory_is_never_reported_as_another_copy() {
1842 let tmp = tempfile::tempdir().expect("temp dir");
1845 let managed = tmp.path().join("bin");
1846 std::fs::create_dir_all(&managed).expect("create");
1847 let name = if cfg!(windows) {
1848 "dev-prune.exe"
1849 } else {
1850 "dev-prune"
1851 };
1852 std::fs::write(managed.join(name), b"binary").expect("write");
1853
1854 assert!(binaries_in(std::slice::from_ref(&managed), Some(&managed)).is_empty());
1855 assert_eq!(binaries_in(std::slice::from_ref(&managed), None).len(), 1);
1856 }
1857
1858 #[test]
1859 fn one_binary_under_both_names_is_reported_once() {
1860 let tmp = tempfile::tempdir().expect("temp dir");
1863 let dir = tmp.path().to_path_buf();
1864 let (a, b) = if cfg!(windows) {
1865 ("dev-prune.exe", "devp.exe")
1866 } else {
1867 ("dev-prune", "devp")
1868 };
1869 std::fs::write(dir.join(a), b"same bytes").expect("write");
1870 std::fs::write(dir.join(b), b"same bytes").expect("write");
1871 assert_eq!(binaries_in(std::slice::from_ref(&dir), None).len(), 1);
1872
1873 std::fs::write(dir.join(b), b"a different build").expect("write");
1875 assert_eq!(binaries_in(&[dir], None).len(), 2);
1876 }
1877}