Skip to main content

decdn_cli/commands/doctor/
mod.rs

1//! `decdn node doctor` — read-only diagnosis of node config, on-disk state,
2//! disk-vs-budget, and network reachability. Runs pre-boot and against a live
3//! daemon; mutates nothing.
4
5use std::path::Path;
6
7use serde::Serialize;
8
9mod chain;
10mod config;
11mod disk;
12mod live;
13mod origin;
14mod ports;
15mod report;
16mod state;
17
18/// A single diagnostic outcome. Every `Warn`/`Fail` carries a one-line
19/// `remediation`; `detail` holds a grep-friendly `key=value` tail.
20#[derive(Debug, Serialize)]
21pub struct Finding {
22    /// Check group this finding belongs to.
23    pub group: &'static str,
24    /// Stable machine-readable id for this specific check.
25    pub id: &'static str,
26    /// Outcome severity of this check.
27    pub severity: Severity,
28    /// One-line human summary of the outcome.
29    pub title: String,
30    /// Optional grep-friendly `key=value` detail tail.
31    pub detail: Option<String>,
32    /// Optional one-line fix suggested when the check is not a clean pass.
33    pub remediation: Option<String>,
34}
35
36/// Diagnostic severity. Only `Fail` (and `Warn` under `--strict`) drives a
37/// nonzero exit.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Severity {
41    /// The check found nothing wrong.
42    Pass,
43    /// The check found a non-fatal issue worth the operator's attention.
44    Warn,
45    /// The check found a problem that blocks correct operation.
46    Fail,
47}
48
49/// The collected findings of one doctor run.
50#[derive(Debug, Default, Serialize)]
51pub struct Report {
52    /// Findings pushed so far, in the order each check ran.
53    pub findings: Vec<Finding>,
54}
55
56impl Report {
57    /// Append one finding to the report.
58    pub fn push(&mut self, finding: Finding) {
59        self.findings.push(finding);
60    }
61
62    /// Returns `(pass, warn, fail)` counts.
63    pub fn counts(&self) -> (usize, usize, usize) {
64        self.findings
65            .iter()
66            .fold((0, 0, 0), |(p, w, f), finding| match finding.severity {
67                Severity::Pass => (p + 1, w, f),
68                Severity::Warn => (p, w + 1, f),
69                Severity::Fail => (p, w, f + 1),
70            })
71    }
72
73    /// True when any finding is a `Fail`.
74    pub fn has_fail(&self) -> bool {
75        self.findings.iter().any(|x| x.severity == Severity::Fail)
76    }
77
78    /// True when any finding is a `Warn`.
79    pub fn has_warn(&self) -> bool {
80        self.findings.iter().any(|x| x.severity == Severity::Warn)
81    }
82}
83
84/// Sentinel error returned by [`run`] when the report contains failing checks
85/// (or warnings under `--strict`). `main` recognizes it to set a nonzero exit
86/// **without** printing an `Error:` line — the report is already on stdout.
87#[derive(Debug)]
88pub struct DoctorFailed;
89
90impl std::fmt::Display for DoctorFailed {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(f, "doctor found failing checks")
93    }
94}
95
96impl std::error::Error for DoctorFailed {}
97
98/// Run every diagnostic group, render the report, and map severity to exit.
99pub async fn run(
100    args: &decdn_common::cli::DoctorArgs,
101    global_config: Option<&Path>,
102) -> anyhow::Result<()> {
103    let mut report = Report::default();
104    let resolved = config::check_config(&mut report, args, global_config);
105    // Config-dependent groups run only when resolve succeeded (Tasks 4-9).
106    if let Some(resolved) = &resolved {
107        let live = if args.offline {
108            live::LiveInfo {
109                daemon_running: false,
110                cache_bytes: None,
111            }
112        } else {
113            live::probe_live(&mut report, resolved, args, global_config).await
114        };
115
116        disk::check_disk(&mut report, resolved, live.cache_bytes);
117        state::check_state(&mut report, resolved, live.daemon_running);
118        if !args.offline {
119            chain::check_chain(&mut report, resolved, args.timeout_ms).await;
120            origin::check_origins(&mut report, resolved, args.timeout_ms).await;
121            ports::check_ports(&mut report, resolved, live.daemon_running);
122        }
123    }
124
125    let mut stdout = std::io::stdout().lock();
126    report::render(&mut stdout, &report, args.json, args.strict)
127        .map_err(|e| anyhow::anyhow!("failed to write doctor report: {e}"))?;
128
129    let fail = report.has_fail() || (args.strict && report.has_warn());
130    if fail {
131        return Err(DoctorFailed.into());
132    }
133    Ok(())
134}
135
136#[cfg(test)]
137#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // tests
138mod tests;