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::runner_contract::{is_machine_format, write_failure, write_success, CliErrorPayload};
18use crate::runtime_registry::{protocol_catalog, workspace_protocol_registry};
19
20#[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#[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 ®istry,
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}