Skip to main content

mant_protocol/
doctor.rs

1//! Versioned report contract for read-only installation diagnostics.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::Producer;
7
8/// Exact schema marker for an installation health report.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
10pub enum DoctorSchema {
11    /// Version 1 of the doctor report protocol.
12    #[serde(rename = "mant.doctor/v1")]
13    V1,
14}
15
16/// Aggregate health derived from every doctor check.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "kebab-case")]
19pub enum DoctorOutcome {
20    /// No warning or error was detected.
21    Healthy,
22    /// At least one non-fatal condition deserves attention.
23    Warning,
24    /// At least one promised local capability is broken.
25    Error,
26}
27
28/// Severity of one stable doctor check.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
30#[serde(rename_all = "kebab-case")]
31pub enum DoctorCheckStatus {
32    /// The checked capability is available and healthy.
33    Ok,
34    /// The check records useful context without requiring action.
35    Info,
36    /// An optional or recoverable capability needs attention.
37    Warning,
38    /// A promised local capability is unusable.
39    Error,
40}
41
42/// Effective local paths and host identity inspected by `mant --doctor`.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44#[serde(rename_all = "camelCase")]
45pub struct DoctorEnvironment {
46    /// Rust host operating-system family.
47    pub os: String,
48    /// Rust host processor architecture.
49    pub arch: String,
50    /// Platform-native `ManT` data root, when it could be derived.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub data_root: Option<String>,
53    /// Effective `sources.toml` path, when the data root is available.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub config_path: Option<String>,
56    /// Personal Markdown root, when the data root is available.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub documents_root: Option<String>,
59    /// Managed source root, when the data root is available.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub sources_root: Option<String>,
62    /// Native manual roots in effective precedence order.
63    pub manual_roots: Vec<String>,
64    /// tldr cache roots in effective read order.
65    pub tldr_roots: Vec<String>,
66}
67
68/// One independently actionable installation check.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
70#[serde(rename_all = "camelCase")]
71pub struct DoctorCheck {
72    /// Stable machine-readable check identifier.
73    pub code: String,
74    /// Optional configured source or other logical subject.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub subject: Option<String>,
77    /// Check severity.
78    pub status: DoctorCheckStatus,
79    /// Concise human-readable result.
80    pub message: String,
81    /// Additional bounded evidence, when useful.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub details: Vec<String>,
84    /// Explicit next command or corrective action, when available.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub remediation: Option<String>,
87}
88
89/// Stable counts for one complete doctor run.
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
91#[serde(rename_all = "camelCase")]
92pub struct DoctorSummary {
93    /// Successful checks.
94    pub ok: u32,
95    /// Informational checks.
96    pub info: u32,
97    /// Non-fatal warnings.
98    pub warnings: u32,
99    /// Failed capabilities.
100    pub errors: u32,
101}
102
103/// Complete read-only installation health report.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
105#[serde(rename_all = "camelCase")]
106#[schemars(extend("$id" = "urn:mant:doctor:v1"))]
107pub struct DoctorReport {
108    /// Exact response schema discriminator.
109    pub schema: DoctorSchema,
110    /// Process provenance.
111    pub producer: Producer,
112    /// Aggregate health derived from [`Self::checks`].
113    pub outcome: DoctorOutcome,
114    /// Effective host and storage paths.
115    pub environment: DoctorEnvironment,
116    /// Deterministically ordered checks.
117    pub checks: Vec<DoctorCheck>,
118    /// Counts derived from [`Self::checks`].
119    pub summary: DoctorSummary,
120}
121
122impl DoctorReport {
123    /// Build a report whose summary and aggregate outcome cannot disagree with
124    /// its checks.
125    #[must_use]
126    pub fn new(
127        producer: Producer,
128        environment: DoctorEnvironment,
129        checks: Vec<DoctorCheck>,
130    ) -> Self {
131        let mut summary = DoctorSummary::default();
132        for check in &checks {
133            match check.status {
134                DoctorCheckStatus::Ok => summary.ok += 1,
135                DoctorCheckStatus::Info => summary.info += 1,
136                DoctorCheckStatus::Warning => summary.warnings += 1,
137                DoctorCheckStatus::Error => summary.errors += 1,
138            }
139        }
140        let outcome = if summary.errors > 0 {
141            DoctorOutcome::Error
142        } else if summary.warnings > 0 {
143            DoctorOutcome::Warning
144        } else {
145            DoctorOutcome::Healthy
146        };
147        Self {
148            schema: DoctorSchema::V1,
149            producer,
150            outcome,
151            environment,
152            checks,
153            summary,
154        }
155    }
156
157    /// Return whether a promised capability failed.
158    #[must_use]
159    pub const fn has_errors(&self) -> bool {
160        self.summary.errors > 0
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use serde_json::json;
167
168    use super::{DoctorCheck, DoctorCheckStatus, DoctorEnvironment, DoctorOutcome, DoctorReport};
169    use crate::Producer;
170
171    fn environment() -> DoctorEnvironment {
172        DoctorEnvironment {
173            os: "linux".to_owned(),
174            arch: "x86_64".to_owned(),
175            data_root: Some("/data/mant".to_owned()),
176            config_path: Some("/data/mant/sources.toml".to_owned()),
177            documents_root: Some("/data/mant/documents".to_owned()),
178            sources_root: Some("/data/mant/sources".to_owned()),
179            manual_roots: vec!["/usr/share/man".to_owned()],
180            tldr_roots: Vec::new(),
181        }
182    }
183
184    fn producer() -> Producer {
185        Producer {
186            name: "mant".to_owned(),
187            version: "0.9.0".to_owned(),
188            engine: None,
189        }
190    }
191
192    #[test]
193    fn report_derives_warning_outcome_and_counts() {
194        let report = DoctorReport::new(
195            producer(),
196            environment(),
197            vec![
198                DoctorCheck {
199                    code: "runtime.libmandoc".to_owned(),
200                    subject: None,
201                    status: DoctorCheckStatus::Ok,
202                    message: "parser probe succeeded".to_owned(),
203                    details: Vec::new(),
204                    remediation: None,
205                },
206                DoctorCheck {
207                    code: "sources.not-installed".to_owned(),
208                    subject: Some("team".to_owned()),
209                    status: DoctorCheckStatus::Warning,
210                    message: "configured source is not installed".to_owned(),
211                    details: Vec::new(),
212                    remediation: Some("mant --update-docs".to_owned()),
213                },
214            ],
215        );
216
217        assert_eq!(report.outcome, DoctorOutcome::Warning);
218        assert_eq!(report.summary.ok, 1);
219        assert_eq!(report.summary.warnings, 1);
220        assert!(!report.has_errors());
221        assert_eq!(
222            serde_json::to_value(report).expect("doctor report"),
223            json!({
224                "schema": "mant.doctor/v1",
225                "producer": { "name": "mant", "version": "0.9.0" },
226                "outcome": "warning",
227                "environment": {
228                    "os": "linux",
229                    "arch": "x86_64",
230                    "dataRoot": "/data/mant",
231                    "configPath": "/data/mant/sources.toml",
232                    "documentsRoot": "/data/mant/documents",
233                    "sourcesRoot": "/data/mant/sources",
234                    "manualRoots": ["/usr/share/man"],
235                    "tldrRoots": []
236                },
237                "checks": [
238                    {
239                        "code": "runtime.libmandoc",
240                        "status": "ok",
241                        "message": "parser probe succeeded"
242                    },
243                    {
244                        "code": "sources.not-installed",
245                        "subject": "team",
246                        "status": "warning",
247                        "message": "configured source is not installed",
248                        "remediation": "mant --update-docs"
249                    }
250                ],
251                "summary": { "ok": 1, "info": 0, "warnings": 1, "errors": 0 }
252            })
253        );
254    }
255
256    #[test]
257    fn any_error_makes_the_report_fail() {
258        let report = DoctorReport::new(
259            producer(),
260            environment(),
261            vec![DoctorCheck {
262                code: "paths.data-root".to_owned(),
263                subject: None,
264                status: DoctorCheckStatus::Error,
265                message: "data root is unavailable".to_owned(),
266                details: Vec::new(),
267                remediation: Some("set HOME".to_owned()),
268            }],
269        );
270
271        assert_eq!(report.outcome, DoctorOutcome::Error);
272        assert!(report.has_errors());
273    }
274}