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