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    /// The `SKILL.md` export is missing or out of date.
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_file(),
326            Repair::Hooks => setup::ensure_hooks(chain),
327            Repair::Scheduler => setup::ensure_daemon(interval),
328            Repair::UnlinkMissing | Repair::RepoConfigs => unreachable!("handled above"),
329        };
330        match outcome {
331            setup::Outcome::Installed => {
332                ok(label, "repaired");
333                repaired += 1;
334            }
335            setup::Outcome::AlreadyPresent => {
336                ok(label, "already in place");
337                repaired += 1;
338            }
339            setup::Outcome::Skipped(why) => {
340                skipped(label, &why);
341                attention += 1;
342                if is_problem {
343                    skipped_problems += 1;
344                }
345            }
346            setup::Outcome::Failed(why) => {
347                failed_line(label, &why);
348                failures += 1;
349            }
350        }
351    }
352
353    f.section("Verdict");
354    let unfixable: Vec<&String> = f
355        .problems
356        .iter()
357        .enumerate()
358        .filter(|(i, _)| !f.fixable_problems.contains(i))
359        .map(|(_, p)| p)
360        .collect();
361    for p in &unfixable {
362        println!("  {} {p} (not auto-repairable)", "✗".red());
363    }
364    if !unfixable.is_empty() {
365        println!();
366        println!("  Troubleshooting: {}", constants::TROUBLESHOOTING_URL);
367    }
368    println!();
369    output::print_info(&format!(
370        "{repaired} repaired, {attention} skipped, {failures} failed. \
371         Run `devp doctor` to confirm."
372    ));
373
374    let unresolved = failures + skipped_problems + unfixable.len();
375    if unresolved > 0 {
376        anyhow::bail!(
377            "{unresolved} {} could not be repaired.",
378            output::plural(unresolved, "finding", "findings")
379        );
380    }
381    Ok(())
382}
383
384fn check_binary(f: &mut Findings) {
385    f.section("Installation");
386    f.note("Version", constants::VERSION);
387
388    // 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        // npm is the one channel that delivers the second name without a second file:
436        // it declares both commands in its own `bin` map and writes a launcher for
437        // each. The directory being searched here is the platform package, which is
438        // not on `PATH` at all, and anything written into it would be discarded by the
439        // next `npm install -g dev-prune` — so a missing file here costs nothing and
440        // there is nothing to repair.
441        if crate::channel::Channel::detect() == crate::channel::Channel::Npm {
442            f.ok(twin_stem, "provided by npm as a command of its own");
443        } else {
444            f.warn(
445                twin_stem,
446                &format!("not installed next to {running} — run `{running} setup`"),
447            );
448            // Either name may recreate a twin that is missing outright.
449            f.fixable(Repair::Twin);
450        }
451    } else if same_binary(&exe, &twin) {
452        f.ok(twin_stem, &output::clean_path(&twin));
453    } else {
454        // An upgrade that could not replace a running executable leaves exactly this
455        // state, and the stale name silently runs the previous version from then on.
456        f.warn(
457            twin_stem,
458            &format!(
459                "{} is not the same binary as {} — one of the pair is stale and \
460                 silently runs a different version. `dev-prune setup` refreshes `devp` \
461                 from the canonical `dev-prune`.",
462                output::clean_path(&twin),
463                output::clean_path(&exe)
464            ),
465        );
466        // Only the canonical `dev-prune` may overwrite a differing twin — `devp`
467        // refreshing `dev-prune` could reinstall the version an upgrade just replaced.
468        // So this is repairable only from the canonical side.
469        if running == "dev-prune" {
470            f.fixable(Repair::Twin);
471        }
472    }
473
474    let sep = if cfg!(windows) { ';' } else { ':' };
475    let on_path = std::env::var("PATH")
476        .unwrap_or_default()
477        .split(sep)
478        .any(|p| !p.is_empty() && same_dir(Path::new(p), dir));
479    if on_path {
480        f.ok("PATH", &output::clean_path(dir));
481    } else {
482        // A warning, not a problem: the binary demonstrably runs — doctor is it
483        // running. Off PATH is a convenience gap (portable installs, cargo target
484        // dirs), not breakage, and exit 1 here would fail CI on a healthy install.
485        f.warn(
486            "PATH",
487            &format!(
488                "{} is not on PATH — `devp` will not resolve in a new shell. \
489                 `dev-prune setup` adds it.",
490                output::clean_path(dir)
491            ),
492        );
493    }
494}
495
496/// Name the package manager that installed this copy, and the commands that upgrade and
497/// remove it through that manager.
498///
499/// Never a warning. Every channel here is a supported one and an unrecognised location is
500/// a perfectly valid way to run a binary — this reports what is true so the next question
501/// ("how do I update this?") has an answer on the same screen, rather than sending the
502/// user to guess between six install methods they may not remember choosing.
503fn check_install_channel(f: &mut Findings) {
504    let channel = Channel::detect();
505    let detail = match (channel.upgrade_command(), channel.uninstall_command()) {
506        (Some(upgrade), Some(uninstall)) => {
507            format!(
508                "{} — upgrade `{upgrade}`, remove `{uninstall}`",
509                channel.label()
510            )
511        }
512        // The installer's own copy: `devp uninstall` removes it, so there is no manager
513        // command to name.
514        (Some(upgrade), None) => format!("{} — upgrade `{upgrade}`", channel.label()),
515        _ => format!(
516            "{} — `devp update --install` still upgrades it in place",
517            channel.label()
518        ),
519    };
520    f.ok("Install channel", &detail);
521
522    // Only for the copy the receipt actually describes. Any other channel's binary would
523    // be shown a date belonging to a different file, which is worse than no date.
524    if channel == Channel::Installer
525        && let Some(receipt) = crate::receipt::load()
526    {
527        f.ok("Install receipt", &crate::receipt::summary(&receipt));
528    }
529}
530
531/// Find every *other* `dev-prune` on the machine and report the ones running a
532/// different version.
533///
534/// dev-prune ships through five channels and each one keeps its own copy. Upgrading via
535/// `devp update --install` replaces the copy that matters — the managed one the hooks
536/// and the scheduler invoke — and deliberately leaves the channel's own file alone,
537/// because rewriting another manager's directory is how installations end up
538/// unrepairable. The cost of that choice is a stale binary sitting on `PATH`, and if it
539/// comes first the user types `devp` and silently gets the old release, with every
540/// symptom pointing at dev-prune rather than at which copy answered.
541///
542/// So the copies are named. Nothing is deleted: which of them the user wants is a
543/// question only they can answer, and the manager that installed one is the only thing
544/// that should remove it.
545fn check_other_copies(f: &mut Findings) {
546    let mine = std::env::current_exe().ok();
547    let managed_dir = setup::managed_exe_path()
548        .ok()
549        .and_then(|p| p.parent().map(Path::to_path_buf));
550
551    let search = copy_search_dirs(
552        &std::env::var("PATH").unwrap_or_default(),
553        dirs::home_dir().as_deref(),
554    );
555    let copies = binaries_in(&search, managed_dir.as_deref());
556
557    // Asking each copy its own version, rather than comparing bytes: two channels can
558    // hold byte-identical files of the same release, and a differing byte is just as
559    // likely to be a different target triple as a different version. The question here
560    // is only ever "would running this give me a different dev-prune".
561    let stale: Vec<String> = copies
562        .iter()
563        .filter(|path| mine.as_deref() != Some(path.as_path()))
564        .filter_map(|path| {
565            // A file under this name that cannot state a version is left alone: it is
566            // far more likely to be something else entirely — a shell wrapper, a
567            // package-manager proxy that refuses to run under another name — than a
568            // dev-prune, and naming it would send the user to delete an unrelated file.
569            let version = setup::binary_version(path)?;
570            let ours = setup::parse_version(constants::VERSION)?;
571            (version != ours).then(|| {
572                let channel = Channel::detect_at(path, managed_dir.as_deref());
573                stale_copy_line(path, version, channel)
574            })
575        })
576        .collect();
577
578    if stale.is_empty() {
579        f.ok("Other copies", "none on PATH running a different version");
580        return;
581    }
582    f.warn(
583        "Other copies",
584        &format!(
585            "{} — whichever comes first on PATH is the one `devp` runs, and \
586             `devp update --install` only replaces the managed copy.",
587            stale.join("; ")
588        ),
589    );
590}
591
592/// One line of the "Other copies" warning: where the copy is, which release it is, and
593/// the command that removes it *through whatever put it there*.
594///
595/// Naming that command per copy is the whole value of the finding. "Remove each through
596/// the manager that installed it" is true and useless: the reason a second copy goes
597/// unnoticed for months is precisely that nobody remembers installing it, so the
598/// instruction to remember is the one thing the user cannot follow.
599fn stale_copy_line(path: &Path, version: (u64, u64, u64), channel: Channel) -> String {
600    let (major, minor, patch) = version;
601    let remedy = match channel.uninstall_command() {
602        Some(cmd) => format!("from {}, remove with `{cmd}`", channel.label()),
603        // The two channels that have no manager to ask. A copy inside the managed
604        // directory never reaches here — `binaries_in` skips that directory outright
605        // — but a second directory shaped like the installer's still can.
606        None if channel == Channel::Installer => {
607            "left by the install script, remove with `devp uninstall`".to_string()
608        }
609        None => "no package manager owns it; delete the file yourself".to_string(),
610    };
611    format!(
612        "{} (v{major}.{minor}.{patch}, {remedy})",
613        output::clean_path(path)
614    )
615}
616
617/// Every directory that could hold a `dev-prune` this machine might run: `PATH`, plus
618/// the fixed per-channel directories from [`crate::channel::install_dirs`] — the same
619/// list `devp uninstall` sweeps, so doctor can never report a copy that uninstall then
620/// fails to find.
621///
622/// The channel directories are searched even when they are not on `PATH`, because that
623/// is the case that matters most — a copy nobody can see is also a copy nobody
624/// upgrades, and it becomes the one that runs the day the user adds the directory to
625/// `PATH` or a script calls it by absolute path.
626///
627/// Lexical and non-existent entries included; [`binaries_in`] does the filtering. Split
628/// from it so the directory list can be tested without a home directory full of package
629/// managers.
630fn copy_search_dirs(path_var: &str, home: Option<&Path>) -> Vec<PathBuf> {
631    let sep = if cfg!(windows) { ';' } else { ':' };
632    let mut dirs: Vec<PathBuf> = path_var
633        .split(sep)
634        .filter(|p| !p.is_empty())
635        .map(PathBuf::from)
636        .collect();
637
638    dirs.extend(crate::channel::install_dirs(home));
639    dirs
640}
641
642/// The `dev-prune` and `devp` files that actually exist in `dirs`, minus everything in
643/// `skip_dir`.
644///
645/// `skip_dir` is the managed `bin` directory, whose contents — the canonical binary, its
646/// alias and the windowless twin — are already reported one by one by
647/// [`check_binary`] and [`check_scheduler_target`]. Listing them again as "other copies"
648/// would report a healthy install as having three stale binaries.
649fn binaries_in(dirs: &[PathBuf], skip_dir: Option<&Path>) -> Vec<PathBuf> {
650    let names: [&str; 2] = if cfg!(windows) {
651        ["dev-prune.exe", "devp.exe"]
652    } else {
653        ["dev-prune", "devp"]
654    };
655    let mut found: Vec<PathBuf> = Vec::new();
656    for dir in dirs {
657        if skip_dir.is_some_and(|skip| same_dir(dir, skip)) {
658            continue;
659        }
660        for name in names {
661            let candidate = dir.join(name);
662            // `PATH` routinely lists the same directory twice, spelled differently, and
663            // on Windows `dev-prune.exe` and `devp.exe` are usually hard links to one
664            // file — so a copy would otherwise be reported once per name and per
665            // spelling.
666            if candidate.is_file()
667                && !found.iter().any(|seen| {
668                    seen == &candidate
669                        || (seen.parent() == candidate.parent() && same_binary(seen, &candidate))
670                })
671            {
672                found.push(candidate);
673            }
674        }
675    }
676    found
677}
678
679/// Whether a PATH entry names the directory the binary lives in.
680///
681/// Windows paths are case-insensitive but `Path` equality is not, and PATH entries
682/// routinely carry a trailing backslash the installer never wrote. Either mismatch made
683/// doctor report a perfectly good install as "not on PATH" — as a problem, so `devp
684/// doctor` exited 1 on a healthy machine.
685fn same_dir(entry: &Path, dir: &Path) -> bool {
686    if entry == dir {
687        return true;
688    }
689    cfg!(windows) && {
690        let norm = |p: &Path| {
691            p.to_string_lossy()
692                .trim_end_matches(['\\', '/'])
693                .to_lowercase()
694        };
695        norm(entry) == norm(dir)
696    }
697}
698
699/// Whether two files hold the same bytes.
700///
701/// Doctor runs at human speed, so when the cheap size test cannot rule the pair
702/// different this reads both files outright — a stale twin left by a failed upgrade can
703/// share a size with its replacement, and "same version" is the whole question here.
704fn same_binary(a: &Path, b: &Path) -> bool {
705    let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
706        return false;
707    };
708    if ma.len() != mb.len() {
709        return false;
710    }
711    matches!((std::fs::read(a), std::fs::read(b)), (Ok(ba), Ok(bb)) if ba == bb)
712}
713
714/// Read the config directory and validate every stored setting.
715///
716/// Returns `None` when the registry cannot be read, which is the one failure that makes
717/// every later section meaningless — they all need the settings.
718fn check_configuration(f: &mut Findings) -> Option<Registry> {
719    f.section("Configuration");
720
721    let dir = match Registry::config_dir() {
722        Ok(d) => d,
723        Err(e) => {
724            f.problem("Config directory", &format!("cannot be resolved: {e}"));
725            return None;
726        }
727    };
728    f.note("Config directory", &output::clean_path(&dir));
729    if std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE).is_ok() {
730        f.note(
731            "",
732            &format!("(set by {})", constants::ENV_CONFIG_DIR_OVERRIDE),
733        );
734    }
735
736    let path = dir.join(constants::REGISTRY_FILENAME);
737    if !path.exists() {
738        // Not a fault. Absent configuration means defaults, which is the documented
739        // behaviour — it is an unreadable one that dev-prune refuses to guess about.
740        f.ok("registry.json", "not created yet — defaults apply");
741        return Some(Registry::default());
742    }
743
744    let registry = match Registry::load_from(&path) {
745        Ok(r) => r,
746        Err(e) => {
747            f.problem(
748                "registry.json",
749                &format!(
750                    "{} — dev-prune refuses to guess at a config it cannot read. \
751                     Fix the syntax, or delete the file to start from defaults.",
752                    root_cause(&e)
753                ),
754            );
755            return None;
756        }
757    };
758
759    f.ok(
760        "registry.json",
761        &format!(
762            "readable — {} {} registered",
763            registry.repo_count(),
764            output::plural(registry.repo_count(), "repository", "repositories")
765        ),
766    );
767
768    let invalid = crate::commands::config::invalid_settings(&registry.settings);
769    if invalid.is_empty() {
770        f.ok(
771            "Settings",
772            &format!(
773                "all {} within range",
774                crate::commands::config::setting_count()
775            ),
776        );
777    } else {
778        for (key, why) in &invalid {
779            f.problem("Settings", &format!("{key}: {why}"));
780        }
781    }
782
783    Some(registry)
784}
785
786fn check_integrations(f: &mut Findings, registry: Option<&Registry>) {
787    f.section("Integrations");
788
789    match setup::skill_path() {
790        Ok(p) if p.exists() => f.ok("SKILL.md", &output::clean_path(&p)),
791        _ => {
792            f.warn("SKILL.md", "not exported — run `devp skill`");
793            f.fixable(Repair::SkillFile);
794        }
795    }
796
797    if crate::commands::icon::is_registered() {
798        f.ok("File icons", "registered with the file manager");
799    } else {
800        f.warn("File icons", "not registered — run `devp icon`");
801    }
802
803    if !hook::git_available() {
804        f.warn(
805            "Git hooks",
806            "git is not on PATH, so repositories cannot auto-register",
807        );
808    } else {
809        match hook::state() {
810            // Reported before the target check because it is the worse of the two: a
811            // hook set installed before 1.4.0 looks perfectly healthy from here — the
812            // files exist and name a live binary — while Git, which reads
813            // `core.hooksPath` instead of `.git/hooks`, is silently running none of the
814            // repository's own hooks.
815            Ok(HookState::Active) if hook::shims_incomplete() => {
816                f.warn(
817                    "Git hooks",
818                    concat!(
819                        "active, but installed without passthrough shims — ",
820                        "every repository's own `.git/hooks` is being ignored ",
821                        "machine-wide. `devp hook install` rewrites them to forward."
822                    ),
823                );
824                f.fixable(Repair::Hooks);
825            }
826            Ok(HookState::Active) => check_hook_target(f, "active"),
827            Ok(HookState::Absent) => f.warn("Git hooks", "not installed — run `devp hook install`"),
828            Ok(HookState::Chained { previous, drifted }) if drifted.is_empty() => {
829                check_hook_target(f, &format!("active, chained to `{previous}`"))
830            }
831            Ok(HookState::Chained { previous, drifted }) => {
832                f.warn(
833                    "Git hooks",
834                    &format!(
835                        "chained to `{previous}`, but {} not forwarded ({}) — \
836                         re-run `devp hook install --chain`",
837                        drifted.len(),
838                        drifted.join(", ")
839                    ),
840                );
841                // The chain is installed and merely out of date; reinstalling it is the
842                // same repair the automatic setup pass makes.
843                f.fixable(Repair::Hooks);
844            }
845            Ok(HookState::Foreign(p)) => f.warn(
846                "Git hooks",
847                &format!(
848                    "core.hooksPath belongs to `{p}` — install in front of it with \
849                     `devp hook install --chain`"
850                ),
851            ),
852            Err(e) => f.warn("Git hooks", &format!("state unknown ({e})")),
853        }
854    }
855
856    match daemon::daemon_status() {
857        Ok(daemon::DaemonStatus::Installed) => check_scheduler_target(f),
858        Ok(daemon::DaemonStatus::NotInstalled) => f.warn(
859            "Scheduler",
860            "not installed — nothing prunes on its own. `devp daemon install` adds it.",
861        ),
862        Ok(daemon::DaemonStatus::Unknown(why)) => f.warn("Scheduler", &why),
863        Err(e) => f.warn("Scheduler", &format!("state unknown ({e})")),
864    }
865
866    if let Some(r) = registry {
867        f.note(
868            "Automatic setup",
869            &format!(
870                "auto_setup={} auto_hooks={} auto_daemon={}",
871                r.settings.auto_setup, r.settings.auto_hooks, r.settings.auto_daemon
872            ),
873        );
874    }
875
876    // Said out loud, because "auto_setup = true" next to integrations that never install
877    // is a contradiction the user has no other way to explain.
878    if let Some(why) = setup::unattended_environment() {
879        f.note("", &format!("unattended installation is off because {why}"));
880    }
881    // The same presence test the suppression itself uses — `=true`, `=0` and even an
882    // empty value all switch setup off, so all of them must be reported here.
883    if setup::no_auto_setup_requested() {
884        f.note(
885            "",
886            &format!(
887                "{} is set — nothing installs by itself. `devp setup` still works.",
888                setup::ENV_NO_AUTO_SETUP
889            ),
890        );
891    }
892}
893
894/// Report an installed integration, and whether the binary it will run is still there.
895///
896/// An installed scheduler and an installed hook are both silent by construction — the
897/// scheduled task has no console and the hook throws its own output away — so a recorded
898/// path that has since been deleted produces no symptom whatsoever. Every interval, the
899/// task fails instantly; every commit, the hook does nothing. This is the only place that
900/// says so.
901///
902/// The path goes stale when the integration is installed from somewhere temporary:
903/// `npx dev-prune`, `uvx dev-prune`, or a `target/debug` build during development. Those
904/// no longer record the temporary path (see `setup::stable_exe_path`), but entries
905/// registered before that are still out there, and a user can always delete the binary
906/// out from under a perfectly ordinary install.
907fn report_integration_target(
908    f: &mut Findings,
909    label: &str,
910    installed: &str,
911    recorded: Option<std::path::PathBuf>,
912    repair: &str,
913) -> bool {
914    match recorded {
915        // Nothing to report: the entry is unreadable on this machine, which is not
916        // evidence of a problem. Saying so would be a warning nobody can act on.
917        None => f.ok(label, installed),
918        Some(path) if path.is_file() => f.ok(
919            label,
920            &format!("{installed} — {}", output::clean_path(&path)),
921        ),
922        Some(path) => {
923            f.problem(
924                label,
925                &format!(
926                    "registered, but `{}` no longer exists — it never runs. {repair}",
927                    output::clean_path(&path)
928                ),
929            );
930            return true;
931        }
932    }
933    false
934}
935
936fn check_scheduler_target(f: &mut Findings) {
937    if report_integration_target(
938        f,
939        "Scheduler",
940        "installed",
941        daemon::registered_exe_path(),
942        "Re-register it with `devp daemon install`.",
943    ) {
944        f.fixable_problem(Repair::Scheduler);
945    }
946}
947
948fn check_hook_target(f: &mut Findings, installed: &str) {
949    if report_integration_target(
950        f,
951        "Git hooks",
952        installed,
953        hook::registered_exe_path(),
954        "Rewrite them with `devp hook install`.",
955    ) {
956        f.fixable_problem(Repair::Hooks);
957    }
958}
959
960/// Check the package-manager binaries the registered repositories actually need.
961///
962/// Every adapter, not just the needed ones, would report `bun: not found` on a machine
963/// with no JavaScript on it at all — a warning about a tool the user has deliberately not
964/// installed. So the list comes from what is registered, and only falls back to all eight
965/// when nothing is registered yet and there is nothing else to go on.
966fn check_package_managers(f: &mut Findings, registry: Option<&Registry>) {
967    f.section("Package managers");
968
969    // `required` distinguishes a manager some registered repository actually depends on
970    // from one merely listed for completeness. Warning that `go` is absent on a machine
971    // with no Go project on it is noise the user cannot act on and would not want to.
972    let (needed, required): (Vec<String>, bool) = match registry {
973        Some(r) if r.repo_count() > 0 => {
974            let mut names: Vec<String> = engine::get_full_status(r)
975                .into_iter()
976                .flat_map(|e| e.adapters)
977                .collect();
978            names.sort();
979            names.dedup();
980            (names, true)
981        }
982        _ => (
983            adapters::get_all_adapters()
984                .iter()
985                .map(|a| a.name().to_string())
986                .collect(),
987            false,
988        ),
989    };
990
991    if needed.is_empty() {
992        f.note(
993            "",
994            "no package managers are needed by the registered repositories",
995        );
996        return;
997    }
998    if !required {
999        f.note("", "nothing is registered yet, so this is the full list");
1000    }
1001
1002    for status in adapters::scan_required_binaries(&needed) {
1003        match (status.available, status.version) {
1004            (true, Some(v)) => f.ok(&status.name, &v),
1005            (true, None) => f.ok(&status.name, "available"),
1006            (false, _) if required => {
1007                let detail =
1008                    "not on PATH — projects using it cannot be verified, pruned or restored";
1009                match adapters::install_hint(&status.name) {
1010                    Some(hint) => f.warn(&status.name, &format!("{detail}. Install it: {hint}")),
1011                    None => f.warn(&status.name, detail),
1012                }
1013            }
1014            (false, _) => f.note(&status.name, "not installed"),
1015        }
1016    }
1017
1018    // `venv` is filtered out of the binary scan because it is not a command; its restore
1019    // path still needs an interpreter, and that is worth saying once.
1020    if required && needed.iter().any(|n| n == "venv") && !adapters::binary_available("python") {
1021        f.warn(
1022            "python",
1023            "not on PATH — `devp restore` cannot rebuild a plain virtual environment. \
1024             Install it: https://www.python.org/downloads/",
1025        );
1026    }
1027}
1028
1029fn check_registry_health(f: &mut Findings, registry: Option<&Registry>) {
1030    f.section("Registered repositories");
1031
1032    let Some(registry) = registry else { return };
1033    if registry.repo_count() == 0 {
1034        f.note("", "none yet — `devp init ~/Code` or `devp link .`");
1035        return;
1036    }
1037
1038    let entries = engine::get_full_status(registry);
1039    let count = |want: &SkipReason| {
1040        entries
1041            .iter()
1042            .filter(|e| std::mem::discriminant(&e.reason) == std::mem::discriminant(want))
1043            .count()
1044    };
1045    let reclaimable: u64 = entries.iter().map(|e| e.reclaimable_bytes).sum();
1046
1047    f.note(
1048        "Total",
1049        &format!(
1050            "{} registered, {} reclaimable",
1051            entries.len(),
1052            output::format_bytes(reclaimable)
1053        ),
1054    );
1055    f.note(
1056        "Breakdown",
1057        &format!(
1058            "{} candidates, {} active, {} ignored, {} with no bloat",
1059            count(&SkipReason::Candidate),
1060            count(&SkipReason::Active),
1061            count(&SkipReason::Ignored),
1062            count(&SkipReason::NoBloat),
1063        ),
1064    );
1065
1066    // A path that has gone is stale bookkeeping, not breakage: the pass reports it and
1067    // moves on, so this warns rather than failing. Listing thirty of them individually
1068    // buries every other finding, and thirty `devp unlink` lines is not a fix anyone will
1069    // run — so they collapse to a count and the one command that clears all of them.
1070    let missing: Vec<&Path> = entries
1071        .iter()
1072        .filter(|e| matches!(e.reason, SkipReason::PathMissing))
1073        .map(|e| e.path.as_path())
1074        .collect();
1075
1076    match missing.len() {
1077        0 => {}
1078        1 => {
1079            f.warn(
1080                "Missing",
1081                &format!(
1082                    "{} no longer exists — `devp unlink {}`",
1083                    output::clean_path(missing[0]),
1084                    output::clean_path(missing[0])
1085                ),
1086            );
1087            f.fixable(Repair::UnlinkMissing);
1088        }
1089        n => {
1090            f.warn(
1091                "Missing",
1092                &format!(
1093                    "{n} registered paths no longer exist, starting with {} \
1094                     — `devp unlink --missing` clears all of them",
1095                    output::clean_path(missing[0])
1096                ),
1097            );
1098            f.fixable(Repair::UnlinkMissing);
1099        }
1100    }
1101
1102    // An unreadable `.devprune.json` *is* breakage: the file that cannot be read may be
1103    // the one saying `"ignore": true`, so the repository is skipped until it is fixed.
1104    for entry in &entries {
1105        if let SkipReason::ConfigError(e) = &entry.reason {
1106            f.problem(
1107                "Unreadable config",
1108                &format!("{}: {e}", output::clean_path(&entry.path)),
1109            );
1110            f.fixable_problem(Repair::RepoConfigs);
1111        }
1112    }
1113}
1114
1115fn check_release_state(f: &mut Findings, registry: Option<&Registry>) {
1116    f.section("Release check");
1117
1118    let Some(registry) = registry else { return };
1119
1120    // Above the release check rather than beside it, because it outranks the answer:
1121    // whatever the check finds, a pinned copy is not going to install it.
1122    if registry.settings.version_lock {
1123        f.note(
1124            "version_lock",
1125            &format!(
1126                "on — this copy stays at v{}. auto_update, `devp update --install`, \
1127                 `devp install --channel` and the install scripts all stand down until \
1128                 `devp config set version_lock false`",
1129                constants::VERSION
1130            ),
1131        );
1132    }
1133
1134    if !registry.settings.update_check {
1135        f.note(
1136            "update_check",
1137            "off — dev-prune opens no network connection",
1138        );
1139        return;
1140    }
1141
1142    f.note(
1143        "update_check",
1144        &format!(
1145            "on, every {} {}",
1146            registry.settings.update_check_interval_days,
1147            output::plural(
1148                registry.settings.update_check_interval_days as usize,
1149                "day",
1150                "days"
1151            )
1152        ),
1153    );
1154
1155    match registry.last_update_check {
1156        Some(at) => f.note(
1157            "Last checked",
1158            &format!(
1159                "{} ({} days ago)",
1160                at.format("%Y-%m-%d %H:%M UTC"),
1161                (Utc::now() - at).num_days()
1162            ),
1163        ),
1164        None => f.note("Last checked", "never"),
1165    }
1166
1167    // Compared as versions, not as strings. `!=` reported the *cached* release as an
1168    // upgrade whenever it differed at all, so a machine running 1.2.0 with 1.1.0 still in
1169    // the cache was told to upgrade to 1.1.0 — and a development build one commit ahead of
1170    // the tag was told the same. Only "strictly newer" is an upgrade.
1171    match registry.latest_known_version.as_deref() {
1172        Some(latest) => {
1173            let latest_core = latest.trim_start_matches('v');
1174            match super::update::compare_versions(constants::VERSION, latest_core) {
1175                // Not a warning under a pin: being behind is the state that was
1176                // asked for, and doctor flagging it would be doctor arguing with the
1177                // configuration.
1178                Some(Ordering::Less) if registry.settings.version_lock => f.note(
1179                    "Latest release",
1180                    &format!(
1181                        "{latest} is available, and version_lock is holding this copy at v{}",
1182                        constants::VERSION
1183                    ),
1184                ),
1185                Some(Ordering::Less) => f.warn(
1186                    "Latest release",
1187                    &format!("{latest} is available — `devp update` shows how to upgrade"),
1188                ),
1189                Some(Ordering::Greater) => f.ok(
1190                    "Latest release",
1191                    &format!("{latest} — this build is newer than the last published one"),
1192                ),
1193                Some(Ordering::Equal) => f.ok("Latest release", &format!("{latest} — up to date")),
1194                None => f.note(
1195                    "Latest release",
1196                    &format!("{latest} — could not be compared to {}", constants::VERSION),
1197                ),
1198            }
1199        }
1200        None => f.note("Latest release", "not known yet"),
1201    }
1202}
1203
1204// ---------------------------------------------------------------------------
1205// One repository
1206// ---------------------------------------------------------------------------
1207
1208fn check_repository(path_str: &str) -> Result<()> {
1209    let path = Path::new(path_str)
1210        .canonicalize()
1211        .with_context(|| format!("Path not found: {path_str}"))?;
1212
1213    output::print_header(&format!("dev-prune doctor ({})", output::clean_path(&path)));
1214    let mut f = Findings::default();
1215
1216    // Loaded, not defaulted: a repository's verdict depends on the global thresholds, and
1217    // silently using the defaults would explain the wrong tool's behaviour.
1218    let registry = Registry::load().unwrap_or_default();
1219
1220    let ctx = check_repo_basics(&mut f, &path, &registry);
1221    let projects = check_repo_projects(&mut f, &path, &ctx);
1222    let headline = repo_verdict(&ctx, &projects);
1223
1224    verdict(
1225        &f,
1226        &format!("{} is in good shape.", output::clean_path(&ctx.path)),
1227        Some(&headline),
1228    )
1229}
1230
1231/// Everything about the repository that is decided before any project is looked at.
1232struct RepoContext {
1233    path: PathBuf,
1234    is_git: bool,
1235    registered: bool,
1236    opted_out: Option<String>,
1237    config_broken: bool,
1238    idle: bool,
1239    idle_days: u64,
1240    min_size_bytes: u64,
1241    depth: usize,
1242}
1243
1244fn check_repo_basics(f: &mut Findings, path: &Path, registry: &Registry) -> RepoContext {
1245    f.section("Repository");
1246
1247    let is_git = scanner::is_git_repo(path);
1248    if is_git {
1249        f.ok("Git repository", "yes");
1250    } else {
1251        f.problem(
1252            "Git repository",
1253            "no — dev-prune only ever touches Git repositories",
1254        );
1255    }
1256
1257    let key = crate::config::canonical_key(path);
1258    let entry = registry.repositories.get(&key);
1259    match entry {
1260        Some(e) if e.enabled => f.ok(
1261            "Registered",
1262            &format!("yes, since {}", e.added_at.format("%Y-%m-%d")),
1263        ),
1264        Some(e) => f.warn(
1265            "Registered",
1266            &format!(
1267                "yes since {}, but disabled — `devp config {} --update`",
1268                e.added_at.format("%Y-%m-%d"),
1269                output::clean_path(path)
1270            ),
1271        ),
1272        None => f.warn(
1273            "Registered",
1274            "no — a prune pass will not visit it. `devp link .` registers it.",
1275        ),
1276    }
1277    if let Some(at) = entry.and_then(|e| e.last_pruned_at) {
1278        f.note("Last pruned", &at.format("%Y-%m-%d %H:%M UTC").to_string());
1279    }
1280
1281    // Read exactly the way the prune pass reads it, refusal to guess included.
1282    let (per_repo, config_broken) = match PerRepoConfig::load_with_diagnostics(path) {
1283        Ok(Some(cfg)) => {
1284            f.ok(constants::PER_REPO_CONFIG_FILE, &describe_overrides(&cfg));
1285            (Some(cfg), false)
1286        }
1287        Ok(None) => {
1288            f.note(
1289                constants::PER_REPO_CONFIG_FILE,
1290                "absent — global settings apply",
1291            );
1292            (None, false)
1293        }
1294        Err(e) => {
1295            f.problem(
1296                constants::PER_REPO_CONFIG_FILE,
1297                &format!("{e} — the repository is skipped entirely until this parses"),
1298            );
1299            (None, true)
1300        }
1301    };
1302
1303    let mut opted_out = None;
1304    if path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
1305        opted_out = Some(format!("{} is present", constants::DEVPRUNE_IGNORE_FILE));
1306    } else if per_repo.as_ref().is_some_and(|c| c.ignore) {
1307        opted_out = Some(format!(
1308            "\"ignore\": true in {}",
1309            constants::PER_REPO_CONFIG_FILE
1310        ));
1311    } else if entry.is_some_and(|e| !e.enabled) {
1312        opted_out = Some("disabled in the registry".to_string());
1313    }
1314    match &opted_out {
1315        Some(why) => f.note("Opt-out", why),
1316        None => f.note("Opt-out", "none"),
1317    }
1318
1319    // The same three-level resolution the engine performs: the repository's own file
1320    // beats its registry override, which beats the global setting.
1321    let idle_days = per_repo
1322        .as_ref()
1323        .and_then(|c| c.override_idle_days)
1324        .or_else(|| entry.and_then(|e| e.override_idle_days))
1325        .unwrap_or(registry.settings.idle_days);
1326
1327    let activity = git::get_last_activity(path).ok().flatten();
1328    let idle = git::is_idle_at(activity, idle_days);
1329    match activity {
1330        Some(t) => {
1331            let days = chrono::DateTime::<Utc>::from(t);
1332            let ago = (Utc::now() - days).num_days();
1333            let detail = format!(
1334                "{} ({ago} {} ago), threshold {idle_days}",
1335                days.format("%Y-%m-%d"),
1336                output::plural(ago.unsigned_abs() as usize, "day", "days")
1337            );
1338            if idle {
1339                f.ok("Activity", &format!("{detail} — idle"));
1340            } else {
1341                f.note("Activity", &format!("{detail} — active"));
1342            }
1343        }
1344        None => f.note(
1345            "Activity",
1346            &format!("no commits or source edits found, threshold {idle_days}"),
1347        ),
1348    }
1349
1350    let min_size_mb = per_repo
1351        .as_ref()
1352        .and_then(|c| c.min_size_mb)
1353        .unwrap_or(registry.settings.min_size_mb);
1354    f.note(
1355        "Size floor",
1356        &if min_size_mb == 0 {
1357            "none — every recognised directory counts".to_string()
1358        } else {
1359            format!("{min_size_mb} MiB")
1360        },
1361    );
1362
1363    let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
1364    f.note("Scan depth", &format!("{depth} levels below the root"));
1365
1366    RepoContext {
1367        path: path.to_path_buf(),
1368        is_git,
1369        registered: entry.is_some(),
1370        opted_out,
1371        config_broken,
1372        idle,
1373        idle_days,
1374        min_size_bytes: min_size_mb.saturating_mul(BYTES_PER_MIB),
1375        depth,
1376    }
1377}
1378
1379/// One project's worth of findings, kept so the verdict can reason over all of them.
1380struct ProjectReport {
1381    /// Whether any bloat directory here is above the floor and not symlinked.
1382    prunable: bool,
1383    /// Whether anything was found at all.
1384    has_bloat: bool,
1385}
1386
1387fn check_repo_projects(f: &mut Findings, path: &Path, ctx: &RepoContext) -> Vec<ProjectReport> {
1388    f.section("Projects");
1389
1390    if ctx.config_broken {
1391        f.note(
1392            "",
1393            "not scanned — the configuration above has to parse first",
1394        );
1395        return Vec::new();
1396    }
1397
1398    let projects = workspace::discover_to_depth(path, ctx.depth);
1399    if projects.is_empty() {
1400        f.note(
1401            "",
1402            &format!(
1403                "no recognised package-manager project within {} levels. \
1404                 Raise it with `devp config set scan_depth N`.",
1405                ctx.depth
1406            ),
1407        );
1408        return Vec::new();
1409    }
1410
1411    let mut reports = Vec::new();
1412    for project in &projects {
1413        for adapter in &project.adapters {
1414            println!();
1415            println!("  {} ({})", project.relative.bold(), adapter.name());
1416
1417            // Presence only. Proving a lockfile is *usable* means running the package
1418            // manager, which is minutes of work and, for cargo and go, a write.
1419            let missing: Vec<&str> = adapter
1420                .lockfiles()
1421                .iter()
1422                .copied()
1423                .filter(|n| !project.path.join(n).exists())
1424                .collect();
1425            match (adapter.lockfiles().is_empty(), missing.is_empty()) {
1426                (true, _) => f.note("    Lockfile", "no single file identifies this manager"),
1427                (false, true) => f.ok(
1428                    "    Lockfile",
1429                    &format!("{} present", adapter.lockfiles().join(", ")),
1430                ),
1431                // Every listed file absent — for bun, whose two spellings are
1432                // alternatives, that means neither is there.
1433                (false, false) if missing.len() == adapter.lockfiles().len() => f.problem(
1434                    "    Lockfile",
1435                    &format!(
1436                        "{} missing — nothing can prove the directory is rebuildable, \
1437                         so it will never be pruned",
1438                        missing.join(" / ")
1439                    ),
1440                ),
1441                (false, false) => f.ok(
1442                    "    Lockfile",
1443                    &format!(
1444                        "{} present",
1445                        adapter
1446                            .lockfiles()
1447                            .iter()
1448                            .filter(|n| !missing.contains(n))
1449                            .copied()
1450                            .collect::<Vec<_>>()
1451                            .join(", ")
1452                    ),
1453                ),
1454            }
1455
1456            let bloat = adapter.bloat_dirs(&project.path);
1457            if bloat.is_empty() {
1458                f.note("    Bloat", "nothing installed — nothing to reclaim");
1459                reports.push(ProjectReport {
1460                    prunable: false,
1461                    has_bloat: false,
1462                });
1463                continue;
1464            }
1465
1466            let mut prunable = false;
1467            for bd in &bloat {
1468                let label = workspace::relative_label(path, &bd.path);
1469                let size = output::format_bytes(bd.size_bytes);
1470
1471                if std::fs::symlink_metadata(&bd.path)
1472                    .map(|m| m.file_type().is_symlink())
1473                    .unwrap_or(false)
1474                {
1475                    f.warn(
1476                        "    Bloat",
1477                        &format!(
1478                            "{label} ({size}) is a symlink — refused, because the storage \
1479                             it points at is not this repository's to delete"
1480                        ),
1481                    );
1482                } else if bd.size_bytes < ctx.min_size_bytes {
1483                    f.warn(
1484                        "    Bloat",
1485                        &format!("{label} ({size}) is below the size floor — left alone"),
1486                    );
1487                } else {
1488                    f.ok("    Bloat", &format!("{label} ({size})"));
1489                    prunable = true;
1490                }
1491            }
1492            reports.push(ProjectReport {
1493                prunable,
1494                has_bloat: true,
1495            });
1496        }
1497    }
1498
1499    reports
1500}
1501
1502/// Name the one reason this repository would not be pruned right now.
1503///
1504/// In the order the prune pass applies them, so the answer matches what `devp run` would
1505/// actually do rather than listing everything that happens to be true.
1506fn repo_verdict(ctx: &RepoContext, projects: &[ProjectReport]) -> String {
1507    let clean = output::clean_path(&ctx.path);
1508    let no = |detail: String| format!("{} Would `devp run` prune this? {detail}", "✗".red());
1509
1510    if !ctx.is_git {
1511        no("No — not a Git repository. Nothing else is even checked.".to_string())
1512    } else if ctx.config_broken {
1513        no(format!(
1514            "No — `{}` does not parse, and dev-prune will not guess at a config it \
1515             cannot read.",
1516            constants::PER_REPO_CONFIG_FILE
1517        ))
1518    } else if let Some(why) = &ctx.opted_out {
1519        no(format!("No — opted out: {why}."))
1520    } else if !ctx.registered {
1521        no(format!(
1522            "Not in a full pass — it is not registered. `devp link {clean}` adds it; \
1523             `devp run {clean}` prunes it once without registering."
1524        ))
1525    } else if !ctx.idle {
1526        no(format!(
1527            "No — active within the last {} {}. `devp --ignore-idle run {clean}` overrides \
1528             exactly that check and nothing else.",
1529            ctx.idle_days,
1530            output::plural(ctx.idle_days as usize, "day", "days")
1531        ))
1532    } else if projects.is_empty() {
1533        no("No — no package-manager project was found to prune.".to_string())
1534    } else if !projects.iter().any(|p| p.has_bloat) {
1535        no("No — every project here is already clean.".to_string())
1536    } else if !projects.iter().any(|p| p.prunable) {
1537        no("No — everything found is symlinked or below the size floor. See above.".to_string())
1538    } else {
1539        format!(
1540            "{} Would `devp run` prune this? Yes — subject to each lockfile verifying. \
1541             `devp run {clean} --dry-run` lists what would go.",
1542            "✓".green()
1543        )
1544    }
1545}
1546
1547/// One line describing what a `.devprune.json` actually overrides.
1548fn describe_overrides(cfg: &PerRepoConfig) -> String {
1549    let mut parts = Vec::new();
1550    if let Some(name) = &cfg.project_name {
1551        parts.push(format!("name={name}"));
1552    }
1553    if let Some(days) = cfg.override_idle_days {
1554        parts.push(format!("idle_days={days}"));
1555    }
1556    if let Some(mb) = cfg.min_size_mb {
1557        parts.push(format!("min_size_mb={mb}"));
1558    }
1559    if let Some(depth) = cfg.scan_depth {
1560        parts.push(format!("scan_depth={depth}"));
1561    }
1562    if cfg.ignore {
1563        parts.push("ignore=true".to_string());
1564    }
1565    if cfg.disable_hooks {
1566        parts.push("disable_hooks=true".to_string());
1567    }
1568    if cfg.disable_daemon {
1569        parts.push("disable_daemon=true".to_string());
1570    }
1571    if parts.is_empty() {
1572        "parses; overrides nothing".to_string()
1573    } else {
1574        format!("parses; {}", parts.join(", "))
1575    }
1576}
1577
1578/// The innermost cause of an error, which is the part that says what is actually wrong.
1579///
1580/// `anyhow`'s `{:#}` prints the whole chain, and the outer links here are all "failed to
1581/// parse the registry at <path>" — which the report has already said.
1582fn root_cause(e: &anyhow::Error) -> String {
1583    e.chain().last().map(|c| c.to_string()).unwrap_or_default()
1584}
1585
1586/// Replace every registered repository's unreadable `.devprune.json` with a default.
1587///
1588/// The broken file is never destroyed: it is renamed to `.devprune.json.broken`
1589/// (numbered if that name is taken) beside the fresh one, because it may hold overrides
1590/// the user meant — an `"ignore": true` with a trailing comma is still a decision, and
1591/// the person who typed it is the only one who can retype it. The rename-then-write
1592/// order means a failure between the two leaves the repository with no config at all —
1593/// which is defaults, the same thing the fresh file says.
1594fn heal_repo_configs() -> Result<usize> {
1595    let registry = Registry::load()?;
1596    let mut healed = 0usize;
1597    for repo in registry.repositories.keys() {
1598        if !repo.exists() || PerRepoConfig::load_with_diagnostics(repo).is_ok() {
1599            continue;
1600        }
1601        let file = repo.join(constants::PER_REPO_CONFIG_FILE);
1602        let mut backup_name = format!("{}.broken", constants::PER_REPO_CONFIG_FILE);
1603        let mut n = 1;
1604        while repo.join(&backup_name).exists() {
1605            n += 1;
1606            backup_name = format!("{}.broken-{n}", constants::PER_REPO_CONFIG_FILE);
1607        }
1608        let backup = repo.join(&backup_name);
1609        std::fs::rename(&file, &backup).with_context(|| {
1610            format!(
1611                "could not move the broken config aside: {}",
1612                output::clean_path(&file)
1613            )
1614        })?;
1615        PerRepoConfig::default()
1616            .save_to_repo(repo)
1617            .with_context(|| {
1618                format!(
1619                    "could not write a default config in {}",
1620                    output::clean_path(repo)
1621                )
1622            })?;
1623        let _ = crate::config::ensure_in_git_exclude(repo, &backup_name);
1624        output::print_info(&format!(
1625            "{}: broken config kept as `{}`, defaults written",
1626            output::clean_path(repo),
1627            backup_name
1628        ));
1629        healed += 1;
1630    }
1631    Ok(healed)
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636    use super::*;
1637    use tempfile::TempDir;
1638
1639    #[test]
1640    fn a_stale_copy_names_the_command_that_removes_it() {
1641        // The finding exists so the user can act on it without first remembering how
1642        // the copy got there, which is exactly what they have forgotten.
1643        let line = stale_copy_line(
1644            Path::new("/usr/local/bin/dev-prune"),
1645            (1, 6, 0),
1646            Channel::Cargo,
1647        );
1648        assert!(line.contains("v1.6.0"), "{line}");
1649        assert!(line.contains("cargo uninstall dev-prune"), "{line}");
1650    }
1651
1652    #[test]
1653    fn a_copy_from_the_install_script_is_removed_by_devp_itself() {
1654        // `Channel::uninstall_command` is None here for a good reason -- there is no
1655        // manager to ask -- but None must not come out as silence.
1656        let line = stale_copy_line(
1657            Path::new("/home/a/.dev-prune/bin/dev-prune"),
1658            (1, 5, 0),
1659            Channel::Installer,
1660        );
1661        assert!(line.contains("devp uninstall"), "{line}");
1662    }
1663
1664    #[test]
1665    fn a_copy_nothing_owns_says_so_rather_than_naming_a_command() {
1666        // A file somebody copied into place by hand. Inventing a manager command for it
1667        // would send the user to run something that reports the package is not installed.
1668        let line = stale_copy_line(Path::new("/opt/dev-prune"), (0, 9, 1), Channel::Unknown);
1669        assert!(line.contains("delete the file yourself"), "{line}");
1670        assert!(!line.contains("uninstall dev-prune"), "{line}");
1671    }
1672
1673    #[test]
1674    fn an_integration_pointing_at_a_deleted_binary_is_a_problem_not_a_warning() {
1675        // The whole point of the check: this is broken, not merely worth knowing, so it
1676        // has to reach the non-zero exit code.
1677        let mut f = Findings::default();
1678        report_integration_target(
1679            &mut f,
1680            "Scheduler",
1681            "installed",
1682            Some(PathBuf::from("/nonexistent/dev-prune")),
1683            "Re-register it.",
1684        );
1685        assert_eq!(f.warnings.len(), 0);
1686        assert_eq!(f.problems.len(), 1);
1687        assert!(f.problems[0].contains("no longer exists"));
1688    }
1689
1690    #[test]
1691    fn an_integration_whose_binary_is_present_passes() {
1692        let tmp = TempDir::new().unwrap();
1693        let exe = tmp.path().join("dev-prune");
1694        std::fs::write(&exe, b"binary").unwrap();
1695
1696        let mut f = Findings::default();
1697        report_integration_target(&mut f, "Scheduler", "installed", Some(exe), "Re-register.");
1698        assert!(f.problems.is_empty() && f.warnings.is_empty());
1699    }
1700
1701    #[test]
1702    fn an_unreadable_entry_is_not_reported_as_broken() {
1703        // `None` means the platform could not tell us, which is not evidence of a
1704        // problem — reporting it would be a warning nobody can act on.
1705        let mut f = Findings::default();
1706        report_integration_target(&mut f, "Scheduler", "installed", None, "Re-register.");
1707        assert!(f.problems.is_empty() && f.warnings.is_empty());
1708    }
1709
1710    #[test]
1711    fn overrides_are_listed_by_name() {
1712        let mut cfg = PerRepoConfig::default();
1713        assert_eq!(describe_overrides(&cfg), "parses; overrides nothing");
1714
1715        cfg.override_idle_days = Some(30);
1716        cfg.ignore = true;
1717        assert_eq!(
1718            describe_overrides(&cfg),
1719            "parses; idle_days=30, ignore=true"
1720        );
1721    }
1722
1723    /// `min_size_mb: 0` is a value, not an absence — it opts a repository out of a global
1724    /// floor, so the report has to show it rather than treating it as unset.
1725    #[test]
1726    fn a_zero_floor_is_reported_as_an_override() {
1727        let cfg = PerRepoConfig {
1728            min_size_mb: Some(0),
1729            ..PerRepoConfig::default()
1730        };
1731        assert_eq!(describe_overrides(&cfg), "parses; min_size_mb=0");
1732    }
1733
1734    #[test]
1735    fn a_repository_that_is_not_a_git_repo_is_the_first_thing_reported() {
1736        let dir = TempDir::new().unwrap();
1737        let ctx = RepoContext {
1738            path: dir.path().to_path_buf(),
1739            is_git: false,
1740            registered: false,
1741            opted_out: Some("ignore.devprune.json is present".to_string()),
1742            config_broken: true,
1743            idle: true,
1744            idle_days: 15,
1745            min_size_bytes: 0,
1746            depth: 6,
1747        };
1748        // Three reasons are true at once; the verdict names the one the prune pass would
1749        // hit first, which is the one the user has to fix before any other matters.
1750        let line = repo_verdict(&ctx, &[]);
1751        assert!(line.contains("not a Git repository"), "{line}");
1752    }
1753
1754    #[test]
1755    fn warnings_alone_do_not_fail_the_command() {
1756        let mut f = Findings::default();
1757        f.warn("Scheduler", "not installed");
1758        assert!(verdict(&f, "fine", None).is_ok());
1759
1760        f.problem("PATH", "missing");
1761        assert!(verdict(&f, "fine", None).is_err());
1762    }
1763
1764    #[test]
1765    #[cfg(windows)]
1766    fn a_path_entry_matches_regardless_of_case_and_trailing_separator() {
1767        // Both differences are ones Windows itself ignores, and either used to turn
1768        // into a "not on PATH" problem — a healthy install failing `devp doctor`.
1769        let dir = Path::new(r"C:\Users\Someone\AppData\Roaming\dev-prune\bin");
1770        assert!(same_dir(
1771            Path::new(r"c:\users\someone\appdata\roaming\dev-prune\bin\"),
1772            dir
1773        ));
1774        assert!(!same_dir(Path::new(r"C:\Windows"), dir));
1775    }
1776
1777    #[test]
1778    #[cfg(not(windows))]
1779    fn a_path_entry_on_unix_is_matched_exactly() {
1780        assert!(same_dir(
1781            Path::new("/usr/local/bin"),
1782            Path::new("/usr/local/bin")
1783        ));
1784        assert!(!same_dir(
1785            Path::new("/USR/local/bin"),
1786            Path::new("/usr/local/bin")
1787        ));
1788    }
1789
1790    #[test]
1791    fn a_stale_twin_is_told_apart_from_a_current_one() {
1792        let dir = TempDir::new().unwrap();
1793        let a = dir.path().join("dev-prune");
1794        let b = dir.path().join("devp");
1795        std::fs::write(&a, b"version two").unwrap();
1796        std::fs::write(&b, b"version two").unwrap();
1797        assert!(same_binary(&a, &b));
1798
1799        // Same length, different bytes — the case a size-only test waves through.
1800        std::fs::write(&b, b"version one").unwrap();
1801        assert!(!same_binary(&a, &b));
1802
1803        std::fs::write(&b, b"short").unwrap();
1804        assert!(!same_binary(&a, &b));
1805        assert!(!same_binary(&a, &dir.path().join("missing")));
1806    }
1807
1808    #[test]
1809    fn the_channel_directories_are_searched_even_when_they_are_not_on_path() {
1810        // The whole point of this check: a copy nobody can see is a copy nobody
1811        // upgrades, and it becomes the one that runs the day PATH changes.
1812        let home = Path::new(if cfg!(windows) {
1813            "C:\\home\\u"
1814        } else {
1815            "/home/u"
1816        });
1817        let dirs = copy_search_dirs("", Some(home));
1818        let joined = dirs
1819            .iter()
1820            .map(|d| d.to_string_lossy().to_lowercase())
1821            .collect::<Vec<_>>()
1822            .join("|");
1823        for marker in ["cargo", "uv", "pipx"] {
1824            assert!(
1825                joined.contains(marker),
1826                "{marker} directory missing from {joined}"
1827            );
1828        }
1829    }
1830
1831    #[test]
1832    fn path_entries_are_searched_and_empty_ones_dropped() {
1833        let sep = if cfg!(windows) { ";" } else { ":" };
1834        let a = if cfg!(windows) { "C:\\a" } else { "/a" };
1835        let b = if cfg!(windows) { "C:\\b" } else { "/b" };
1836        let dirs = copy_search_dirs(&format!("{a}{sep}{sep}{b}"), None);
1837        assert_eq!(dirs, vec![PathBuf::from(a), PathBuf::from(b)]);
1838    }
1839
1840    #[test]
1841    fn the_managed_directory_is_never_reported_as_another_copy() {
1842        // Its three files are each reported by name elsewhere; listing them here would
1843        // tell a healthy install it has stale binaries.
1844        let tmp = tempfile::tempdir().expect("temp dir");
1845        let managed = tmp.path().join("bin");
1846        std::fs::create_dir_all(&managed).expect("create");
1847        let name = if cfg!(windows) {
1848            "dev-prune.exe"
1849        } else {
1850            "dev-prune"
1851        };
1852        std::fs::write(managed.join(name), b"binary").expect("write");
1853
1854        assert!(binaries_in(std::slice::from_ref(&managed), Some(&managed)).is_empty());
1855        assert_eq!(binaries_in(std::slice::from_ref(&managed), None).len(), 1);
1856    }
1857
1858    #[test]
1859    fn one_binary_under_both_names_is_reported_once() {
1860        // `dev-prune` and `devp` in the same directory are the same binary — on Windows
1861        // usually literally the same file — so a single install must not read as two.
1862        let tmp = tempfile::tempdir().expect("temp dir");
1863        let dir = tmp.path().to_path_buf();
1864        let (a, b) = if cfg!(windows) {
1865            ("dev-prune.exe", "devp.exe")
1866        } else {
1867            ("dev-prune", "devp")
1868        };
1869        std::fs::write(dir.join(a), b"same bytes").expect("write");
1870        std::fs::write(dir.join(b), b"same bytes").expect("write");
1871        assert_eq!(binaries_in(std::slice::from_ref(&dir), None).len(), 1);
1872
1873        // A genuinely different binary under the second name is a second copy.
1874        std::fs::write(dir.join(b), b"a different build").expect("write");
1875        assert_eq!(binaries_in(&[dir], None).len(), 2);
1876    }
1877}