Skip to main content

spec_driven_docs/commands/
doctor.rs

1//! `sdd doctor`: is this host ready?
2//!
3//! Runs the whole probe catalog from `crate::probes` and reports by class.
4//! A probe failure is a result, not an error: the exit code stays 0 and the
5//! report is what a caller reads.
6
7use serde::Serialize;
8
9use crate::cli::doctor::DoctorArgs;
10use crate::context::AppContext;
11use crate::error::AppError;
12use crate::output;
13use crate::probes::{self, ProbeClass, ProbeResult, ProbeStatus};
14
15/// The machine form of the doctor report.
16#[derive(Debug, Serialize)]
17struct Report {
18    /// The shape version of this document.
19    schema: &'static str,
20    /// Every probe's answer, in catalog order.
21    probes: Vec<ProbeResult>,
22    /// What plausibly follows.
23    next: Vec<String>,
24}
25
26/// Run the catalog and report it.
27///
28/// # Errors
29///
30/// [`AppError::Other`] only when the report cannot serialize; probe
31/// failures are results, not errors, and the exit code stays 0.
32pub fn run(_ctx: &AppContext, args: &DoctorArgs) -> Result<(), AppError> {
33    let probes = probes::run_all();
34    let next = vec!["sdd status --target . reports the instance".to_owned()];
35    if args.json {
36        return output::json(&Report {
37            schema: "sdd.doctor/1",
38            probes,
39            next,
40        });
41    }
42    for class in [ProbeClass::Hard, ProbeClass::Soft] {
43        output::line(match class {
44            ProbeClass::Hard => "hard",
45            ProbeClass::Soft => "soft",
46        });
47        for probe in probes.iter().filter(|probe| probe.class == class) {
48            let status = match probe.status {
49                ProbeStatus::Ok => "ok    ",
50                ProbeStatus::Failed => "failed",
51            };
52            output::line(format!("  {status}  {}: {}", probe.id, probe.message));
53            if let Some(remediation) = &probe.remediation {
54                output::line(format!("          next: {remediation}"));
55            }
56        }
57    }
58    output::line("Next:");
59    for line in &next {
60        output::line(format!("  {line}"));
61    }
62    Ok(())
63}
64
65#[cfg(test)]
66mod tests {
67    #![allow(
68        clippy::expect_used,
69        reason = "a test panics as its failure signal, not as control flow"
70    )]
71
72    use crate::probes::{ProbeClass, ProbeResult, ProbeStatus};
73
74    /// The complete `sdd.doctor/1` shape, held by snapshot.
75    #[test]
76    fn the_doctor_report_schema_snapshot_holds() {
77        let report = super::Report {
78            schema: "sdd.doctor/1",
79            probes: vec![ProbeResult {
80                id: "state-root",
81                class: ProbeClass::Hard,
82                status: ProbeStatus::Ok,
83                message: "the state root is writable".into(),
84                remediation: None,
85            }],
86            next: vec!["sdd status --target . reports the instance".into()],
87        };
88        assert_eq!(
89            serde_json::to_string(&report).expect("a report serializes"),
90            r#"{"schema":"sdd.doctor/1","probes":[{"id":"state-root","class":"hard","status":"ok","message":"the state root is writable"}],"next":["sdd status --target . reports the instance"]}"#
91        );
92    }
93
94    /// The `sdd.doctor/1` probe shape, held by snapshot.
95    #[test]
96    fn the_probe_schema_snapshot_holds() {
97        let ok = ProbeResult {
98            id: "git",
99            class: ProbeClass::Soft,
100            status: ProbeStatus::Ok,
101            message: "git runs".into(),
102            remediation: None,
103        };
104        assert_eq!(
105            serde_json::to_string(&ok).expect("a probe serializes"),
106            r#"{"id":"git","class":"soft","status":"ok","message":"git runs"}"#
107        );
108        let failed = ProbeResult {
109            id: "pre-commit",
110            class: ProbeClass::Soft,
111            status: ProbeStatus::Failed,
112            message: "pre-commit is not on PATH".into(),
113            remediation: Some("install pre-commit; the delivered gates run through it".into()),
114        };
115        assert_eq!(
116            serde_json::to_string(&failed).expect("a probe serializes"),
117            r#"{"id":"pre-commit","class":"soft","status":"failed","message":"pre-commit is not on PATH","remediation":"install pre-commit; the delivered gates run through it"}"#
118        );
119    }
120}