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::path::{Path, PathBuf};
27
28use anyhow::{Context, Result};
29use chrono::Utc;
30
31use crate::adapters;
32use crate::commands::hook::{self, HookState};
33use crate::config::{PerRepoConfig, Registry};
34use crate::constants;
35use crate::daemon;
36use crate::engine::{self, BYTES_PER_MIB, SkipReason};
37use crate::output;
38use crate::scanner::{self, git};
39use crate::setup;
40use crate::workspace;
41
42/// One repair `--fix` knows how to make. Every variant mends something that is already
43/// installed but broken; none of them installs an integration for the first time.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum Repair {
46    /// The `dev-prune`/`devp` pair is missing a member, or the alias is stale.
47    Twin,
48    /// The `SKILL.md` export is missing or out of date.
49    SkillFile,
50    /// Installed hooks point at a deleted binary, or a chain has drifted.
51    Hooks,
52    /// The installed scheduler points at a deleted binary.
53    Scheduler,
54    /// Registered paths that no longer exist on disk.
55    UnlinkMissing,
56}
57
58/// Tally of everything the report flagged, so the verdict is derived from the same
59/// lines the user just read rather than recomputed from scratch.
60#[derive(Default)]
61struct Findings {
62    warnings: Vec<String>,
63    problems: Vec<String>,
64    /// Repairs `--fix` would make, in the order their findings were reported.
65    fixes: Vec<Repair>,
66    /// Indices into `problems` that one of `fixes` addresses, so the repair verdict can
67    /// tell "fixed by the pass that just ran" apart from "still needs a human".
68    fixable_problems: Vec<usize>,
69    /// The subset of `fixes` whose finding was a problem rather than a warning. A repair
70    /// from this list that does not actually land leaves the problem standing, and the
71    /// exit code has to say so.
72    problem_repairs: Vec<Repair>,
73}
74
75impl Findings {
76    /// A check that passed.
77    fn ok(&mut self, label: &str, detail: &str) {
78        println!("  {label:<22} {} {detail}", "✓".green());
79    }
80
81    /// Something to be aware of that is not stopping anything working.
82    fn warn(&mut self, label: &str, detail: &str) {
83        println!("  {label:<22} {} {detail}", "!".yellow());
84        self.warnings.push(format!("{label}: {detail}"));
85    }
86
87    /// Something that is actually broken.
88    fn problem(&mut self, label: &str, detail: &str) {
89        println!("  {label:<22} {} {detail}", "✗".red());
90        self.problems.push(format!("{label}: {detail}"));
91    }
92
93    /// Record that the most recent `warn` is one `--fix` can repair.
94    fn fixable(&mut self, repair: Repair) {
95        if !self.fixes.contains(&repair) {
96            self.fixes.push(repair);
97        }
98    }
99
100    /// Record that the most recent `problem` is one `--fix` can repair.
101    ///
102    /// Called immediately after the `problem` it belongs to, so the index bookkeeping
103    /// stays next to the finding it describes.
104    fn fixable_problem(&mut self, repair: Repair) {
105        self.fixable(repair);
106        if !self.problem_repairs.contains(&repair) {
107            self.problem_repairs.push(repair);
108        }
109        if let Some(last) = self.problems.len().checked_sub(1) {
110            if !self.fixable_problems.contains(&last) {
111                self.fixable_problems.push(last);
112            }
113        }
114    }
115
116    /// A fact with no verdict attached.
117    fn note(&self, label: &str, detail: &str) {
118        println!("  {label:<22}   {detail}");
119    }
120
121    fn section(&self, title: &str) {
122        println!();
123        println!("{}", title.bold());
124    }
125}
126
127// `colored` is used through these three, so the trait import stays local to the file.
128use colored::Colorize as _;
129
130/// Run the `doctor` command.
131///
132/// `path` is whatever the user typed, already tilde-expanded. `None` means the global
133/// installation; `Some(".")` is an ordinary path like any other. `fix` applies the
134/// repairs the installation check found; clap refuses `--fix` alongside a path, because
135/// the repository check has nothing it could safely repair.
136pub fn run(path: Option<&str>, fix: bool) -> Result<()> {
137    match path {
138        Some(p) => check_repository(p),
139        None => check_installation(fix),
140    }
141}
142
143/// Print the verdict and pick the exit code.
144///
145/// Warnings exit `0`. A doctor that fails the build because the scheduler is not
146/// installed is a doctor people stop running; only the things that stop dev-prune doing
147/// its job are worth a non-zero status.
148fn verdict(f: &Findings, all_clear: &str, headline: Option<&str>) -> Result<()> {
149    f.section("Verdict");
150
151    if let Some(line) = headline {
152        println!("  {line}");
153        println!();
154    }
155
156    if f.problems.is_empty() && f.warnings.is_empty() {
157        output::print_success(all_clear);
158        return Ok(());
159    }
160
161    for w in &f.warnings {
162        println!("  {} {w}", "!".yellow());
163    }
164    for p in &f.problems {
165        println!("  {} {p}", "✗".red());
166    }
167
168    if !f.fixes.is_empty() {
169        println!();
170        println!(
171            "  {} of these can be repaired automatically — run `devp doctor --fix`.",
172            f.fixes.len()
173        );
174    }
175
176    println!();
177    println!("  Troubleshooting: {}", constants::TROUBLESHOOTING_URL);
178
179    if f.problems.is_empty() {
180        println!();
181        output::print_info(&format!(
182            "{} {} — nothing broken.",
183            f.warnings.len(),
184            output::plural(f.warnings.len(), "warning", "warnings")
185        ));
186        return Ok(());
187    }
188
189    anyhow::bail!(
190        "{} {} found.",
191        f.problems.len(),
192        output::plural(f.problems.len(), "problem", "problems")
193    )
194}
195
196// ---------------------------------------------------------------------------
197// Global installation
198// ---------------------------------------------------------------------------
199
200fn check_installation(fix: bool) -> Result<()> {
201    output::print_header("dev-prune doctor");
202    let mut f = Findings::default();
203
204    check_binary(&mut f);
205    let registry = check_configuration(&mut f);
206    check_integrations(&mut f, registry.as_ref());
207    check_package_managers(&mut f, registry.as_ref());
208    check_registry_health(&mut f, registry.as_ref());
209    check_release_state(&mut f, registry.as_ref());
210
211    if fix && !f.fixes.is_empty() {
212        return apply_repairs(&f, registry.as_ref());
213    }
214    if fix {
215        // `--fix` with nothing repairable falls through to the ordinary verdict: what
216        // remains (if anything) needs a human, and saying which things is the verdict's
217        // whole job.
218        return verdict(&f, "Everything checks out — nothing to repair.", None);
219    }
220    verdict(&f, "Everything checks out.", None)
221}
222
223/// Apply every repair the diagnosis recorded, then give the repair verdict.
224///
225/// Each repair goes through the same `setup::ensure_*` passes the automatic setup uses,
226/// so a repair can never do something the setup pass would not — and like that pass,
227/// each one re-checks the state itself before touching anything, so a finding that
228/// healed between diagnosis and repair reports "already in place" rather than being
229/// re-applied.
230///
231/// `DEV_PRUNE_NO_AUTO_SETUP` disables every self-installation path, and the repairs
232/// that write outside the config directory — the twin binary, the git hooks, the OS
233/// scheduler — are exactly that, so with the variable set they are skipped and named.
234/// Bookkeeping inside dev-prune's own config directory (the `SKILL.md` export, dead
235/// registry entries) is not an installation and still runs.
236///
237/// Exit code: `0` unless a repair failed outright, a *problem*-level finding was left
238/// unrepaired, or a problem remains that no repair addresses. A skipped repair whose
239/// finding was only a warning (the twin is a running executable) exits `0` like any
240/// other warning — the report says what to do, and nothing is more broken than it
241/// already was.
242fn apply_repairs(f: &Findings, registry: Option<&Registry>) -> Result<()> {
243    f.section("Repairs");
244
245    let ok = |label: &str, detail: &str| println!("  {label:<22} {} {detail}", "✓".green());
246    let skipped = |label: &str, detail: &str| println!("  {label:<22} {} {detail}", "!".yellow());
247    let failed_line = |label: &str, detail: &str| println!("  {label:<22} {} {detail}", "✗".red());
248
249    let chain = registry
250        .map(|r| r.settings.auto_hooks_chain)
251        .unwrap_or(false);
252    let interval = registry
253        .map(|r| r.settings.check_interval_days)
254        .unwrap_or(constants::DEFAULT_CHECK_INTERVAL_DAYS);
255    let installs_off = setup::no_auto_setup_requested();
256
257    let mut repaired = 0usize;
258    let mut failures = 0usize;
259    let mut attention = 0usize;
260    // Problems whose repair was skipped rather than failed. Failures already count
261    // toward the exit code; a skipped problem has to as well, because the breakage the
262    // diagnosis reported is still there.
263    let mut skipped_problems = 0usize;
264
265    for repair in &f.fixes {
266        let is_problem = f.problem_repairs.contains(repair);
267        let (label, manual) = match repair {
268            Repair::Twin => ("Binary pair", "run `dev-prune setup` yourself"),
269            Repair::SkillFile => ("SKILL.md", "run `devp skill` yourself"),
270            Repair::Hooks => ("Git hooks", "run `devp hook install` yourself"),
271            Repair::Scheduler => ("Scheduler", "run `devp daemon install` yourself"),
272            Repair::UnlinkMissing => {
273                match crate::commands::link::run_unlink_missing() {
274                    Ok(()) => repaired += 1,
275                    Err(e) => {
276                        failed_line("Registry", &format!("{e:#}"));
277                        failures += 1;
278                    }
279                }
280                continue;
281            }
282        };
283        // These three write outside the config directory, which is precisely what the
284        // variable exists to forbid.
285        if installs_off && matches!(repair, Repair::Twin | Repair::Hooks | Repair::Scheduler) {
286            skipped(
287                label,
288                &format!("{} is set — {manual}", setup::ENV_NO_AUTO_SETUP),
289            );
290            attention += 1;
291            if is_problem {
292                skipped_problems += 1;
293            }
294            continue;
295        }
296        let outcome = match repair {
297            Repair::Twin => setup::ensure_alias(),
298            Repair::SkillFile => setup::ensure_skill_file(),
299            Repair::Hooks => setup::ensure_hooks(chain),
300            Repair::Scheduler => setup::ensure_daemon(interval),
301            Repair::UnlinkMissing => unreachable!("handled above"),
302        };
303        match outcome {
304            setup::Outcome::Installed => {
305                ok(label, "repaired");
306                repaired += 1;
307            }
308            setup::Outcome::AlreadyPresent => {
309                ok(label, "already in place");
310                repaired += 1;
311            }
312            setup::Outcome::Skipped(why) => {
313                skipped(label, &why);
314                attention += 1;
315                if is_problem {
316                    skipped_problems += 1;
317                }
318            }
319            setup::Outcome::Failed(why) => {
320                failed_line(label, &why);
321                failures += 1;
322            }
323        }
324    }
325
326    f.section("Verdict");
327    let unfixable: Vec<&String> = f
328        .problems
329        .iter()
330        .enumerate()
331        .filter(|(i, _)| !f.fixable_problems.contains(i))
332        .map(|(_, p)| p)
333        .collect();
334    for p in &unfixable {
335        println!("  {} {p} (not auto-repairable)", "✗".red());
336    }
337    if !unfixable.is_empty() {
338        println!();
339        println!("  Troubleshooting: {}", constants::TROUBLESHOOTING_URL);
340    }
341    println!();
342    output::print_info(&format!(
343        "{repaired} repaired, {attention} skipped, {failures} failed. \
344         Run `devp doctor` to confirm."
345    ));
346
347    let unresolved = failures + skipped_problems + unfixable.len();
348    if unresolved > 0 {
349        anyhow::bail!(
350            "{unresolved} {} could not be repaired.",
351            output::plural(unresolved, "finding", "findings")
352        );
353    }
354    Ok(())
355}
356
357fn check_binary(f: &mut Findings) {
358    f.section("Installation");
359    f.note("Version", constants::VERSION);
360
361    let Ok(exe) = std::env::current_exe() else {
362        f.warn("Executable", "the running binary's own path is unavailable");
363        return;
364    };
365    f.note("Executable", &output::clean_path(&exe));
366
367    let Some(dir) = exe.parent() else { return };
368
369    // The pair is one binary under two names, and either one can be the one running
370    // right now. Looking for `devp` unconditionally made this check vacuous whenever
371    // the user typed `devp doctor` — the file being looked for was the file doing the
372    // looking, so it always "passed". Look for whichever name is *not* running.
373    let running = if exe
374        .file_stem()
375        .and_then(|s| s.to_str())
376        .is_some_and(|stem| stem.eq_ignore_ascii_case("devp"))
377    {
378        "devp"
379    } else {
380        "dev-prune"
381    };
382    let twin_stem = if running == "devp" {
383        "dev-prune"
384    } else {
385        "devp"
386    };
387    let twin = dir.join(if cfg!(windows) {
388        format!("{twin_stem}.exe")
389    } else {
390        twin_stem.to_string()
391    });
392    if !twin.exists() {
393        f.warn(
394            twin_stem,
395            &format!("not installed next to {running} — run `{running} setup`"),
396        );
397        // Either name may recreate a twin that is missing outright.
398        f.fixable(Repair::Twin);
399    } else if same_binary(&exe, &twin) {
400        f.ok(twin_stem, &output::clean_path(&twin));
401    } else {
402        // An upgrade that could not replace a running executable leaves exactly this
403        // state, and the stale name silently runs the previous version from then on.
404        f.warn(
405            twin_stem,
406            &format!(
407                "{} is not the same binary as {} — one of the pair is stale and \
408                 silently runs a different version. `dev-prune setup` refreshes `devp` \
409                 from the canonical `dev-prune`.",
410                output::clean_path(&twin),
411                output::clean_path(&exe)
412            ),
413        );
414        // Only the canonical `dev-prune` may overwrite a differing twin — `devp`
415        // refreshing `dev-prune` could reinstall the version an upgrade just replaced.
416        // So this is repairable only from the canonical side.
417        if running == "dev-prune" {
418            f.fixable(Repair::Twin);
419        }
420    }
421
422    let sep = if cfg!(windows) { ';' } else { ':' };
423    let on_path = std::env::var("PATH")
424        .unwrap_or_default()
425        .split(sep)
426        .any(|p| !p.is_empty() && same_dir(Path::new(p), dir));
427    if on_path {
428        f.ok("PATH", &output::clean_path(dir));
429    } else {
430        f.problem(
431            "PATH",
432            &format!(
433                "{} is not on PATH — `devp` will not resolve in a new shell",
434                output::clean_path(dir)
435            ),
436        );
437    }
438}
439
440/// Whether a PATH entry names the directory the binary lives in.
441///
442/// Windows paths are case-insensitive but `Path` equality is not, and PATH entries
443/// routinely carry a trailing backslash the installer never wrote. Either mismatch made
444/// doctor report a perfectly good install as "not on PATH" — as a problem, so `devp
445/// doctor` exited 1 on a healthy machine.
446fn same_dir(entry: &Path, dir: &Path) -> bool {
447    if entry == dir {
448        return true;
449    }
450    cfg!(windows) && {
451        let norm = |p: &Path| {
452            p.to_string_lossy()
453                .trim_end_matches(['\\', '/'])
454                .to_lowercase()
455        };
456        norm(entry) == norm(dir)
457    }
458}
459
460/// Whether two files hold the same bytes.
461///
462/// Doctor runs at human speed, so when the cheap size test cannot rule the pair
463/// different this reads both files outright — a stale twin left by a failed upgrade can
464/// share a size with its replacement, and "same version" is the whole question here.
465fn same_binary(a: &Path, b: &Path) -> bool {
466    let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
467        return false;
468    };
469    if ma.len() != mb.len() {
470        return false;
471    }
472    matches!((std::fs::read(a), std::fs::read(b)), (Ok(ba), Ok(bb)) if ba == bb)
473}
474
475/// Read the config directory and validate every stored setting.
476///
477/// Returns `None` when the registry cannot be read, which is the one failure that makes
478/// every later section meaningless — they all need the settings.
479fn check_configuration(f: &mut Findings) -> Option<Registry> {
480    f.section("Configuration");
481
482    let dir = match Registry::config_dir() {
483        Ok(d) => d,
484        Err(e) => {
485            f.problem("Config directory", &format!("cannot be resolved: {e}"));
486            return None;
487        }
488    };
489    f.note("Config directory", &output::clean_path(&dir));
490    if std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE).is_ok() {
491        f.note(
492            "",
493            &format!("(set by {})", constants::ENV_CONFIG_DIR_OVERRIDE),
494        );
495    }
496
497    let path = dir.join(constants::REGISTRY_FILENAME);
498    if !path.exists() {
499        // Not a fault. Absent configuration means defaults, which is the documented
500        // behaviour — it is an unreadable one that dev-prune refuses to guess about.
501        f.ok("registry.json", "not created yet — defaults apply");
502        return Some(Registry::default());
503    }
504
505    let registry = match Registry::load_from(&path) {
506        Ok(r) => r,
507        Err(e) => {
508            f.problem(
509                "registry.json",
510                &format!(
511                    "{} — dev-prune refuses to guess at a config it cannot read. \
512                     Fix the syntax, or delete the file to start from defaults.",
513                    root_cause(&e)
514                ),
515            );
516            return None;
517        }
518    };
519
520    f.ok(
521        "registry.json",
522        &format!(
523            "readable — {} {} registered",
524            registry.repo_count(),
525            output::plural(registry.repo_count(), "repository", "repositories")
526        ),
527    );
528
529    let invalid = crate::commands::config::invalid_settings(&registry.settings);
530    if invalid.is_empty() {
531        f.ok(
532            "Settings",
533            &format!(
534                "all {} within range",
535                crate::commands::config::setting_count()
536            ),
537        );
538    } else {
539        for (key, why) in &invalid {
540            f.problem("Settings", &format!("{key}: {why}"));
541        }
542    }
543
544    Some(registry)
545}
546
547fn check_integrations(f: &mut Findings, registry: Option<&Registry>) {
548    f.section("Integrations");
549
550    match setup::skill_path() {
551        Ok(p) if p.exists() => f.ok("SKILL.md", &output::clean_path(&p)),
552        _ => {
553            f.warn("SKILL.md", "not exported — run `devp skill`");
554            f.fixable(Repair::SkillFile);
555        }
556    }
557
558    if crate::commands::icon::is_registered() {
559        f.ok("File icons", "registered with the file manager");
560    } else {
561        f.warn("File icons", "not registered — run `devp icon`");
562    }
563
564    if !hook::git_available() {
565        f.warn(
566            "Git hooks",
567            "git is not on PATH, so repositories cannot auto-register",
568        );
569    } else {
570        match hook::state() {
571            Ok(HookState::Active) => check_hook_target(f, "active"),
572            Ok(HookState::Absent) => f.warn("Git hooks", "not installed — run `devp hook install`"),
573            Ok(HookState::Chained { previous, drifted }) if drifted.is_empty() => {
574                check_hook_target(f, &format!("active, chained to `{previous}`"))
575            }
576            Ok(HookState::Chained { previous, drifted }) => {
577                f.warn(
578                    "Git hooks",
579                    &format!(
580                        "chained to `{previous}`, but {} not forwarded ({}) — \
581                         re-run `devp hook install --chain`",
582                        drifted.len(),
583                        drifted.join(", ")
584                    ),
585                );
586                // The chain is installed and merely out of date; reinstalling it is the
587                // same repair the automatic setup pass makes.
588                f.fixable(Repair::Hooks);
589            }
590            Ok(HookState::Foreign(p)) => f.warn(
591                "Git hooks",
592                &format!(
593                    "core.hooksPath belongs to `{p}` — install in front of it with \
594                     `devp hook install --chain`"
595                ),
596            ),
597            Err(e) => f.warn("Git hooks", &format!("state unknown ({e})")),
598        }
599    }
600
601    match daemon::daemon_status() {
602        Ok(daemon::DaemonStatus::Installed) => check_scheduler_target(f),
603        Ok(daemon::DaemonStatus::NotInstalled) => f.warn(
604            "Scheduler",
605            "not installed — nothing prunes on its own. `devp daemon install` adds it.",
606        ),
607        Ok(daemon::DaemonStatus::Unknown(why)) => f.warn("Scheduler", &why),
608        Err(e) => f.warn("Scheduler", &format!("state unknown ({e})")),
609    }
610
611    if let Some(r) = registry {
612        f.note(
613            "Automatic setup",
614            &format!(
615                "auto_setup={} auto_hooks={} auto_daemon={}",
616                r.settings.auto_setup, r.settings.auto_hooks, r.settings.auto_daemon
617            ),
618        );
619    }
620
621    // Said out loud, because "auto_setup = true" next to integrations that never install
622    // is a contradiction the user has no other way to explain.
623    if let Some(why) = setup::unattended_environment() {
624        f.note("", &format!("unattended installation is off because {why}"));
625    }
626    // The same presence test the suppression itself uses — `=true`, `=0` and even an
627    // empty value all switch setup off, so all of them must be reported here.
628    if setup::no_auto_setup_requested() {
629        f.note(
630            "",
631            &format!(
632                "{} is set — nothing installs by itself. `devp setup` still works.",
633                setup::ENV_NO_AUTO_SETUP
634            ),
635        );
636    }
637}
638
639/// Report an installed integration, and whether the binary it will run is still there.
640///
641/// An installed scheduler and an installed hook are both silent by construction — the
642/// scheduled task has no console and the hook throws its own output away — so a recorded
643/// path that has since been deleted produces no symptom whatsoever. Every interval, the
644/// task fails instantly; every commit, the hook does nothing. This is the only place that
645/// says so.
646///
647/// The path goes stale when the integration is installed from somewhere temporary:
648/// `npx dev-prune`, `uvx dev-prune`, or a `target/debug` build during development. Those
649/// no longer record the temporary path (see `setup::stable_exe_path`), but entries
650/// registered before that are still out there, and a user can always delete the binary
651/// out from under a perfectly ordinary install.
652fn report_integration_target(
653    f: &mut Findings,
654    label: &str,
655    installed: &str,
656    recorded: Option<std::path::PathBuf>,
657    repair: &str,
658) -> bool {
659    match recorded {
660        // Nothing to report: the entry is unreadable on this machine, which is not
661        // evidence of a problem. Saying so would be a warning nobody can act on.
662        None => f.ok(label, installed),
663        Some(path) if path.is_file() => f.ok(
664            label,
665            &format!("{installed} — {}", output::clean_path(&path)),
666        ),
667        Some(path) => {
668            f.problem(
669                label,
670                &format!(
671                    "registered, but `{}` no longer exists — it never runs. {repair}",
672                    output::clean_path(&path)
673                ),
674            );
675            return true;
676        }
677    }
678    false
679}
680
681fn check_scheduler_target(f: &mut Findings) {
682    if report_integration_target(
683        f,
684        "Scheduler",
685        "installed",
686        daemon::registered_exe_path(),
687        "Re-register it with `devp daemon install`.",
688    ) {
689        f.fixable_problem(Repair::Scheduler);
690    }
691}
692
693fn check_hook_target(f: &mut Findings, installed: &str) {
694    if report_integration_target(
695        f,
696        "Git hooks",
697        installed,
698        hook::registered_exe_path(),
699        "Rewrite them with `devp hook install`.",
700    ) {
701        f.fixable_problem(Repair::Hooks);
702    }
703}
704
705/// Check the package-manager binaries the registered repositories actually need.
706///
707/// Every adapter, not just the needed ones, would report `bun: not found` on a machine
708/// with no JavaScript on it at all — a warning about a tool the user has deliberately not
709/// installed. So the list comes from what is registered, and only falls back to all eight
710/// when nothing is registered yet and there is nothing else to go on.
711fn check_package_managers(f: &mut Findings, registry: Option<&Registry>) {
712    f.section("Package managers");
713
714    // `required` distinguishes a manager some registered repository actually depends on
715    // from one merely listed for completeness. Warning that `go` is absent on a machine
716    // with no Go project on it is noise the user cannot act on and would not want to.
717    let (needed, required): (Vec<String>, bool) = match registry {
718        Some(r) if r.repo_count() > 0 => {
719            let mut names: Vec<String> = engine::get_full_status(r)
720                .into_iter()
721                .flat_map(|e| e.adapters)
722                .collect();
723            names.sort();
724            names.dedup();
725            (names, true)
726        }
727        _ => (
728            adapters::get_all_adapters()
729                .iter()
730                .map(|a| a.name().to_string())
731                .collect(),
732            false,
733        ),
734    };
735
736    if needed.is_empty() {
737        f.note(
738            "",
739            "no package managers are needed by the registered repositories",
740        );
741        return;
742    }
743    if !required {
744        f.note("", "nothing is registered yet, so this is the full list");
745    }
746
747    for status in adapters::scan_required_binaries(&needed) {
748        match (status.available, status.version) {
749            (true, Some(v)) => f.ok(&status.name, &v),
750            (true, None) => f.ok(&status.name, "available"),
751            (false, _) if required => f.warn(
752                &status.name,
753                "not on PATH — projects using it cannot be verified, pruned or restored",
754            ),
755            (false, _) => f.note(&status.name, "not installed"),
756        }
757    }
758
759    // `venv` is filtered out of the binary scan because it is not a command; its restore
760    // path still needs an interpreter, and that is worth saying once.
761    if required && needed.iter().any(|n| n == "venv") && !adapters::binary_available("python") {
762        f.warn(
763            "python",
764            "not on PATH — `devp restore` cannot rebuild a plain virtual environment",
765        );
766    }
767}
768
769fn check_registry_health(f: &mut Findings, registry: Option<&Registry>) {
770    f.section("Registered repositories");
771
772    let Some(registry) = registry else { return };
773    if registry.repo_count() == 0 {
774        f.note("", "none yet — `devp init ~/Code` or `devp link .`");
775        return;
776    }
777
778    let entries = engine::get_full_status(registry);
779    let count = |want: &SkipReason| {
780        entries
781            .iter()
782            .filter(|e| std::mem::discriminant(&e.reason) == std::mem::discriminant(want))
783            .count()
784    };
785    let reclaimable: u64 = entries.iter().map(|e| e.reclaimable_bytes).sum();
786
787    f.note(
788        "Total",
789        &format!(
790            "{} registered, {} reclaimable",
791            entries.len(),
792            output::format_bytes(reclaimable)
793        ),
794    );
795    f.note(
796        "Breakdown",
797        &format!(
798            "{} candidates, {} active, {} ignored, {} with no bloat",
799            count(&SkipReason::Candidate),
800            count(&SkipReason::Active),
801            count(&SkipReason::Ignored),
802            count(&SkipReason::NoBloat),
803        ),
804    );
805
806    // A path that has gone is stale bookkeeping, not breakage: the pass reports it and
807    // moves on, so this warns rather than failing. Listing thirty of them individually
808    // buries every other finding, and thirty `devp unlink` lines is not a fix anyone will
809    // run — so they collapse to a count and the one command that clears all of them.
810    let missing: Vec<&Path> = entries
811        .iter()
812        .filter(|e| matches!(e.reason, SkipReason::PathMissing))
813        .map(|e| e.path.as_path())
814        .collect();
815
816    match missing.len() {
817        0 => {}
818        1 => {
819            f.warn(
820                "Missing",
821                &format!(
822                    "{} no longer exists — `devp unlink {}`",
823                    output::clean_path(missing[0]),
824                    output::clean_path(missing[0])
825                ),
826            );
827            f.fixable(Repair::UnlinkMissing);
828        }
829        n => {
830            f.warn(
831                "Missing",
832                &format!(
833                    "{n} registered paths no longer exist, starting with {} \
834                     — `devp unlink --missing` clears all of them",
835                    output::clean_path(missing[0])
836                ),
837            );
838            f.fixable(Repair::UnlinkMissing);
839        }
840    }
841
842    // An unreadable `.devprune.json` *is* breakage: the file that cannot be read may be
843    // the one saying `"ignore": true`, so the repository is skipped until it is fixed.
844    for entry in &entries {
845        if let SkipReason::ConfigError(e) = &entry.reason {
846            f.problem(
847                "Unreadable config",
848                &format!("{}: {e}", output::clean_path(&entry.path)),
849            );
850        }
851    }
852}
853
854fn check_release_state(f: &mut Findings, registry: Option<&Registry>) {
855    f.section("Release check");
856
857    let Some(registry) = registry else { return };
858    if !registry.settings.update_check {
859        f.note(
860            "update_check",
861            "off — dev-prune opens no network connection",
862        );
863        return;
864    }
865
866    f.note(
867        "update_check",
868        &format!(
869            "on, every {} {}",
870            registry.settings.update_check_interval_days,
871            output::plural(
872                registry.settings.update_check_interval_days as usize,
873                "day",
874                "days"
875            )
876        ),
877    );
878
879    match registry.last_update_check {
880        Some(at) => f.note(
881            "Last checked",
882            &format!(
883                "{} ({} days ago)",
884                at.format("%Y-%m-%d %H:%M UTC"),
885                (Utc::now() - at).num_days()
886            ),
887        ),
888        None => f.note("Last checked", "never"),
889    }
890
891    match registry.latest_known_version.as_deref() {
892        Some(latest) if latest.trim_start_matches('v') != constants::VERSION => f.warn(
893            "Latest release",
894            &format!("{latest} is available — `devp update` shows how to upgrade"),
895        ),
896        Some(latest) => f.ok("Latest release", &format!("{latest} — up to date")),
897        None => f.note("Latest release", "not known yet"),
898    }
899}
900
901// ---------------------------------------------------------------------------
902// One repository
903// ---------------------------------------------------------------------------
904
905fn check_repository(path_str: &str) -> Result<()> {
906    let path = Path::new(path_str)
907        .canonicalize()
908        .with_context(|| format!("Path not found: {path_str}"))?;
909
910    output::print_header(&format!("dev-prune doctor ({})", output::clean_path(&path)));
911    let mut f = Findings::default();
912
913    // Loaded, not defaulted: a repository's verdict depends on the global thresholds, and
914    // silently using the defaults would explain the wrong tool's behaviour.
915    let registry = Registry::load().unwrap_or_default();
916
917    let ctx = check_repo_basics(&mut f, &path, &registry);
918    let projects = check_repo_projects(&mut f, &path, &ctx);
919    let headline = repo_verdict(&ctx, &projects);
920
921    verdict(
922        &f,
923        &format!("{} is in good shape.", output::clean_path(&ctx.path)),
924        Some(&headline),
925    )
926}
927
928/// Everything about the repository that is decided before any project is looked at.
929struct RepoContext {
930    path: PathBuf,
931    is_git: bool,
932    registered: bool,
933    opted_out: Option<String>,
934    config_broken: bool,
935    idle: bool,
936    idle_days: u64,
937    min_size_bytes: u64,
938    depth: usize,
939}
940
941fn check_repo_basics(f: &mut Findings, path: &Path, registry: &Registry) -> RepoContext {
942    f.section("Repository");
943
944    let is_git = scanner::is_git_repo(path);
945    if is_git {
946        f.ok("Git repository", "yes");
947    } else {
948        f.problem(
949            "Git repository",
950            "no — dev-prune only ever touches Git repositories",
951        );
952    }
953
954    let key = crate::config::canonical_key(path);
955    let entry = registry.repositories.get(&key);
956    match entry {
957        Some(e) if e.enabled => f.ok(
958            "Registered",
959            &format!("yes, since {}", e.added_at.format("%Y-%m-%d")),
960        ),
961        Some(e) => f.warn(
962            "Registered",
963            &format!(
964                "yes since {}, but disabled — `devp config {} --update`",
965                e.added_at.format("%Y-%m-%d"),
966                output::clean_path(path)
967            ),
968        ),
969        None => f.warn(
970            "Registered",
971            "no — a prune pass will not visit it. `devp link .` registers it.",
972        ),
973    }
974    if let Some(at) = entry.and_then(|e| e.last_pruned_at) {
975        f.note("Last pruned", &at.format("%Y-%m-%d %H:%M UTC").to_string());
976    }
977
978    // Read exactly the way the prune pass reads it, refusal to guess included.
979    let (per_repo, config_broken) = match PerRepoConfig::load_with_diagnostics(path) {
980        Ok(Some(cfg)) => {
981            f.ok(constants::PER_REPO_CONFIG_FILE, &describe_overrides(&cfg));
982            (Some(cfg), false)
983        }
984        Ok(None) => {
985            f.note(
986                constants::PER_REPO_CONFIG_FILE,
987                "absent — global settings apply",
988            );
989            (None, false)
990        }
991        Err(e) => {
992            f.problem(
993                constants::PER_REPO_CONFIG_FILE,
994                &format!("{e} — the repository is skipped entirely until this parses"),
995            );
996            (None, true)
997        }
998    };
999
1000    let mut opted_out = None;
1001    if path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
1002        opted_out = Some(format!("{} is present", constants::DEVPRUNE_IGNORE_FILE));
1003    } else if per_repo.as_ref().is_some_and(|c| c.ignore) {
1004        opted_out = Some(format!(
1005            "\"ignore\": true in {}",
1006            constants::PER_REPO_CONFIG_FILE
1007        ));
1008    } else if entry.is_some_and(|e| !e.enabled) {
1009        opted_out = Some("disabled in the registry".to_string());
1010    }
1011    match &opted_out {
1012        Some(why) => f.note("Opt-out", why),
1013        None => f.note("Opt-out", "none"),
1014    }
1015
1016    // The same three-level resolution the engine performs: the repository's own file
1017    // beats its registry override, which beats the global setting.
1018    let idle_days = per_repo
1019        .as_ref()
1020        .and_then(|c| c.override_idle_days)
1021        .or_else(|| entry.and_then(|e| e.override_idle_days))
1022        .unwrap_or(registry.settings.idle_days);
1023
1024    let activity = git::get_last_activity(path).ok().flatten();
1025    let idle = git::is_idle_at(activity, idle_days);
1026    match activity {
1027        Some(t) => {
1028            let days = chrono::DateTime::<Utc>::from(t);
1029            let ago = (Utc::now() - days).num_days();
1030            let detail = format!(
1031                "{} ({ago} {} ago), threshold {idle_days}",
1032                days.format("%Y-%m-%d"),
1033                output::plural(ago.unsigned_abs() as usize, "day", "days")
1034            );
1035            if idle {
1036                f.ok("Activity", &format!("{detail} — idle"));
1037            } else {
1038                f.note("Activity", &format!("{detail} — active"));
1039            }
1040        }
1041        None => f.note(
1042            "Activity",
1043            &format!("no commits or source edits found, threshold {idle_days}"),
1044        ),
1045    }
1046
1047    let min_size_mb = per_repo
1048        .as_ref()
1049        .and_then(|c| c.min_size_mb)
1050        .unwrap_or(registry.settings.min_size_mb);
1051    f.note(
1052        "Size floor",
1053        &if min_size_mb == 0 {
1054            "none — every recognised directory counts".to_string()
1055        } else {
1056            format!("{min_size_mb} MiB")
1057        },
1058    );
1059
1060    let depth = workspace::resolve_depth(path, registry.settings.scan_depth);
1061    f.note("Scan depth", &format!("{depth} levels below the root"));
1062
1063    RepoContext {
1064        path: path.to_path_buf(),
1065        is_git,
1066        registered: entry.is_some(),
1067        opted_out,
1068        config_broken,
1069        idle,
1070        idle_days,
1071        min_size_bytes: min_size_mb.saturating_mul(BYTES_PER_MIB),
1072        depth,
1073    }
1074}
1075
1076/// One project's worth of findings, kept so the verdict can reason over all of them.
1077struct ProjectReport {
1078    /// Whether any bloat directory here is above the floor and not symlinked.
1079    prunable: bool,
1080    /// Whether anything was found at all.
1081    has_bloat: bool,
1082}
1083
1084fn check_repo_projects(f: &mut Findings, path: &Path, ctx: &RepoContext) -> Vec<ProjectReport> {
1085    f.section("Projects");
1086
1087    if ctx.config_broken {
1088        f.note(
1089            "",
1090            "not scanned — the configuration above has to parse first",
1091        );
1092        return Vec::new();
1093    }
1094
1095    let projects = workspace::discover_to_depth(path, ctx.depth);
1096    if projects.is_empty() {
1097        f.note(
1098            "",
1099            &format!(
1100                "no recognised package-manager project within {} levels. \
1101                 Raise it with `devp config set scan_depth N`.",
1102                ctx.depth
1103            ),
1104        );
1105        return Vec::new();
1106    }
1107
1108    let mut reports = Vec::new();
1109    for project in &projects {
1110        for adapter in &project.adapters {
1111            println!();
1112            println!("  {} ({})", project.relative.bold(), adapter.name());
1113
1114            // Presence only. Proving a lockfile is *usable* means running the package
1115            // manager, which is minutes of work and, for cargo and go, a write.
1116            let missing: Vec<&str> = adapter
1117                .lockfiles()
1118                .iter()
1119                .copied()
1120                .filter(|n| !project.path.join(n).exists())
1121                .collect();
1122            match (adapter.lockfiles().is_empty(), missing.is_empty()) {
1123                (true, _) => f.note("    Lockfile", "no single file identifies this manager"),
1124                (false, true) => f.ok(
1125                    "    Lockfile",
1126                    &format!("{} present", adapter.lockfiles().join(", ")),
1127                ),
1128                // Every listed file absent — for bun, whose two spellings are
1129                // alternatives, that means neither is there.
1130                (false, false) if missing.len() == adapter.lockfiles().len() => f.problem(
1131                    "    Lockfile",
1132                    &format!(
1133                        "{} missing — nothing can prove the directory is rebuildable, \
1134                         so it will never be pruned",
1135                        missing.join(" / ")
1136                    ),
1137                ),
1138                (false, false) => f.ok(
1139                    "    Lockfile",
1140                    &format!(
1141                        "{} present",
1142                        adapter
1143                            .lockfiles()
1144                            .iter()
1145                            .filter(|n| !missing.contains(n))
1146                            .copied()
1147                            .collect::<Vec<_>>()
1148                            .join(", ")
1149                    ),
1150                ),
1151            }
1152
1153            let bloat = adapter.bloat_dirs(&project.path);
1154            if bloat.is_empty() {
1155                f.note("    Bloat", "nothing installed — nothing to reclaim");
1156                reports.push(ProjectReport {
1157                    prunable: false,
1158                    has_bloat: false,
1159                });
1160                continue;
1161            }
1162
1163            let mut prunable = false;
1164            for bd in &bloat {
1165                let label = workspace::relative_label(path, &bd.path);
1166                let size = output::format_bytes(bd.size_bytes);
1167
1168                if std::fs::symlink_metadata(&bd.path)
1169                    .map(|m| m.file_type().is_symlink())
1170                    .unwrap_or(false)
1171                {
1172                    f.warn(
1173                        "    Bloat",
1174                        &format!(
1175                            "{label} ({size}) is a symlink — refused, because the storage \
1176                             it points at is not this repository's to delete"
1177                        ),
1178                    );
1179                } else if bd.size_bytes < ctx.min_size_bytes {
1180                    f.warn(
1181                        "    Bloat",
1182                        &format!("{label} ({size}) is below the size floor — left alone"),
1183                    );
1184                } else {
1185                    f.ok("    Bloat", &format!("{label} ({size})"));
1186                    prunable = true;
1187                }
1188            }
1189            reports.push(ProjectReport {
1190                prunable,
1191                has_bloat: true,
1192            });
1193        }
1194    }
1195
1196    reports
1197}
1198
1199/// Name the one reason this repository would not be pruned right now.
1200///
1201/// In the order the prune pass applies them, so the answer matches what `devp run` would
1202/// actually do rather than listing everything that happens to be true.
1203fn repo_verdict(ctx: &RepoContext, projects: &[ProjectReport]) -> String {
1204    let clean = output::clean_path(&ctx.path);
1205    let no = |detail: String| format!("{} Would `devp run` prune this? {detail}", "✗".red());
1206
1207    if !ctx.is_git {
1208        no("No — not a Git repository. Nothing else is even checked.".to_string())
1209    } else if ctx.config_broken {
1210        no(format!(
1211            "No — `{}` does not parse, and dev-prune will not guess at a config it \
1212             cannot read.",
1213            constants::PER_REPO_CONFIG_FILE
1214        ))
1215    } else if let Some(why) = &ctx.opted_out {
1216        no(format!("No — opted out: {why}."))
1217    } else if !ctx.registered {
1218        no(format!(
1219            "Not in a full pass — it is not registered. `devp link {clean}` adds it; \
1220             `devp run {clean}` prunes it once without registering."
1221        ))
1222    } else if !ctx.idle {
1223        no(format!(
1224            "No — active within the last {} {}. `devp --ignore-idle run {clean}` overrides \
1225             exactly that check and nothing else.",
1226            ctx.idle_days,
1227            output::plural(ctx.idle_days as usize, "day", "days")
1228        ))
1229    } else if projects.is_empty() {
1230        no("No — no package-manager project was found to prune.".to_string())
1231    } else if !projects.iter().any(|p| p.has_bloat) {
1232        no("No — every project here is already clean.".to_string())
1233    } else if !projects.iter().any(|p| p.prunable) {
1234        no("No — everything found is symlinked or below the size floor. See above.".to_string())
1235    } else {
1236        format!(
1237            "{} Would `devp run` prune this? Yes — subject to each lockfile verifying. \
1238             `devp run {clean} --dry-run` lists what would go.",
1239            "✓".green()
1240        )
1241    }
1242}
1243
1244/// One line describing what a `.devprune.json` actually overrides.
1245fn describe_overrides(cfg: &PerRepoConfig) -> String {
1246    let mut parts = Vec::new();
1247    if let Some(name) = &cfg.project_name {
1248        parts.push(format!("name={name}"));
1249    }
1250    if let Some(days) = cfg.override_idle_days {
1251        parts.push(format!("idle_days={days}"));
1252    }
1253    if let Some(mb) = cfg.min_size_mb {
1254        parts.push(format!("min_size_mb={mb}"));
1255    }
1256    if let Some(depth) = cfg.scan_depth {
1257        parts.push(format!("scan_depth={depth}"));
1258    }
1259    if cfg.ignore {
1260        parts.push("ignore=true".to_string());
1261    }
1262    if cfg.disable_hooks {
1263        parts.push("disable_hooks=true".to_string());
1264    }
1265    if cfg.disable_daemon {
1266        parts.push("disable_daemon=true".to_string());
1267    }
1268    if parts.is_empty() {
1269        "parses; overrides nothing".to_string()
1270    } else {
1271        format!("parses; {}", parts.join(", "))
1272    }
1273}
1274
1275/// The innermost cause of an error, which is the part that says what is actually wrong.
1276///
1277/// `anyhow`'s `{:#}` prints the whole chain, and the outer links here are all "failed to
1278/// parse the registry at <path>" — which the report has already said.
1279fn root_cause(e: &anyhow::Error) -> String {
1280    e.chain().last().map(|c| c.to_string()).unwrap_or_default()
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285    use super::*;
1286    use tempfile::TempDir;
1287
1288    #[test]
1289    fn an_integration_pointing_at_a_deleted_binary_is_a_problem_not_a_warning() {
1290        // The whole point of the check: this is broken, not merely worth knowing, so it
1291        // has to reach the non-zero exit code.
1292        let mut f = Findings::default();
1293        report_integration_target(
1294            &mut f,
1295            "Scheduler",
1296            "installed",
1297            Some(PathBuf::from("/nonexistent/dev-prune")),
1298            "Re-register it.",
1299        );
1300        assert_eq!(f.warnings.len(), 0);
1301        assert_eq!(f.problems.len(), 1);
1302        assert!(f.problems[0].contains("no longer exists"));
1303    }
1304
1305    #[test]
1306    fn an_integration_whose_binary_is_present_passes() {
1307        let tmp = TempDir::new().unwrap();
1308        let exe = tmp.path().join("dev-prune");
1309        std::fs::write(&exe, b"binary").unwrap();
1310
1311        let mut f = Findings::default();
1312        report_integration_target(&mut f, "Scheduler", "installed", Some(exe), "Re-register.");
1313        assert!(f.problems.is_empty() && f.warnings.is_empty());
1314    }
1315
1316    #[test]
1317    fn an_unreadable_entry_is_not_reported_as_broken() {
1318        // `None` means the platform could not tell us, which is not evidence of a
1319        // problem — reporting it would be a warning nobody can act on.
1320        let mut f = Findings::default();
1321        report_integration_target(&mut f, "Scheduler", "installed", None, "Re-register.");
1322        assert!(f.problems.is_empty() && f.warnings.is_empty());
1323    }
1324
1325    #[test]
1326    fn overrides_are_listed_by_name() {
1327        let mut cfg = PerRepoConfig::default();
1328        assert_eq!(describe_overrides(&cfg), "parses; overrides nothing");
1329
1330        cfg.override_idle_days = Some(30);
1331        cfg.ignore = true;
1332        assert_eq!(
1333            describe_overrides(&cfg),
1334            "parses; idle_days=30, ignore=true"
1335        );
1336    }
1337
1338    /// `min_size_mb: 0` is a value, not an absence — it opts a repository out of a global
1339    /// floor, so the report has to show it rather than treating it as unset.
1340    #[test]
1341    fn a_zero_floor_is_reported_as_an_override() {
1342        let cfg = PerRepoConfig {
1343            min_size_mb: Some(0),
1344            ..PerRepoConfig::default()
1345        };
1346        assert_eq!(describe_overrides(&cfg), "parses; min_size_mb=0");
1347    }
1348
1349    #[test]
1350    fn a_repository_that_is_not_a_git_repo_is_the_first_thing_reported() {
1351        let dir = TempDir::new().unwrap();
1352        let ctx = RepoContext {
1353            path: dir.path().to_path_buf(),
1354            is_git: false,
1355            registered: false,
1356            opted_out: Some("ignore.devprune.json is present".to_string()),
1357            config_broken: true,
1358            idle: true,
1359            idle_days: 15,
1360            min_size_bytes: 0,
1361            depth: 6,
1362        };
1363        // Three reasons are true at once; the verdict names the one the prune pass would
1364        // hit first, which is the one the user has to fix before any other matters.
1365        let line = repo_verdict(&ctx, &[]);
1366        assert!(line.contains("not a Git repository"), "{line}");
1367    }
1368
1369    #[test]
1370    fn warnings_alone_do_not_fail_the_command() {
1371        let mut f = Findings::default();
1372        f.warn("Scheduler", "not installed");
1373        assert!(verdict(&f, "fine", None).is_ok());
1374
1375        f.problem("PATH", "missing");
1376        assert!(verdict(&f, "fine", None).is_err());
1377    }
1378
1379    #[test]
1380    #[cfg(windows)]
1381    fn a_path_entry_matches_regardless_of_case_and_trailing_separator() {
1382        // Both differences are ones Windows itself ignores, and either used to turn
1383        // into a "not on PATH" problem — a healthy install failing `devp doctor`.
1384        let dir = Path::new(r"C:\Users\Someone\AppData\Roaming\dev-prune\bin");
1385        assert!(same_dir(
1386            Path::new(r"c:\users\someone\appdata\roaming\dev-prune\bin\"),
1387            dir
1388        ));
1389        assert!(!same_dir(Path::new(r"C:\Windows"), dir));
1390    }
1391
1392    #[test]
1393    #[cfg(not(windows))]
1394    fn a_path_entry_on_unix_is_matched_exactly() {
1395        assert!(same_dir(
1396            Path::new("/usr/local/bin"),
1397            Path::new("/usr/local/bin")
1398        ));
1399        assert!(!same_dir(
1400            Path::new("/USR/local/bin"),
1401            Path::new("/usr/local/bin")
1402        ));
1403    }
1404
1405    #[test]
1406    fn a_stale_twin_is_told_apart_from_a_current_one() {
1407        let dir = TempDir::new().unwrap();
1408        let a = dir.path().join("dev-prune");
1409        let b = dir.path().join("devp");
1410        std::fs::write(&a, b"version two").unwrap();
1411        std::fs::write(&b, b"version two").unwrap();
1412        assert!(same_binary(&a, &b));
1413
1414        // Same length, different bytes — the case a size-only test waves through.
1415        std::fs::write(&b, b"version one").unwrap();
1416        assert!(!same_binary(&a, &b));
1417
1418        std::fs::write(&b, b"short").unwrap();
1419        assert!(!same_binary(&a, &b));
1420        assert!(!same_binary(&a, &dir.path().join("missing")));
1421    }
1422}