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_copies(),
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 let channel = crate::channel::Channel::detect();
443 if matches!(
444 channel,
445 crate::channel::Channel::Npm
446 | crate::channel::Channel::Bun
447 | crate::channel::Channel::Pnpm
448 | crate::channel::Channel::Yarn
449 ) {
450 f.ok(
451 twin_stem,
452 &format!("provided by {} as a command of its own", channel.label()),
453 );
454 } else {
455 f.warn(
456 twin_stem,
457 &format!("not installed next to {running} — run `{running} setup`"),
458 );
459 f.fixable(Repair::Twin);
461 }
462 } else if same_binary(&exe, &twin) {
463 f.ok(twin_stem, &output::clean_path(&twin));
464 } else {
465 f.warn(
468 twin_stem,
469 &format!(
470 "{} is not the same binary as {} — one of the pair is stale and \
471 silently runs a different version. `dev-prune setup` refreshes `devp` \
472 from the canonical `dev-prune`.",
473 output::clean_path(&twin),
474 output::clean_path(&exe)
475 ),
476 );
477 if running == "dev-prune" {
481 f.fixable(Repair::Twin);
482 }
483 }
484
485 let sep = if cfg!(windows) { ';' } else { ':' };
486 let on_path = std::env::var("PATH")
487 .unwrap_or_default()
488 .split(sep)
489 .any(|p| !p.is_empty() && same_dir(Path::new(p), dir));
490 if on_path {
491 f.ok("PATH", &output::clean_path(dir));
492 } else {
493 f.warn(
497 "PATH",
498 &format!(
499 "{} is not on PATH — `devp` will not resolve in a new shell. \
500 `dev-prune setup` adds it.",
501 output::clean_path(dir)
502 ),
503 );
504 }
505}
506
507fn check_install_channel(f: &mut Findings) {
515 let channel = Channel::detect();
516 let detail = match (channel.upgrade_command(), channel.uninstall_command()) {
517 (Some(upgrade), Some(uninstall)) => {
518 format!(
519 "{} — upgrade `{upgrade}`, remove `{uninstall}`",
520 channel.label()
521 )
522 }
523 (Some(upgrade), None) => format!("{} — upgrade `{upgrade}`", channel.label()),
526 _ => format!(
527 "{} — `devp update --install` still upgrades it in place",
528 channel.label()
529 ),
530 };
531 f.ok("Install channel", &detail);
532
533 if channel == Channel::Installer
536 && let Some(receipt) = crate::receipt::load()
537 {
538 f.ok("Install receipt", &crate::receipt::summary(&receipt));
539 }
540}
541
542fn check_other_copies(f: &mut Findings) {
557 let mine = std::env::current_exe().ok();
558 let managed_dir = setup::managed_exe_path()
559 .ok()
560 .and_then(|p| p.parent().map(Path::to_path_buf));
561
562 let search = copy_search_dirs(
563 &std::env::var("PATH").unwrap_or_default(),
564 dirs::home_dir().as_deref(),
565 );
566 let copies = binaries_in(&search, managed_dir.as_deref());
567
568 let stale: Vec<String> = copies
573 .iter()
574 .filter(|path| mine.as_deref() != Some(path.as_path()))
575 .filter_map(|path| {
576 let version = setup::binary_version(path)?;
581 let ours = setup::parse_version(constants::VERSION)?;
582 (version != ours).then(|| {
583 let channel = Channel::detect_at(path, managed_dir.as_deref());
584 stale_copy_line(path, version, channel)
585 })
586 })
587 .collect();
588
589 if stale.is_empty() {
590 f.ok("Other copies", "none on PATH running a different version");
591 return;
592 }
593 f.warn(
594 "Other copies",
595 &format!(
596 "{} — whichever comes first on PATH is the one `devp` runs, and \
597 `devp update --install` only replaces the managed copy.",
598 stale.join("; ")
599 ),
600 );
601}
602
603fn stale_copy_line(path: &Path, version: (u64, u64, u64), channel: Channel) -> String {
611 let (major, minor, patch) = version;
612 let remedy = match channel.uninstall_command() {
613 Some(cmd) => format!("from {}, remove with `{cmd}`", channel.label()),
614 None if channel == Channel::Installer => {
618 "left by the install script, remove with `devp uninstall`".to_string()
619 }
620 None => "no package manager owns it; delete the file yourself".to_string(),
621 };
622 format!(
623 "{} (v{major}.{minor}.{patch}, {remedy})",
624 output::clean_path(path)
625 )
626}
627
628fn copy_search_dirs(path_var: &str, home: Option<&Path>) -> Vec<PathBuf> {
642 let sep = if cfg!(windows) { ';' } else { ':' };
643 let mut dirs: Vec<PathBuf> = path_var
644 .split(sep)
645 .filter(|p| !p.is_empty())
646 .map(PathBuf::from)
647 .collect();
648
649 dirs.extend(crate::channel::install_dirs(home));
650 dirs
651}
652
653fn binaries_in(dirs: &[PathBuf], skip_dir: Option<&Path>) -> Vec<PathBuf> {
661 let names: [&str; 2] = if cfg!(windows) {
662 ["dev-prune.exe", "devp.exe"]
663 } else {
664 ["dev-prune", "devp"]
665 };
666 let mut found: Vec<PathBuf> = Vec::new();
667 for dir in dirs {
668 if skip_dir.is_some_and(|skip| same_dir(dir, skip)) {
669 continue;
670 }
671 for name in names {
672 let candidate = dir.join(name);
673 if candidate.is_file()
678 && !found.iter().any(|seen| {
679 seen == &candidate
680 || (seen.parent() == candidate.parent() && same_binary(seen, &candidate))
681 })
682 {
683 found.push(candidate);
684 }
685 }
686 }
687 found
688}
689
690fn same_dir(entry: &Path, dir: &Path) -> bool {
697 if entry == dir {
698 return true;
699 }
700 cfg!(windows) && {
701 let norm = |p: &Path| {
702 p.to_string_lossy()
703 .trim_end_matches(['\\', '/'])
704 .to_lowercase()
705 };
706 norm(entry) == norm(dir)
707 }
708}
709
710fn same_binary(a: &Path, b: &Path) -> bool {
716 let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
717 return false;
718 };
719 if ma.len() != mb.len() {
720 return false;
721 }
722 matches!((std::fs::read(a), std::fs::read(b)), (Ok(ba), Ok(bb)) if ba == bb)
723}
724
725fn check_configuration(f: &mut Findings) -> Option<Registry> {
730 f.section("Configuration");
731
732 let dir = match Registry::config_dir() {
733 Ok(d) => d,
734 Err(e) => {
735 f.problem("Config directory", &format!("cannot be resolved: {e}"));
736 return None;
737 }
738 };
739 f.note("Config directory", &output::clean_path(&dir));
740 if std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE).is_ok() {
741 f.note(
742 "",
743 &format!("(set by {})", constants::ENV_CONFIG_DIR_OVERRIDE),
744 );
745 }
746
747 let path = dir.join(constants::REGISTRY_FILENAME);
748 if !path.exists() {
749 f.ok("registry.json", "not created yet — defaults apply");
752 return Some(Registry::default());
753 }
754
755 let registry = match Registry::load_from(&path) {
756 Ok(r) => r,
757 Err(e) => {
758 f.problem(
759 "registry.json",
760 &format!(
761 "{} — dev-prune refuses to guess at a config it cannot read. \
762 Fix the syntax, or delete the file to start from defaults.",
763 root_cause(&e)
764 ),
765 );
766 return None;
767 }
768 };
769
770 f.ok(
771 "registry.json",
772 &format!(
773 "readable — {} {} registered",
774 registry.repo_count(),
775 output::plural(registry.repo_count(), "repository", "repositories")
776 ),
777 );
778
779 let invalid = crate::commands::config::invalid_settings(®istry.settings);
780 if invalid.is_empty() {
781 f.ok(
782 "Settings",
783 &format!(
784 "all {} within range",
785 crate::commands::config::setting_count()
786 ),
787 );
788 } else {
789 for (key, why) in &invalid {
790 f.problem("Settings", &format!("{key}: {why}"));
791 }
792 }
793
794 Some(registry)
795}
796
797fn check_integrations(f: &mut Findings, registry: Option<&Registry>) {
798 f.section("Integrations");
799
800 match setup::skill_path() {
801 Ok(p) if p.exists() => {
802 let stale = setup::stale_skill_copies();
806 if stale.is_empty() {
807 f.ok("SKILL.md", &output::clean_path(&p));
808 } else {
809 let paths: Vec<String> = stale.iter().map(output::clean_path).collect();
810 f.warn(
811 "SKILL.md",
812 &format!(
813 "not from v{} — run `devp skill`: {}",
814 constants::VERSION,
815 paths.join(", ")
816 ),
817 );
818 f.fixable(Repair::SkillFile);
819 }
820 }
821 _ => {
822 f.warn("SKILL.md", "not exported — run `devp skill`");
823 f.fixable(Repair::SkillFile);
824 }
825 }
826
827 if crate::commands::icon::is_registered() {
828 f.ok("File icons", "registered with the file manager");
829 } else {
830 f.warn("File icons", "not registered — run `devp icon`");
831 }
832
833 if !hook::git_available() {
834 f.warn(
835 "Git hooks",
836 "git is not on PATH, so repositories cannot auto-register",
837 );
838 } else {
839 match hook::state() {
840 Ok(HookState::Active) if hook::shims_incomplete() => {
846 f.warn(
847 "Git hooks",
848 concat!(
849 "active, but installed without passthrough shims — ",
850 "every repository's own `.git/hooks` is being ignored ",
851 "machine-wide. `devp hook install` rewrites them to forward."
852 ),
853 );
854 f.fixable(Repair::Hooks);
855 }
856 Ok(HookState::Active) => check_hook_target(f, "active"),
857 Ok(HookState::Absent) => f.warn("Git hooks", "not installed — run `devp hook install`"),
858 Ok(HookState::Chained { previous, drifted }) if drifted.is_empty() => {
859 check_hook_target(f, &format!("active, chained to `{previous}`"))
860 }
861 Ok(HookState::Chained { previous, drifted }) => {
862 f.warn(
863 "Git hooks",
864 &format!(
865 "chained to `{previous}`, but {} not forwarded ({}) — \
866 re-run `devp hook install --chain`",
867 drifted.len(),
868 drifted.join(", ")
869 ),
870 );
871 f.fixable(Repair::Hooks);
874 }
875 Ok(HookState::Foreign(p)) => f.warn(
876 "Git hooks",
877 &format!(
878 "core.hooksPath belongs to `{p}` — install in front of it with \
879 `devp hook install --chain`"
880 ),
881 ),
882 Err(e) => f.warn("Git hooks", &format!("state unknown ({e})")),
883 }
884 }
885
886 match daemon::daemon_status() {
887 Ok(daemon::DaemonStatus::Installed) => check_scheduler_target(f),
888 Ok(daemon::DaemonStatus::NotInstalled) => f.warn(
889 "Scheduler",
890 "not installed — nothing prunes on its own. `devp daemon install` adds it.",
891 ),
892 Ok(daemon::DaemonStatus::Unknown(why)) => f.warn("Scheduler", &why),
893 Err(e) => f.warn("Scheduler", &format!("state unknown ({e})")),
894 }
895
896 if let Some(r) = registry {
897 f.note(
898 "Automatic setup",
899 &format!(
900 "auto_setup={} auto_hooks={} auto_daemon={}",
901 r.settings.auto_setup, r.settings.auto_hooks, r.settings.auto_daemon
902 ),
903 );
904 }
905
906 if let Some(why) = setup::unattended_environment() {
909 f.note("", &format!("unattended installation is off because {why}"));
910 }
911 if setup::no_auto_setup_requested() {
914 f.note(
915 "",
916 &format!(
917 "{} is set — nothing installs by itself. `devp setup` still works.",
918 setup::ENV_NO_AUTO_SETUP
919 ),
920 );
921 }
922}
923
924fn report_integration_target(
938 f: &mut Findings,
939 label: &str,
940 installed: &str,
941 recorded: Option<std::path::PathBuf>,
942 repair: &str,
943) -> bool {
944 match recorded {
945 None => f.ok(label, installed),
948 Some(path) if path.is_file() => f.ok(
949 label,
950 &format!("{installed} — {}", output::clean_path(&path)),
951 ),
952 Some(path) => {
953 f.problem(
954 label,
955 &format!(
956 "registered, but `{}` no longer exists — it never runs. {repair}",
957 output::clean_path(&path)
958 ),
959 );
960 return true;
961 }
962 }
963 false
964}
965
966fn check_scheduler_target(f: &mut Findings) {
967 if report_integration_target(
968 f,
969 "Scheduler",
970 "installed",
971 daemon::registered_exe_path(),
972 "Re-register it with `devp daemon install`.",
973 ) {
974 f.fixable_problem(Repair::Scheduler);
975 }
976}
977
978fn check_hook_target(f: &mut Findings, installed: &str) {
979 if report_integration_target(
980 f,
981 "Git hooks",
982 installed,
983 hook::registered_exe_path(),
984 "Rewrite them with `devp hook install`.",
985 ) {
986 f.fixable_problem(Repair::Hooks);
987 }
988}
989
990fn check_package_managers(f: &mut Findings, registry: Option<&Registry>) {
997 f.section("Package managers");
998
999 let (needed, required): (Vec<String>, bool) = match registry {
1003 Some(r) if r.repo_count() > 0 => {
1004 let mut names: Vec<String> = engine::get_full_status(r)
1005 .into_iter()
1006 .flat_map(|e| e.adapters)
1007 .collect();
1008 names.sort();
1009 names.dedup();
1010 (names, true)
1011 }
1012 _ => (
1013 adapters::get_all_adapters()
1014 .iter()
1015 .map(|a| a.name().to_string())
1016 .collect(),
1017 false,
1018 ),
1019 };
1020
1021 if needed.is_empty() {
1022 f.note(
1023 "",
1024 "no package managers are needed by the registered repositories",
1025 );
1026 return;
1027 }
1028 if !required {
1029 f.note("", "nothing is registered yet, so this is the full list");
1030 }
1031
1032 for status in adapters::scan_required_binaries(&needed) {
1033 match (status.available, status.version) {
1034 (true, Some(v)) => f.ok(&status.name, &v),
1035 (true, None) => f.ok(&status.name, "available"),
1036 (false, _) if required => {
1037 let detail =
1038 "not on PATH — projects using it cannot be verified, pruned or restored";
1039 match adapters::install_hint(&status.name) {
1040 Some(hint) => f.warn(&status.name, &format!("{detail}. Install it: {hint}")),
1041 None => f.warn(&status.name, detail),
1042 }
1043 }
1044 (false, _) => f.note(&status.name, "not installed"),
1045 }
1046 }
1047
1048 if required && needed.iter().any(|n| n == "venv") && !adapters::binary_available("python") {
1051 f.warn(
1052 "python",
1053 "not on PATH — `devp restore` cannot rebuild a plain virtual environment. \
1054 Install it: https://www.python.org/downloads/",
1055 );
1056 }
1057}
1058
1059fn check_registry_health(f: &mut Findings, registry: Option<&Registry>) {
1060 f.section("Registered repositories");
1061
1062 let Some(registry) = registry else { return };
1063 if registry.repo_count() == 0 {
1064 f.note("", "none yet — `devp init ~/Code` or `devp link .`");
1065 return;
1066 }
1067
1068 let entries = engine::get_full_status(registry);
1069 let count = |want: &SkipReason| {
1070 entries
1071 .iter()
1072 .filter(|e| std::mem::discriminant(&e.reason) == std::mem::discriminant(want))
1073 .count()
1074 };
1075 let reclaimable: u64 = entries.iter().map(|e| e.reclaimable_bytes).sum();
1076
1077 f.note(
1078 "Total",
1079 &format!(
1080 "{} registered, {} reclaimable",
1081 entries.len(),
1082 output::format_bytes(reclaimable)
1083 ),
1084 );
1085 f.note(
1086 "Breakdown",
1087 &format!(
1088 "{} candidates, {} active, {} ignored, {} with no bloat",
1089 count(&SkipReason::Candidate),
1090 count(&SkipReason::Active),
1091 count(&SkipReason::Ignored),
1092 count(&SkipReason::NoBloat),
1093 ),
1094 );
1095
1096 let missing: Vec<&Path> = entries
1101 .iter()
1102 .filter(|e| matches!(e.reason, SkipReason::PathMissing))
1103 .map(|e| e.path.as_path())
1104 .collect();
1105
1106 match missing.len() {
1107 0 => {}
1108 1 => {
1109 f.warn(
1110 "Missing",
1111 &format!(
1112 "{} no longer exists — `devp unlink {}`",
1113 output::clean_path(missing[0]),
1114 output::clean_path(missing[0])
1115 ),
1116 );
1117 f.fixable(Repair::UnlinkMissing);
1118 }
1119 n => {
1120 f.warn(
1121 "Missing",
1122 &format!(
1123 "{n} registered paths no longer exist, starting with {} \
1124 — `devp unlink --missing` clears all of them",
1125 output::clean_path(missing[0])
1126 ),
1127 );
1128 f.fixable(Repair::UnlinkMissing);
1129 }
1130 }
1131
1132 for entry in &entries {
1135 if let SkipReason::ConfigError(e) = &entry.reason {
1136 f.problem(
1137 "Unreadable config",
1138 &format!("{}: {e}", output::clean_path(&entry.path)),
1139 );
1140 f.fixable_problem(Repair::RepoConfigs);
1141 }
1142 }
1143}
1144
1145fn check_release_state(f: &mut Findings, registry: Option<&Registry>) {
1146 f.section("Release check");
1147
1148 let Some(registry) = registry else { return };
1149
1150 if registry.settings.version_lock {
1153 f.note(
1154 "version_lock",
1155 &format!(
1156 "on — this copy stays at v{}. auto_update, `devp update --install`, \
1157 `devp install --channel` and the install scripts all stand down until \
1158 `devp config set version_lock false`",
1159 constants::VERSION
1160 ),
1161 );
1162 }
1163
1164 if !registry.settings.update_check {
1165 f.note(
1166 "update_check",
1167 "off — dev-prune opens no network connection",
1168 );
1169 return;
1170 }
1171
1172 f.note(
1173 "update_check",
1174 &format!(
1175 "on, every {} {}",
1176 registry.settings.update_check_interval_days,
1177 output::plural(
1178 registry.settings.update_check_interval_days as usize,
1179 "day",
1180 "days"
1181 )
1182 ),
1183 );
1184
1185 match registry.last_update_check {
1186 Some(at) => f.note(
1187 "Last checked",
1188 &format!(
1189 "{} ({} days ago)",
1190 at.format("%Y-%m-%d %H:%M UTC"),
1191 (Utc::now() - at).num_days()
1192 ),
1193 ),
1194 None => f.note("Last checked", "never"),
1195 }
1196
1197 match registry.latest_known_version.as_deref() {
1202 Some(latest) => {
1203 let latest_core = latest.trim_start_matches('v');
1204 match super::update::compare_versions(constants::VERSION, latest_core) {
1205 Some(Ordering::Less) if registry.settings.version_lock => f.note(
1209 "Latest release",
1210 &format!(
1211 "{latest} is available, and version_lock is holding this copy at v{}",
1212 constants::VERSION
1213 ),
1214 ),
1215 Some(Ordering::Less) => f.warn(
1216 "Latest release",
1217 &format!("{latest} is available — `devp update` shows how to upgrade"),
1218 ),
1219 Some(Ordering::Greater) => f.ok(
1220 "Latest release",
1221 &format!("{latest} — this build is newer than the last published one"),
1222 ),
1223 Some(Ordering::Equal) => f.ok("Latest release", &format!("{latest} — up to date")),
1224 None => f.note(
1225 "Latest release",
1226 &format!("{latest} — could not be compared to {}", constants::VERSION),
1227 ),
1228 }
1229 }
1230 None => f.note("Latest release", "not known yet"),
1231 }
1232}
1233
1234fn check_repository(path_str: &str) -> Result<()> {
1239 let path = Path::new(path_str)
1240 .canonicalize()
1241 .with_context(|| format!("Path not found: {path_str}"))?;
1242
1243 output::print_header(&format!("dev-prune doctor ({})", output::clean_path(&path)));
1244 let mut f = Findings::default();
1245
1246 let registry = Registry::load().unwrap_or_default();
1249
1250 let ctx = check_repo_basics(&mut f, &path, ®istry);
1251 let projects = check_repo_projects(&mut f, &path, &ctx);
1252 let headline = repo_verdict(&ctx, &projects);
1253
1254 verdict(
1255 &f,
1256 &format!("{} is in good shape.", output::clean_path(&ctx.path)),
1257 Some(&headline),
1258 )
1259}
1260
1261struct RepoContext {
1263 path: PathBuf,
1264 is_git: bool,
1265 registered: bool,
1266 opted_out: Option<String>,
1267 config_broken: bool,
1268 idle: bool,
1269 idle_days: u64,
1270 min_size_bytes: u64,
1271 depth: usize,
1272}
1273
1274fn check_repo_basics(f: &mut Findings, path: &Path, registry: &Registry) -> RepoContext {
1275 f.section("Repository");
1276
1277 let is_git = scanner::is_git_repo(path);
1278 if is_git {
1279 f.ok("Git repository", "yes");
1280 } else {
1281 f.problem(
1282 "Git repository",
1283 "no — dev-prune only ever touches Git repositories",
1284 );
1285 }
1286
1287 let key = crate::config::canonical_key(path);
1288 let entry = registry.repositories.get(&key);
1289 match entry {
1290 Some(e) if e.enabled => f.ok(
1291 "Registered",
1292 &format!("yes, since {}", e.added_at.format("%Y-%m-%d")),
1293 ),
1294 Some(e) => f.warn(
1295 "Registered",
1296 &format!(
1297 "yes since {}, but disabled — `devp config {} --update`",
1298 e.added_at.format("%Y-%m-%d"),
1299 output::clean_path(path)
1300 ),
1301 ),
1302 None => f.warn(
1303 "Registered",
1304 "no — a prune pass will not visit it. `devp link .` registers it.",
1305 ),
1306 }
1307 if let Some(at) = entry.and_then(|e| e.last_pruned_at) {
1308 f.note("Last pruned", &at.format("%Y-%m-%d %H:%M UTC").to_string());
1309 }
1310
1311 let layers = crate::config::RepoConfigLayers::load(path).ok();
1313 let (per_repo, config_broken) = match &layers {
1314 Some(layers) => {
1315 if let Some(shared) = layers.project_config() {
1319 f.ok(
1320 constants::PROJECT_REPO_CONFIG_FILE,
1321 &describe_overrides(shared),
1322 );
1323 }
1324 match layers.personal_config() {
1325 Some(personal) => f.ok(
1326 constants::PER_REPO_CONFIG_FILE,
1327 &describe_overrides(personal),
1328 ),
1329 None => f.note(
1330 constants::PER_REPO_CONFIG_FILE,
1331 "absent — global settings apply",
1332 ),
1333 }
1334 (layers.effective(), false)
1335 }
1336 None => {
1337 for (name, e) in PerRepoConfig::broken_files(path) {
1338 f.problem(
1339 name,
1340 &format!("{e} — the repository is skipped entirely until this parses"),
1341 );
1342 }
1343 (None, true)
1344 }
1345 };
1346
1347 let mut opted_out = None;
1348 if path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
1349 opted_out = Some(format!("{} is present", constants::DEVPRUNE_IGNORE_FILE));
1350 } else if per_repo.as_ref().is_some_and(|c| c.ignore) {
1351 let source = layers
1355 .as_ref()
1356 .map_or(constants::PER_REPO_CONFIG_FILE, |l| {
1357 l.source_of("ignore").label()
1358 });
1359 opted_out = Some(format!("\"ignore\": true in {source}"));
1360 } else if entry.is_some_and(|e| !e.enabled) {
1361 opted_out = Some("disabled in the registry".to_string());
1362 }
1363 match &opted_out {
1364 Some(why) => f.note("Opt-out", why),
1365 None => f.note("Opt-out", "none"),
1366 }
1367
1368 let idle_days = per_repo
1371 .as_ref()
1372 .and_then(|c| c.override_idle_days)
1373 .or_else(|| entry.and_then(|e| e.override_idle_days))
1374 .unwrap_or(registry.settings.idle_days);
1375
1376 let activity = git::get_last_activity(path).ok().flatten();
1377 let idle = git::is_idle_at(activity, idle_days);
1378 match activity {
1379 Some(t) => {
1380 let days = chrono::DateTime::<Utc>::from(t);
1381 let ago = (Utc::now() - days).num_days();
1382 let detail = format!(
1383 "{} ({ago} {} ago), threshold {idle_days}",
1384 days.format("%Y-%m-%d"),
1385 output::plural(ago.unsigned_abs() as usize, "day", "days")
1386 );
1387 if idle {
1388 f.ok("Activity", &format!("{detail} — idle"));
1389 } else {
1390 f.note("Activity", &format!("{detail} — active"));
1391 }
1392 }
1393 None => f.note(
1394 "Activity",
1395 &format!("no commits or source edits found, threshold {idle_days}"),
1396 ),
1397 }
1398
1399 let min_size_mb = per_repo
1400 .as_ref()
1401 .and_then(|c| c.min_size_mb)
1402 .unwrap_or(registry.settings.min_size_mb);
1403 f.note(
1404 "Size floor",
1405 &if min_size_mb == 0 {
1406 "none — every recognised directory counts".to_string()
1407 } else {
1408 format!("{min_size_mb} MiB")
1409 },
1410 );
1411
1412 let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
1413 f.note("Scan depth", &format!("{depth} levels below the root"));
1414
1415 RepoContext {
1416 path: path.to_path_buf(),
1417 is_git,
1418 registered: entry.is_some(),
1419 opted_out,
1420 config_broken,
1421 idle,
1422 idle_days,
1423 min_size_bytes: min_size_mb.saturating_mul(BYTES_PER_MIB),
1424 depth,
1425 }
1426}
1427
1428struct ProjectReport {
1430 prunable: bool,
1432 has_bloat: bool,
1434}
1435
1436fn check_repo_projects(f: &mut Findings, path: &Path, ctx: &RepoContext) -> Vec<ProjectReport> {
1437 f.section("Projects");
1438
1439 if ctx.config_broken {
1440 f.note(
1441 "",
1442 "not scanned — the configuration above has to parse first",
1443 );
1444 return Vec::new();
1445 }
1446
1447 let projects = workspace::discover_to_depth(path, ctx.depth);
1448 if projects.is_empty() {
1449 f.note(
1450 "",
1451 &format!(
1452 "no recognised package-manager project within {} levels. \
1453 Raise it with `devp config set scan_depth N`.",
1454 ctx.depth
1455 ),
1456 );
1457 return Vec::new();
1458 }
1459
1460 let mut reports = Vec::new();
1461 for project in &projects {
1462 for adapter in &project.adapters {
1463 println!();
1464 println!(" {} ({})", project.relative.bold(), adapter.name());
1465
1466 let missing: Vec<&str> = adapter
1469 .lockfiles()
1470 .iter()
1471 .copied()
1472 .filter(|n| !project.path.join(n).exists())
1473 .collect();
1474 match (adapter.lockfiles().is_empty(), missing.is_empty()) {
1475 (true, _) => f.note(" Lockfile", "no single file identifies this manager"),
1476 (false, true) => f.ok(
1477 " Lockfile",
1478 &format!("{} present", adapter.lockfiles().join(", ")),
1479 ),
1480 (false, false) if missing.len() == adapter.lockfiles().len() => f.problem(
1483 " Lockfile",
1484 &format!(
1485 "{} missing — nothing can prove the directory is rebuildable, \
1486 so it will never be pruned",
1487 missing.join(" / ")
1488 ),
1489 ),
1490 (false, false) => f.ok(
1491 " Lockfile",
1492 &format!(
1493 "{} present",
1494 adapter
1495 .lockfiles()
1496 .iter()
1497 .filter(|n| !missing.contains(n))
1498 .copied()
1499 .collect::<Vec<_>>()
1500 .join(", ")
1501 ),
1502 ),
1503 }
1504
1505 let bloat = adapter.bloat_dirs(&project.path);
1506 if bloat.is_empty() {
1507 f.note(" Bloat", "nothing installed — nothing to reclaim");
1508 reports.push(ProjectReport {
1509 prunable: false,
1510 has_bloat: false,
1511 });
1512 continue;
1513 }
1514
1515 let mut prunable = false;
1516 for bd in &bloat {
1517 let label = workspace::relative_label(path, &bd.path);
1518 let size = output::format_bytes(bd.size_bytes);
1519
1520 if std::fs::symlink_metadata(&bd.path)
1521 .map(|m| m.file_type().is_symlink())
1522 .unwrap_or(false)
1523 {
1524 f.warn(
1525 " Bloat",
1526 &format!(
1527 "{label} ({size}) is a symlink — refused, because the storage \
1528 it points at is not this repository's to delete"
1529 ),
1530 );
1531 } else if bd.size_bytes < ctx.min_size_bytes {
1532 f.warn(
1533 " Bloat",
1534 &format!("{label} ({size}) is below the size floor — left alone"),
1535 );
1536 } else {
1537 f.ok(" Bloat", &format!("{label} ({size})"));
1538 prunable = true;
1539 }
1540 }
1541 reports.push(ProjectReport {
1542 prunable,
1543 has_bloat: true,
1544 });
1545 }
1546 }
1547
1548 reports
1549}
1550
1551fn repo_verdict(ctx: &RepoContext, projects: &[ProjectReport]) -> String {
1556 let clean = output::clean_path(&ctx.path);
1557 let no = |detail: String| format!("{} Would `devp run` prune this? {detail}", "✗".red());
1558
1559 if !ctx.is_git {
1560 no("No — not a Git repository. Nothing else is even checked.".to_string())
1561 } else if ctx.config_broken {
1562 no(format!(
1563 "No — `{}` does not parse, and dev-prune will not guess at a config it \
1564 cannot read.",
1565 constants::PER_REPO_CONFIG_FILE
1566 ))
1567 } else if let Some(why) = &ctx.opted_out {
1568 no(format!("No — opted out: {why}."))
1569 } else if !ctx.registered {
1570 no(format!(
1571 "Not in a full pass — it is not registered. `devp link {clean}` adds it; \
1572 `devp run {clean}` prunes it once without registering."
1573 ))
1574 } else if !ctx.idle {
1575 no(format!(
1576 "No — active within the last {} {}. `devp --ignore-idle run {clean}` overrides \
1577 exactly that check and nothing else.",
1578 ctx.idle_days,
1579 output::plural(ctx.idle_days as usize, "day", "days")
1580 ))
1581 } else if projects.is_empty() {
1582 no("No — no package-manager project was found to prune.".to_string())
1583 } else if !projects.iter().any(|p| p.has_bloat) {
1584 no("No — every project here is already clean.".to_string())
1585 } else if !projects.iter().any(|p| p.prunable) {
1586 no("No — everything found is symlinked or below the size floor. See above.".to_string())
1587 } else {
1588 format!(
1589 "{} Would `devp run` prune this? Yes — subject to each lockfile verifying. \
1590 `devp run {clean} --dry-run` lists what would go.",
1591 "✓".green()
1592 )
1593 }
1594}
1595
1596fn describe_overrides(cfg: &PerRepoConfig) -> String {
1598 let mut parts = Vec::new();
1599 if let Some(name) = &cfg.project_name {
1600 parts.push(format!("name={name}"));
1601 }
1602 if let Some(days) = cfg.override_idle_days {
1603 parts.push(format!("idle_days={days}"));
1604 }
1605 if let Some(mb) = cfg.min_size_mb {
1606 parts.push(format!("min_size_mb={mb}"));
1607 }
1608 if let Some(depth) = cfg.scan_depth {
1609 parts.push(format!("scan_depth={depth}"));
1610 }
1611 if cfg.ignore {
1612 parts.push("ignore=true".to_string());
1613 }
1614 if cfg.disable_hooks {
1615 parts.push("disable_hooks=true".to_string());
1616 }
1617 if cfg.disable_daemon {
1618 parts.push("disable_daemon=true".to_string());
1619 }
1620 if parts.is_empty() {
1621 "parses; overrides nothing".to_string()
1622 } else {
1623 format!("parses; {}", parts.join(", "))
1624 }
1625}
1626
1627fn root_cause(e: &anyhow::Error) -> String {
1632 e.chain().last().map(|c| c.to_string()).unwrap_or_default()
1633}
1634
1635fn heal_repo_configs() -> Result<usize> {
1644 let registry = Registry::load()?;
1645 let mut healed = 0usize;
1646 for repo in registry.repositories.keys() {
1647 if !repo.exists()
1652 || !PerRepoConfig::broken_files(repo)
1653 .iter()
1654 .any(|(name, _)| *name == constants::PER_REPO_CONFIG_FILE)
1655 {
1656 continue;
1657 }
1658 let file = repo.join(constants::PER_REPO_CONFIG_FILE);
1659 let mut backup_name = format!("{}.broken", constants::PER_REPO_CONFIG_FILE);
1660 let mut n = 1;
1661 while repo.join(&backup_name).exists() {
1662 n += 1;
1663 backup_name = format!("{}.broken-{n}", constants::PER_REPO_CONFIG_FILE);
1664 }
1665 let backup = repo.join(&backup_name);
1666 std::fs::rename(&file, &backup).with_context(|| {
1667 format!(
1668 "could not move the broken config aside: {}",
1669 output::clean_path(&file)
1670 )
1671 })?;
1672 PerRepoConfig::default()
1673 .save_to_repo(repo)
1674 .with_context(|| {
1675 format!(
1676 "could not write a default config in {}",
1677 output::clean_path(repo)
1678 )
1679 })?;
1680 let _ = crate::config::ensure_in_git_exclude(repo, &backup_name);
1681 output::print_info(&format!(
1682 "{}: broken config kept as `{}`, defaults written",
1683 output::clean_path(repo),
1684 backup_name
1685 ));
1686 healed += 1;
1687 }
1688 Ok(healed)
1689}
1690
1691#[cfg(test)]
1692mod tests {
1693 use super::*;
1694 use tempfile::TempDir;
1695
1696 #[test]
1697 fn a_stale_copy_names_the_command_that_removes_it() {
1698 let line = stale_copy_line(
1701 Path::new("/usr/local/bin/dev-prune"),
1702 (1, 6, 0),
1703 Channel::Cargo,
1704 );
1705 assert!(line.contains("v1.6.0"), "{line}");
1706 assert!(line.contains("cargo uninstall dev-prune"), "{line}");
1707 }
1708
1709 #[test]
1710 fn a_copy_from_the_install_script_is_removed_by_devp_itself() {
1711 let line = stale_copy_line(
1714 Path::new("/home/a/.dev-prune/bin/dev-prune"),
1715 (1, 5, 0),
1716 Channel::Installer,
1717 );
1718 assert!(line.contains("devp uninstall"), "{line}");
1719 }
1720
1721 #[test]
1722 fn a_copy_nothing_owns_says_so_rather_than_naming_a_command() {
1723 let line = stale_copy_line(Path::new("/opt/dev-prune"), (0, 9, 1), Channel::Unknown);
1726 assert!(line.contains("delete the file yourself"), "{line}");
1727 assert!(!line.contains("uninstall dev-prune"), "{line}");
1728 }
1729
1730 #[test]
1731 fn an_integration_pointing_at_a_deleted_binary_is_a_problem_not_a_warning() {
1732 let mut f = Findings::default();
1735 report_integration_target(
1736 &mut f,
1737 "Scheduler",
1738 "installed",
1739 Some(PathBuf::from("/nonexistent/dev-prune")),
1740 "Re-register it.",
1741 );
1742 assert_eq!(f.warnings.len(), 0);
1743 assert_eq!(f.problems.len(), 1);
1744 assert!(f.problems[0].contains("no longer exists"));
1745 }
1746
1747 #[test]
1748 fn an_integration_whose_binary_is_present_passes() {
1749 let tmp = TempDir::new().unwrap();
1750 let exe = tmp.path().join("dev-prune");
1751 std::fs::write(&exe, b"binary").unwrap();
1752
1753 let mut f = Findings::default();
1754 report_integration_target(&mut f, "Scheduler", "installed", Some(exe), "Re-register.");
1755 assert!(f.problems.is_empty() && f.warnings.is_empty());
1756 }
1757
1758 #[test]
1759 fn an_unreadable_entry_is_not_reported_as_broken() {
1760 let mut f = Findings::default();
1763 report_integration_target(&mut f, "Scheduler", "installed", None, "Re-register.");
1764 assert!(f.problems.is_empty() && f.warnings.is_empty());
1765 }
1766
1767 #[test]
1768 fn overrides_are_listed_by_name() {
1769 let mut cfg = PerRepoConfig::default();
1770 assert_eq!(describe_overrides(&cfg), "parses; overrides nothing");
1771
1772 cfg.override_idle_days = Some(30);
1773 cfg.ignore = true;
1774 assert_eq!(
1775 describe_overrides(&cfg),
1776 "parses; idle_days=30, ignore=true"
1777 );
1778 }
1779
1780 #[test]
1783 fn a_zero_floor_is_reported_as_an_override() {
1784 let cfg = PerRepoConfig {
1785 min_size_mb: Some(0),
1786 ..PerRepoConfig::default()
1787 };
1788 assert_eq!(describe_overrides(&cfg), "parses; min_size_mb=0");
1789 }
1790
1791 #[test]
1792 fn a_repository_that_is_not_a_git_repo_is_the_first_thing_reported() {
1793 let dir = TempDir::new().unwrap();
1794 let ctx = RepoContext {
1795 path: dir.path().to_path_buf(),
1796 is_git: false,
1797 registered: false,
1798 opted_out: Some("ignore.devprune.json is present".to_string()),
1799 config_broken: true,
1800 idle: true,
1801 idle_days: 15,
1802 min_size_bytes: 0,
1803 depth: 6,
1804 };
1805 let line = repo_verdict(&ctx, &[]);
1808 assert!(line.contains("not a Git repository"), "{line}");
1809 }
1810
1811 #[test]
1812 fn warnings_alone_do_not_fail_the_command() {
1813 let mut f = Findings::default();
1814 f.warn("Scheduler", "not installed");
1815 assert!(verdict(&f, "fine", None).is_ok());
1816
1817 f.problem("PATH", "missing");
1818 assert!(verdict(&f, "fine", None).is_err());
1819 }
1820
1821 #[test]
1822 #[cfg(windows)]
1823 fn a_path_entry_matches_regardless_of_case_and_trailing_separator() {
1824 let dir = Path::new(r"C:\Users\Someone\AppData\Roaming\dev-prune\bin");
1827 assert!(same_dir(
1828 Path::new(r"c:\users\someone\appdata\roaming\dev-prune\bin\"),
1829 dir
1830 ));
1831 assert!(!same_dir(Path::new(r"C:\Windows"), dir));
1832 }
1833
1834 #[test]
1835 #[cfg(not(windows))]
1836 fn a_path_entry_on_unix_is_matched_exactly() {
1837 assert!(same_dir(
1838 Path::new("/usr/local/bin"),
1839 Path::new("/usr/local/bin")
1840 ));
1841 assert!(!same_dir(
1842 Path::new("/USR/local/bin"),
1843 Path::new("/usr/local/bin")
1844 ));
1845 }
1846
1847 #[test]
1848 fn a_stale_twin_is_told_apart_from_a_current_one() {
1849 let dir = TempDir::new().unwrap();
1850 let a = dir.path().join("dev-prune");
1851 let b = dir.path().join("devp");
1852 std::fs::write(&a, b"version two").unwrap();
1853 std::fs::write(&b, b"version two").unwrap();
1854 assert!(same_binary(&a, &b));
1855
1856 std::fs::write(&b, b"version one").unwrap();
1858 assert!(!same_binary(&a, &b));
1859
1860 std::fs::write(&b, b"short").unwrap();
1861 assert!(!same_binary(&a, &b));
1862 assert!(!same_binary(&a, &dir.path().join("missing")));
1863 }
1864
1865 #[test]
1866 fn the_channel_directories_are_searched_even_when_they_are_not_on_path() {
1867 let home = Path::new(if cfg!(windows) {
1870 "C:\\home\\u"
1871 } else {
1872 "/home/u"
1873 });
1874 let dirs = copy_search_dirs("", Some(home));
1875 let joined = dirs
1876 .iter()
1877 .map(|d| d.to_string_lossy().to_lowercase())
1878 .collect::<Vec<_>>()
1879 .join("|");
1880 for marker in ["cargo", "uv", "pipx"] {
1881 assert!(
1882 joined.contains(marker),
1883 "{marker} directory missing from {joined}"
1884 );
1885 }
1886 }
1887
1888 #[test]
1889 fn path_entries_are_searched_and_empty_ones_dropped() {
1890 let sep = if cfg!(windows) { ";" } else { ":" };
1891 let a = if cfg!(windows) { "C:\\a" } else { "/a" };
1892 let b = if cfg!(windows) { "C:\\b" } else { "/b" };
1893 let dirs = copy_search_dirs(&format!("{a}{sep}{sep}{b}"), None);
1894 assert_eq!(dirs, vec![PathBuf::from(a), PathBuf::from(b)]);
1895 }
1896
1897 #[test]
1898 fn the_managed_directory_is_never_reported_as_another_copy() {
1899 let tmp = tempfile::tempdir().expect("temp dir");
1902 let managed = tmp.path().join("bin");
1903 std::fs::create_dir_all(&managed).expect("create");
1904 let name = if cfg!(windows) {
1905 "dev-prune.exe"
1906 } else {
1907 "dev-prune"
1908 };
1909 std::fs::write(managed.join(name), b"binary").expect("write");
1910
1911 assert!(binaries_in(std::slice::from_ref(&managed), Some(&managed)).is_empty());
1912 assert_eq!(binaries_in(std::slice::from_ref(&managed), None).len(), 1);
1913 }
1914
1915 #[test]
1916 fn one_binary_under_both_names_is_reported_once() {
1917 let tmp = tempfile::tempdir().expect("temp dir");
1920 let dir = tmp.path().to_path_buf();
1921 let (a, b) = if cfg!(windows) {
1922 ("dev-prune.exe", "devp.exe")
1923 } else {
1924 ("dev-prune", "devp")
1925 };
1926 std::fs::write(dir.join(a), b"same bytes").expect("write");
1927 std::fs::write(dir.join(b), b"same bytes").expect("write");
1928 assert_eq!(binaries_in(std::slice::from_ref(&dir), None).len(), 1);
1929
1930 std::fs::write(dir.join(b), b"a different build").expect("write");
1932 assert_eq!(binaries_in(&[dir], None).len(), 2);
1933 }
1934}