Skip to main content

dev_prune/commands/
doctor.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune doctor`.
5//
6// One command that answers "why is this not doing what I expect". Without a path it
7// checks the installation — binary, alias, PATH, config, integrations, package managers,
8// registry, release check. With a path it checks that one repository and ends by naming
9// the reason it would or would not be pruned right now.
10//
11// The plain doctor is read-only. A diagnostic that repairs things as it goes cannot be
12// run twice to see whether the first run helped, so diagnosis and treatment are two
13// separate invocations: the report names every finding it could repair, and `--fix` is
14// the explicit second step that repairs them. `--fix` only mends what is already
15// installed but broken — a missing or stale twin binary, hooks or a scheduler
16// registered against a binary that no longer exists, a drifted hook chain, a missing
17// `SKILL.md` export, registry entries whose repository is gone. It never installs an
18// integration for the first time (`devp setup` and the individual commands are the
19// opt-in for that), and it never touches an unreadable `registry.json`, because
20// guessing at a config it cannot read is exactly what dev-prune refuses to do.
21//
22// Nothing here runs a package manager either: `enforce_lockfile` invokes `npm`,
23// `cargo` and friends, which is minutes of work and, for the opted-in adapters, writes
24// to tracked files. The doctor reports what it can see.
25
26use 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/// One repair `--fix` knows how to make. Every variant mends something that is already
45/// installed but broken; none of them installs an integration for the first time.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum Repair {
48    /// The `dev-prune`/`devp` pair is missing a member, or the alias is stale.
49    Twin,
50    /// A managed `SKILL.md` copy is missing, or is from a different release.
51    SkillFile,
52    /// Installed hooks point at a deleted binary, or a chain has drifted.
53    Hooks,
54    /// The installed scheduler points at a deleted binary.
55    Scheduler,
56    /// Registered paths that no longer exist on disk.
57    UnlinkMissing,
58    /// Registered repositories whose `.devprune.json` cannot be parsed.
59    RepoConfigs,
60}
61
62/// Tally of everything the report flagged, so the verdict is derived from the same
63/// lines the user just read rather than recomputed from scratch.
64#[derive(Default)]
65struct Findings {
66    warnings: Vec<String>,
67    problems: Vec<String>,
68    /// Repairs `--fix` would make, in the order their findings were reported.
69    fixes: Vec<Repair>,
70    /// Indices into `problems` that one of `fixes` addresses, so the repair verdict can
71    /// tell "fixed by the pass that just ran" apart from "still needs a human".
72    fixable_problems: Vec<usize>,
73    /// The subset of `fixes` whose finding was a problem rather than a warning. A repair
74    /// from this list that does not actually land leaves the problem standing, and the
75    /// exit code has to say so.
76    problem_repairs: Vec<Repair>,
77}
78
79impl Findings {
80    /// A check that passed.
81    fn ok(&mut self, label: &str, detail: &str) {
82        println!("  {label:<22} {} {detail}", "✓".green());
83    }
84
85    /// Something to be aware of that is not stopping anything working.
86    fn warn(&mut self, label: &str, detail: &str) {
87        println!("  {label:<22} {} {detail}", "!".yellow());
88        self.warnings.push(format!("{label}: {detail}"));
89    }
90
91    /// Something that is actually broken.
92    fn problem(&mut self, label: &str, detail: &str) {
93        println!("  {label:<22} {} {detail}", "✗".red());
94        self.problems.push(format!("{label}: {detail}"));
95    }
96
97    /// Record that the most recent `warn` is one `--fix` can repair.
98    fn fixable(&mut self, repair: Repair) {
99        if !self.fixes.contains(&repair) {
100            self.fixes.push(repair);
101        }
102    }
103
104    /// Record that the most recent `problem` is one `--fix` can repair.
105    ///
106    /// Called immediately after the `problem` it belongs to, so the index bookkeeping
107    /// stays next to the finding it describes.
108    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    /// A fact with no verdict attached.
121    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
131// `colored` is used through these three, so the trait import stays local to the file.
132use colored::Colorize as _;
133
134/// Run the `doctor` command.
135///
136/// `path` is whatever the user typed, already tilde-expanded. `None` means the global
137/// installation; `Some(".")` is an ordinary path like any other. `fix` applies the
138/// repairs the installation check found; clap refuses `--fix` alongside a path, because
139/// the repository check has nothing it could safely repair.
140pub 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
147/// Print the verdict and pick the exit code.
148///
149/// Warnings exit `0`. A doctor that fails the build because the scheduler is not
150/// installed is a doctor people stop running; only the things that stop dev-prune doing
151/// its job are worth a non-zero status.
152fn 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
200// ---------------------------------------------------------------------------
201// Global installation
202// ---------------------------------------------------------------------------
203
204fn 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        // `--fix` with nothing repairable falls through to the ordinary verdict: what
222        // remains (if anything) needs a human, and saying which things is the verdict's
223        // whole job.
224        return verdict(&f, "Everything checks out — nothing to repair.", None);
225    }
226    verdict(&f, "Everything checks out.", None)
227}
228
229/// Apply every repair the diagnosis recorded, then give the repair verdict.
230///
231/// Each repair goes through the same `setup::ensure_*` passes the automatic setup uses,
232/// so a repair can never do something the setup pass would not — and like that pass,
233/// each one re-checks the state itself before touching anything, so a finding that
234/// healed between diagnosis and repair reports "already in place" rather than being
235/// re-applied.
236///
237/// `DEV_PRUNE_NO_AUTO_SETUP` disables every self-installation path, and the repairs
238/// that write outside the config directory — the twin binary, the git hooks, the OS
239/// scheduler — are exactly that, so with the variable set they are skipped and named.
240/// Bookkeeping inside dev-prune's own config directory (the `SKILL.md` export, dead
241/// registry entries) is not an installation and still runs.
242///
243/// Exit code: `0` unless a repair failed outright, a *problem*-level finding was left
244/// unrepaired, or a problem remains that no repair addresses. A skipped repair whose
245/// finding was only a warning (the twin is a running executable) exits `0` like any
246/// other warning — the report says what to do, and nothing is more broken than it
247/// already was.
248fn 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    // Problems whose repair was skipped rather than failed. Failures already count
267    // toward the exit code; a skipped problem has to as well, because the breakage the
268    // diagnosis reported is still there.
269    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        // These three write outside the config directory, which is precisely what the
311        // variable exists to forbid.
312        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    // A 32-bit build runs fine on a 64-bit machine, so this is a warning and never a
389    // problem — but nothing else on the machine would ever mention it, and the only
390    // symptom is a 4 GB address-space ceiling and a slower binary.
391    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    // The pair is one binary under two names, and either one can be the one running
412    // right now. Looking for `devp` unconditionally made this check vacuous whenever
413    // the user typed `devp doctor` — the file being looked for was the file doing the
414    // looking, so it always "passed". Look for whichever name is *not* running.
415    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        // The npm package is the one delivery that provides the second name without a
436        // second file: it declares both commands in its own `bin` map and each client
437        // writes a launcher for each. The directory being searched here is the platform
438        // package, which is not on `PATH` at all, and anything written into it would be
439        // discarded by the next global install — so a missing file here costs nothing
440        // and there is nothing to repair. True of every client that installs that
441        // package, not of npm alone.
442        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            // Either name may recreate a twin that is missing outright.
460            f.fixable(Repair::Twin);
461        }
462    } else if same_binary(&exe, &twin) {
463        f.ok(twin_stem, &output::clean_path(&twin));
464    } else {
465        // An upgrade that could not replace a running executable leaves exactly this
466        // state, and the stale name silently runs the previous version from then on.
467        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        // Only the canonical `dev-prune` may overwrite a differing twin — `devp`
478        // refreshing `dev-prune` could reinstall the version an upgrade just replaced.
479        // So this is repairable only from the canonical side.
480        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        // A warning, not a problem: the binary demonstrably runs — doctor is it
494        // running. Off PATH is a convenience gap (portable installs, cargo target
495        // dirs), not breakage, and exit 1 here would fail CI on a healthy install.
496        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
507/// Name the package manager that installed this copy, and the commands that upgrade and
508/// remove it through that manager.
509///
510/// Never a warning. Every channel here is a supported one and an unrecognised location is
511/// a perfectly valid way to run a binary — this reports what is true so the next question
512/// ("how do I update this?") has an answer on the same screen, rather than sending the
513/// user to guess between six install methods they may not remember choosing.
514fn 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        // The installer's own copy: `devp uninstall` removes it, so there is no manager
524        // command to name.
525        (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    // Only for the copy the receipt actually describes. Any other channel's binary would
534    // be shown a date belonging to a different file, which is worse than no date.
535    if channel == Channel::Installer
536        && let Some(receipt) = crate::receipt::load()
537    {
538        f.ok("Install receipt", &crate::receipt::summary(&receipt));
539    }
540}
541
542/// Find every *other* `dev-prune` on the machine and report the ones running a
543/// different version.
544///
545/// dev-prune ships through five channels and each one keeps its own copy. Upgrading via
546/// `devp update --install` replaces the copy that matters — the managed one the hooks
547/// and the scheduler invoke — and deliberately leaves the channel's own file alone,
548/// because rewriting another manager's directory is how installations end up
549/// unrepairable. The cost of that choice is a stale binary sitting on `PATH`, and if it
550/// comes first the user types `devp` and silently gets the old release, with every
551/// symptom pointing at dev-prune rather than at which copy answered.
552///
553/// So the copies are named. Nothing is deleted: which of them the user wants is a
554/// question only they can answer, and the manager that installed one is the only thing
555/// that should remove it.
556fn 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    // Asking each copy its own version, rather than comparing bytes: two channels can
569    // hold byte-identical files of the same release, and a differing byte is just as
570    // likely to be a different target triple as a different version. The question here
571    // is only ever "would running this give me a different dev-prune".
572    let stale: Vec<String> = copies
573        .iter()
574        .filter(|path| mine.as_deref() != Some(path.as_path()))
575        .filter_map(|path| {
576            // A file under this name that cannot state a version is left alone: it is
577            // far more likely to be something else entirely — a shell wrapper, a
578            // package-manager proxy that refuses to run under another name — than a
579            // dev-prune, and naming it would send the user to delete an unrelated file.
580            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
603/// One line of the "Other copies" warning: where the copy is, which release it is, and
604/// the command that removes it *through whatever put it there*.
605///
606/// Naming that command per copy is the whole value of the finding. "Remove each through
607/// the manager that installed it" is true and useless: the reason a second copy goes
608/// unnoticed for months is precisely that nobody remembers installing it, so the
609/// instruction to remember is the one thing the user cannot follow.
610fn 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        // The two channels that have no manager to ask. A copy inside the managed
615        // directory never reaches here — `binaries_in` skips that directory outright
616        // — but a second directory shaped like the installer's still can.
617        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
628/// Every directory that could hold a `dev-prune` this machine might run: `PATH`, plus
629/// the fixed per-channel directories from [`crate::channel::install_dirs`] — the same
630/// list `devp uninstall` sweeps, so doctor can never report a copy that uninstall then
631/// fails to find.
632///
633/// The channel directories are searched even when they are not on `PATH`, because that
634/// is the case that matters most — a copy nobody can see is also a copy nobody
635/// upgrades, and it becomes the one that runs the day the user adds the directory to
636/// `PATH` or a script calls it by absolute path.
637///
638/// Lexical and non-existent entries included; [`binaries_in`] does the filtering. Split
639/// from it so the directory list can be tested without a home directory full of package
640/// managers.
641fn 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
653/// The `dev-prune` and `devp` files that actually exist in `dirs`, minus everything in
654/// `skip_dir`.
655///
656/// `skip_dir` is the managed `bin` directory, whose contents — the canonical binary, its
657/// alias and the windowless twin — are already reported one by one by
658/// [`check_binary`] and [`check_scheduler_target`]. Listing them again as "other copies"
659/// would report a healthy install as having three stale binaries.
660fn 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            // `PATH` routinely lists the same directory twice, spelled differently, and
674            // on Windows `dev-prune.exe` and `devp.exe` are usually hard links to one
675            // file — so a copy would otherwise be reported once per name and per
676            // spelling.
677            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
690/// Whether a PATH entry names the directory the binary lives in.
691///
692/// Windows paths are case-insensitive but `Path` equality is not, and PATH entries
693/// routinely carry a trailing backslash the installer never wrote. Either mismatch made
694/// doctor report a perfectly good install as "not on PATH" — as a problem, so `devp
695/// doctor` exited 1 on a healthy machine.
696fn 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
710/// Whether two files hold the same bytes.
711///
712/// Doctor runs at human speed, so when the cheap size test cannot rule the pair
713/// different this reads both files outright — a stale twin left by a failed upgrade can
714/// share a size with its replacement, and "same version" is the whole question here.
715fn 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
725/// Read the config directory and validate every stored setting.
726///
727/// Returns `None` when the registry cannot be read, which is the one failure that makes
728/// every later section meaningless — they all need the settings.
729fn 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        // Not a fault. Absent configuration means defaults, which is the documented
750        // behaviour — it is an unreadable one that dev-prune refuses to guess about.
751        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(&registry.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            // Present is not the same as current. A copy left behind by an earlier
803            // release reads as a working install and answers questions about flags
804            // that were removed two versions ago.
805            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            // Reported before the target check because it is the worse of the two: a
841            // hook set installed before 1.4.0 looks perfectly healthy from here — the
842            // files exist and name a live binary — while Git, which reads
843            // `core.hooksPath` instead of `.git/hooks`, is silently running none of the
844            // repository's own hooks.
845            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                // The chain is installed and merely out of date; reinstalling it is the
872                // same repair the automatic setup pass makes.
873                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    // Said out loud, because "auto_setup = true" next to integrations that never install
907    // is a contradiction the user has no other way to explain.
908    if let Some(why) = setup::unattended_environment() {
909        f.note("", &format!("unattended installation is off because {why}"));
910    }
911    // The same presence test the suppression itself uses — `=true`, `=0` and even an
912    // empty value all switch setup off, so all of them must be reported here.
913    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
924/// Report an installed integration, and whether the binary it will run is still there.
925///
926/// An installed scheduler and an installed hook are both silent by construction — the
927/// scheduled task has no console and the hook throws its own output away — so a recorded
928/// path that has since been deleted produces no symptom whatsoever. Every interval, the
929/// task fails instantly; every commit, the hook does nothing. This is the only place that
930/// says so.
931///
932/// The path goes stale when the integration is installed from somewhere temporary:
933/// `npx dev-prune`, `uvx dev-prune`, or a `target/debug` build during development. Those
934/// no longer record the temporary path (see `setup::stable_exe_path`), but entries
935/// registered before that are still out there, and a user can always delete the binary
936/// out from under a perfectly ordinary install.
937fn 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        // Nothing to report: the entry is unreadable on this machine, which is not
946        // evidence of a problem. Saying so would be a warning nobody can act on.
947        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
990/// Check the package-manager binaries the registered repositories actually need.
991///
992/// Every adapter, not just the needed ones, would report `bun: not found` on a machine
993/// with no JavaScript on it at all — a warning about a tool the user has deliberately not
994/// installed. So the list comes from what is registered, and only falls back to all eight
995/// when nothing is registered yet and there is nothing else to go on.
996fn check_package_managers(f: &mut Findings, registry: Option<&Registry>) {
997    f.section("Package managers");
998
999    // `required` distinguishes a manager some registered repository actually depends on
1000    // from one merely listed for completeness. Warning that `go` is absent on a machine
1001    // with no Go project on it is noise the user cannot act on and would not want to.
1002    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    // `venv` is filtered out of the binary scan because it is not a command; its restore
1049    // path still needs an interpreter, and that is worth saying once.
1050    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    // A path that has gone is stale bookkeeping, not breakage: the pass reports it and
1097    // moves on, so this warns rather than failing. Listing thirty of them individually
1098    // buries every other finding, and thirty `devp unlink` lines is not a fix anyone will
1099    // run — so they collapse to a count and the one command that clears all of them.
1100    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    // An unreadable `.devprune.json` *is* breakage: the file that cannot be read may be
1133    // the one saying `"ignore": true`, so the repository is skipped until it is fixed.
1134    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    // Above the release check rather than beside it, because it outranks the answer:
1151    // whatever the check finds, a pinned copy is not going to install it.
1152    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    // Compared as versions, not as strings. `!=` reported the *cached* release as an
1198    // upgrade whenever it differed at all, so a machine running 1.2.0 with 1.1.0 still in
1199    // the cache was told to upgrade to 1.1.0 — and a development build one commit ahead of
1200    // the tag was told the same. Only "strictly newer" is an upgrade.
1201    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                // Not a warning under a pin: being behind is the state that was
1206                // asked for, and doctor flagging it would be doctor arguing with the
1207                // configuration.
1208                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
1234// ---------------------------------------------------------------------------
1235// One repository
1236// ---------------------------------------------------------------------------
1237
1238fn 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    // Loaded, not defaulted: a repository's verdict depends on the global thresholds, and
1247    // silently using the defaults would explain the wrong tool's behaviour.
1248    let registry = Registry::load().unwrap_or_default();
1249
1250    let ctx = check_repo_basics(&mut f, &path, &registry);
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
1261/// Everything about the repository that is decided before any project is looked at.
1262struct 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    // Read exactly the way the prune pass reads it, refusal to guess included.
1312    let layers = crate::config::RepoConfigLayers::load(path).ok();
1313    let (per_repo, config_broken) = match &layers {
1314        Some(layers) => {
1315            // Each file reported under its own name, holding its own contents. The
1316            // merged view is what the verdict below is computed from, but "what does
1317            // this file say" is the question somebody has while editing one of them.
1318            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        // Naming the wrong file here sends somebody to edit a file that does not decide
1352        // it, and `.devprune.json` losing to the committed one is precisely the case
1353        // where they would then swear the setting does nothing.
1354        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    // The same three-level resolution the engine performs: the repository's own file
1369    // beats its registry override, which beats the global setting.
1370    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
1428/// One project's worth of findings, kept so the verdict can reason over all of them.
1429struct ProjectReport {
1430    /// Whether any bloat directory here is above the floor and not symlinked.
1431    prunable: bool,
1432    /// Whether anything was found at all.
1433    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            // Presence only. Proving a lockfile is *usable* means running the package
1467            // manager, which is minutes of work and, for cargo and go, a write.
1468            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                // Every listed file absent — for bun, whose two spellings are
1481                // alternatives, that means neither is there.
1482                (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
1551/// Name the one reason this repository would not be pruned right now.
1552///
1553/// In the order the prune pass applies them, so the answer matches what `devp run` would
1554/// actually do rather than listing everything that happens to be true.
1555fn 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
1596/// One line describing what a `.devprune.json` actually overrides.
1597fn 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
1627/// The innermost cause of an error, which is the part that says what is actually wrong.
1628///
1629/// `anyhow`'s `{:#}` prints the whole chain, and the outer links here are all "failed to
1630/// parse the registry at <path>" — which the report has already said.
1631fn root_cause(e: &anyhow::Error) -> String {
1632    e.chain().last().map(|c| c.to_string()).unwrap_or_default()
1633}
1634
1635/// Replace every registered repository's unreadable `.devprune.json` with a default.
1636///
1637/// The broken file is never destroyed: it is renamed to `.devprune.json.broken`
1638/// (numbered if that name is taken) beside the fresh one, because it may hold overrides
1639/// the user meant — an `"ignore": true` with a trailing comma is still a decision, and
1640/// the person who typed it is the only one who can retype it. The rename-then-write
1641/// order means a failure between the two leaves the repository with no config at all —
1642/// which is defaults, the same thing the fresh file says.
1643fn heal_repo_configs() -> Result<usize> {
1644    let registry = Registry::load()?;
1645    let mut healed = 0usize;
1646    for repo in registry.repositories.keys() {
1647        // Only ever the personal file. `project.devprune.json` is committed, and renaming
1648        // a tracked file aside to write a default over it would turn a syntax error into
1649        // an unexplained working-tree change on somebody else's branch; `devp doctor`
1650        // reports that one and leaves `git checkout` to fix it.
1651        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        // The finding exists so the user can act on it without first remembering how
1699        // the copy got there, which is exactly what they have forgotten.
1700        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        // `Channel::uninstall_command` is None here for a good reason -- there is no
1712        // manager to ask -- but None must not come out as silence.
1713        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        // A file somebody copied into place by hand. Inventing a manager command for it
1724        // would send the user to run something that reports the package is not installed.
1725        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        // The whole point of the check: this is broken, not merely worth knowing, so it
1733        // has to reach the non-zero exit code.
1734        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        // `None` means the platform could not tell us, which is not evidence of a
1761        // problem — reporting it would be a warning nobody can act on.
1762        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    /// `min_size_mb: 0` is a value, not an absence — it opts a repository out of a global
1781    /// floor, so the report has to show it rather than treating it as unset.
1782    #[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        // Three reasons are true at once; the verdict names the one the prune pass would
1806        // hit first, which is the one the user has to fix before any other matters.
1807        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        // Both differences are ones Windows itself ignores, and either used to turn
1825        // into a "not on PATH" problem — a healthy install failing `devp doctor`.
1826        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        // Same length, different bytes — the case a size-only test waves through.
1857        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        // The whole point of this check: a copy nobody can see is a copy nobody
1868        // upgrades, and it becomes the one that runs the day PATH changes.
1869        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        // Its three files are each reported by name elsewhere; listing them here would
1900        // tell a healthy install it has stale binaries.
1901        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        // `dev-prune` and `devp` in the same directory are the same binary — on Windows
1918        // usually literally the same file — so a single install must not read as two.
1919        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        // A genuinely different binary under the second name is a second copy.
1931        std::fs::write(dir.join(b), b"a different build").expect("write");
1932        assert_eq!(binaries_in(&[dir], None).len(), 2);
1933    }
1934}