1use 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#[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#[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 ®istry,
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}