Skip to main content

mabi_cli/commands/
doctor.rs

1//! Self-contained installation diagnostics for the packaged CLI.
2
3use std::collections::BTreeSet;
4use std::net::{SocketAddr, TcpListener};
5
6use async_trait::async_trait;
7use serde::Serialize;
8use serde_json::json;
9use tokio::time::Duration;
10
11use mabi_runtime::{ProtocolLaunchSpec, RuntimeExtensions, RuntimeSession, RuntimeSessionSpec};
12
13use crate::context::CliContext;
14use crate::error::CliResult;
15use crate::output::{OutputFormat, StatusType, TableBuilder};
16use crate::runner::{Command, CommandOutput};
17use crate::runtime_registry::{protocol_catalog, workspace_protocol_registry};
18
19/// Protocol selection for `mabi doctor`.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DoctorProtocol {
22    All,
23    Modbus,
24    Opcua,
25    Bacnet,
26    Knx,
27}
28
29impl DoctorProtocol {
30    fn selected_keys(self) -> &'static [&'static str] {
31        match self {
32            Self::All => &["modbus", "opcua", "bacnet", "knx"],
33            Self::Modbus => &["modbus"],
34            Self::Opcua => &["opcua"],
35            Self::Bacnet => &["bacnet"],
36            Self::Knx => &["knx"],
37        }
38    }
39}
40
41/// Doctor command that validates the installed binary without external tools.
42#[derive(Debug, Clone)]
43pub struct DoctorCommand {
44    protocol: DoctorProtocol,
45    readiness_timeout: Duration,
46}
47
48impl DoctorCommand {
49    pub fn new(protocol: DoctorProtocol, readiness_timeout: Duration) -> Self {
50        Self {
51            protocol,
52            readiness_timeout,
53        }
54    }
55}
56
57#[derive(Debug, Clone, Serialize)]
58pub struct DoctorReport {
59    pub version: String,
60    pub checks: Vec<DoctorCheck>,
61    pub protocols: Vec<ProtocolDoctorResult>,
62    pub optional_prereqs: Vec<DoctorCheck>,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct DoctorCheck {
67    pub id: String,
68    pub status: DoctorStatus,
69    pub message: String,
70}
71
72impl DoctorCheck {
73    fn pass(id: impl Into<String>, message: impl Into<String>) -> Self {
74        Self {
75            id: id.into(),
76            status: DoctorStatus::Pass,
77            message: message.into(),
78        }
79    }
80
81    fn fail(id: impl Into<String>, message: impl Into<String>) -> Self {
82        Self {
83            id: id.into(),
84            status: DoctorStatus::Fail,
85            message: message.into(),
86        }
87    }
88
89    fn skip(id: impl Into<String>, message: impl Into<String>) -> Self {
90        Self {
91            id: id.into(),
92            status: DoctorStatus::Skip,
93            message: message.into(),
94        }
95    }
96}
97
98#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
99#[serde(rename_all = "snake_case")]
100pub enum DoctorStatus {
101    Pass,
102    Fail,
103    Skip,
104}
105
106impl DoctorStatus {
107    fn as_str(self) -> &'static str {
108        match self {
109            Self::Pass => "pass",
110            Self::Fail => "fail",
111            Self::Skip => "skip",
112        }
113    }
114
115    fn table_status(self) -> StatusType {
116        match self {
117            Self::Pass => StatusType::Success,
118            Self::Fail => StatusType::Error,
119            Self::Skip => StatusType::Warning,
120        }
121    }
122}
123
124#[derive(Debug, Clone, Serialize)]
125pub struct ProtocolDoctorResult {
126    pub protocol: String,
127    pub launch_ok: bool,
128    pub ready_ok: bool,
129    pub snapshot_ok: bool,
130    pub stop_ok: bool,
131    pub metadata_keys: Vec<String>,
132    pub message: String,
133}
134
135impl ProtocolDoctorResult {
136    fn status(&self) -> DoctorStatus {
137        if self.launch_ok && self.ready_ok && self.snapshot_ok && self.stop_ok {
138            DoctorStatus::Pass
139        } else {
140            DoctorStatus::Fail
141        }
142    }
143}
144
145#[async_trait]
146impl Command for DoctorCommand {
147    fn name(&self) -> &str {
148        "doctor"
149    }
150
151    fn description(&self) -> &str {
152        "Run self-contained installation diagnostics"
153    }
154
155    async fn execute(&self, ctx: &mut CliContext) -> CliResult<CommandOutput> {
156        let report = self.run_report().await;
157        render_report(ctx, &report)?;
158
159        if report_has_failures(&report) {
160            Ok(CommandOutput::failure(1, "mabi doctor found failures"))
161        } else {
162            Ok(CommandOutput::quiet_success())
163        }
164    }
165}
166
167impl DoctorCommand {
168    async fn run_report(&self) -> DoctorReport {
169        let mut checks = Vec::new();
170        checks.push(DoctorCheck::pass(
171            "version.release",
172            format!(
173                "CLI release version is {}",
174                mabi_core::version::RELEASE_VERSION
175            ),
176        ));
177
178        let catalog = protocol_catalog();
179        let registered: BTreeSet<&str> = catalog.iter().map(|entry| entry.descriptor.key).collect();
180        for expected in ["modbus", "opcua", "bacnet", "knx"] {
181            if registered.contains(expected) {
182                checks.push(DoctorCheck::pass(
183                    format!("registry.{}", expected),
184                    format!("{} protocol driver is registered", expected),
185                ));
186            } else {
187                checks.push(DoctorCheck::fail(
188                    format!("registry.{}", expected),
189                    format!("{} protocol driver is missing", expected),
190                ));
191            }
192        }
193
194        let mut protocols = Vec::new();
195        for protocol in self.protocol.selected_keys() {
196            protocols.push(self.run_protocol_smoke(protocol).await);
197        }
198
199        DoctorReport {
200            version: mabi_core::version::RELEASE_VERSION.to_string(),
201            checks,
202            protocols,
203            optional_prereqs: optional_prereqs(),
204        }
205    }
206
207    async fn run_protocol_smoke(&self, protocol: &str) -> ProtocolDoctorResult {
208        let launch = doctor_launch_spec(protocol);
209        let mut result = ProtocolDoctorResult {
210            protocol: protocol.to_string(),
211            launch_ok: false,
212            ready_ok: false,
213            snapshot_ok: false,
214            stop_ok: false,
215            metadata_keys: Vec::new(),
216            message: "not started".to_string(),
217        };
218
219        let Some(launch) = launch else {
220            result.message = "unknown doctor protocol".to_string();
221            return result;
222        };
223
224        let registry = workspace_protocol_registry();
225        let session = RuntimeSession::new(
226            RuntimeSessionSpec {
227                services: vec![launch],
228                readiness_timeout: Some(self.readiness_timeout.as_millis() as u64),
229            },
230            &registry,
231            RuntimeExtensions::default(),
232        )
233        .await;
234
235        let session = match session {
236            Ok(session) => {
237                result.launch_ok = true;
238                session
239            }
240            Err(error) => {
241                result.message = format!("launch failed: {}", error);
242                return result;
243            }
244        };
245
246        if let Err(error) = session.start(self.readiness_timeout).await {
247            let detail = session
248                .snapshots()
249                .await
250                .ok()
251                .and_then(|mut snapshots| snapshots.pop())
252                .and_then(|snapshot| snapshot.status.last_error);
253            result.message = match detail {
254                Some(detail) => format!("readiness failed: {}; {}", error, detail),
255                None => format!("readiness failed: {}", error),
256            };
257            let _ = session.stop().await;
258            return result;
259        }
260        result.ready_ok = true;
261
262        match session.snapshots().await {
263            Ok(snapshots) => {
264                if let Some(snapshot) = snapshots.into_iter().next() {
265                    result.metadata_keys = snapshot.metadata.keys().cloned().collect();
266                    result.snapshot_ok = protocol_metadata_ok(protocol, &result.metadata_keys);
267                    if !result.snapshot_ok {
268                        result.message =
269                            format!("snapshot missing required metadata for {}", protocol);
270                    }
271                } else {
272                    result.message = "runtime returned no snapshots".to_string();
273                }
274            }
275            Err(error) => {
276                result.message = format!("snapshot failed: {}", error);
277            }
278        }
279
280        match session.stop().await {
281            Ok(()) => {
282                result.stop_ok = true;
283            }
284            Err(error) => {
285                result.message = format!("stop failed: {}", error);
286            }
287        }
288
289        if result.status() == DoctorStatus::Pass {
290            result.message = "self-contained runtime smoke passed".to_string();
291        }
292        result
293    }
294}
295
296fn report_has_failures(report: &DoctorReport) -> bool {
297    report
298        .checks
299        .iter()
300        .chain(report.optional_prereqs.iter())
301        .any(|check| check.status == DoctorStatus::Fail)
302        || report
303            .protocols
304            .iter()
305            .any(|protocol| protocol.status() == DoctorStatus::Fail)
306}
307
308fn render_report(ctx: &CliContext, report: &DoctorReport) -> CliResult<()> {
309    match ctx.output().format() {
310        OutputFormat::Json | OutputFormat::Yaml | OutputFormat::Compact => {
311            ctx.output().write(report)?;
312        }
313        OutputFormat::Table => {
314            ctx.output().header("mabi doctor");
315            ctx.output().kv("Version", &report.version);
316
317            TableBuilder::new(ctx.colors_enabled())
318                .header(["Check", "Status", "Message"])
319                .status_row(
320                    [
321                        "version".to_string(),
322                        "pass".to_string(),
323                        format!("mabi {}", report.version),
324                    ],
325                    StatusType::Success,
326                )
327                .print();
328
329            let mut registry_table =
330                TableBuilder::new(ctx.colors_enabled()).header(["Check", "Status", "Message"]);
331            for check in &report.checks {
332                registry_table = registry_table.status_row(
333                    [
334                        check.id.clone(),
335                        check.status.as_str().to_string(),
336                        check.message.clone(),
337                    ],
338                    check.status.table_status(),
339                );
340            }
341            registry_table.print();
342
343            let mut protocol_table = TableBuilder::new(ctx.colors_enabled())
344                .header(["Protocol", "Launch", "Ready", "Snapshot", "Stop", "Status"]);
345            for protocol in &report.protocols {
346                protocol_table = protocol_table.status_row(
347                    [
348                        protocol.protocol.clone(),
349                        bool_status(protocol.launch_ok),
350                        bool_status(protocol.ready_ok),
351                        bool_status(protocol.snapshot_ok),
352                        bool_status(protocol.stop_ok),
353                        protocol.status().as_str().to_string(),
354                    ],
355                    protocol.status().table_status(),
356                );
357            }
358            protocol_table.print();
359
360            let mut optional_table =
361                TableBuilder::new(ctx.colors_enabled()).header(["Optional", "Status", "Message"]);
362            for check in &report.optional_prereqs {
363                optional_table = optional_table.status_row(
364                    [
365                        check.id.clone(),
366                        check.status.as_str().to_string(),
367                        check.message.clone(),
368                    ],
369                    check.status.table_status(),
370                );
371            }
372            optional_table.print();
373        }
374    }
375    Ok(())
376}
377
378fn bool_status(value: bool) -> String {
379    if value { "pass" } else { "fail" }.to_string()
380}
381
382fn optional_prereqs() -> Vec<DoctorCheck> {
383    [
384        (
385            "interop.docker",
386            "Docker/Compose is only required for source-tree interop matrices",
387        ),
388        (
389            "interop.python",
390            "Python peers such as XKNX/BACpypes are optional interop assets",
391        ),
392        (
393            "interop.java",
394            "Java peers such as Calimero/Milo are optional interop assets",
395        ),
396        (
397            "interop.node",
398            "Node peers such as knx are optional interop assets",
399        ),
400        (
401            "interop.knxd",
402            "knxd is optional and never required by installed CLI smoke checks",
403        ),
404    ]
405    .into_iter()
406    .map(|(id, message)| DoctorCheck::skip(id, message))
407    .collect()
408}
409
410fn doctor_launch_spec(protocol: &str) -> Option<ProtocolLaunchSpec> {
411    let modbus_bind_addr = reserve_loopback_tcp_addr()
412        .map(|address| address.to_string())
413        .unwrap_or_else(|| "127.0.0.1:0".to_string());
414    let config = match protocol {
415        "modbus" => json!({
416            "transport": {
417                "kind": "tcp",
418                "bind_addr": modbus_bind_addr,
419                "performance_preset": "default",
420            },
421            "devices": 1,
422            "points_per_device": 4,
423        }),
424        "opcua" => json!({
425            "bind_addr": "127.0.0.1:0",
426            "endpoint_path": "/mabi/doctor",
427            "nodes": 4,
428            "security_mode": "None",
429        }),
430        "bacnet" => json!({
431            "bind_addr": "127.0.0.1:0",
432            "device_instance": 9_001,
433            "objects": 8,
434            "bbmd_enabled": false,
435        }),
436        "knx" => json!({
437            "bind_addr": "127.0.0.1:0",
438            "individual_address": "1.1.1",
439            "group_objects": 8,
440        }),
441        _ => return None,
442    };
443
444    Some(ProtocolLaunchSpec {
445        protocol: protocol.to_string(),
446        name: Some(format!("doctor-{}", protocol)),
447        config,
448    })
449}
450
451fn reserve_loopback_tcp_addr() -> Option<SocketAddr> {
452    let listener = TcpListener::bind(("127.0.0.1", 0)).ok()?;
453    let address = listener.local_addr().ok()?;
454    drop(listener);
455    Some(address)
456}
457
458fn protocol_metadata_ok(protocol: &str, keys: &[String]) -> bool {
459    let keys: BTreeSet<&str> = keys.iter().map(String::as_str).collect();
460    let required: &[&str] = match protocol {
461        "modbus" => &["transport", "devices", "points", "bind_address"],
462        "opcua" => &[
463            "endpoint",
464            "transport_protocol",
465            "nodes",
466            "security_profile",
467        ],
468        "bacnet" => &["bind_address", "device_instance", "objects", "metrics"],
469        "knx" => &[
470            "bind_address",
471            "individual_address",
472            "group_objects",
473            "metrics",
474        ],
475        _ => return false,
476    };
477    required.iter().all(|key| keys.contains(key))
478}