1use std::path::{Path, PathBuf};
33use std::sync::mpsc;
34use std::thread;
35use std::time::Duration;
36
37use prost::Message;
38
39use crate::broker::capabilities::CAP_HANDLE_PASSING;
40use crate::broker::client::{
41 broker_disabled_by_env, connect_local_socket, RUNNING_PROCESS_DISABLE_ENV,
42 RUNNING_PROCESS_FAKE_BACKEND_ENV,
43};
44use crate::broker::lifecycle::names::{backend_pipe, shared_broker_pipe, PipePathError};
45use crate::broker::lifecycle::sid::user_sid_hash;
46use crate::broker::protocol::{
47 hello_reply::Result as HelloReplyResult, read_frame, write_frame, ErrorCode, Frame, FrameKind,
48 Hello, HelloReply, PayloadEncoding, CONTROL_PAYLOAD_PROTOCOL, PROTOCOL_VERSION,
49};
50use crate::broker::server::service_def_loader::{
51 service_definition_dir, ServiceDefinitionLoader, SERVICE_DEF_EXTENSION,
52};
53use crate::broker::{secure_dir, FRAMING_VERSION_V1};
54
55use crate::env_vars::EnvVar;
56
57pub const DOCTOR_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
60
61pub const DOCTOR_PROBE_SERVICE: &str = "rp-doctor-probe";
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum DoctorStatus {
71 Pass,
73 Warn,
75 Fail,
77}
78
79impl DoctorStatus {
80 pub fn as_str(self) -> &'static str {
82 match self {
83 DoctorStatus::Pass => "PASS",
84 DoctorStatus::Warn => "WARN",
85 DoctorStatus::Fail => "FAIL",
86 }
87 }
88}
89
90#[derive(Clone, Debug)]
92pub struct DoctorCheck {
93 pub name: String,
95 pub status: DoctorStatus,
97 pub detail: String,
99}
100
101impl DoctorCheck {
102 fn pass(name: impl Into<String>, detail: impl Into<String>) -> Self {
103 Self {
104 name: name.into(),
105 status: DoctorStatus::Pass,
106 detail: detail.into(),
107 }
108 }
109
110 fn warn(name: impl Into<String>, detail: impl Into<String>) -> Self {
111 Self {
112 name: name.into(),
113 status: DoctorStatus::Warn,
114 detail: detail.into(),
115 }
116 }
117
118 fn fail(name: impl Into<String>, detail: impl Into<String>) -> Self {
119 Self {
120 name: name.into(),
121 status: DoctorStatus::Fail,
122 detail: detail.into(),
123 }
124 }
125}
126
127#[derive(Clone, Debug, Default)]
129pub struct DoctorReport {
130 pub checks: Vec<DoctorCheck>,
132}
133
134impl DoctorReport {
135 pub fn has_failures(&self) -> bool {
137 self.checks
138 .iter()
139 .any(|check| check.status == DoctorStatus::Fail)
140 }
141
142 pub fn exit_code(&self) -> i32 {
144 if self.has_failures() {
145 1
146 } else {
147 0
148 }
149 }
150
151 pub fn to_json(&self) -> String {
157 let checks: Vec<serde_json::Value> = self
158 .checks
159 .iter()
160 .map(|check| {
161 serde_json::json!({
162 "check": check.name,
163 "status": check.status.as_str(),
164 "detail": check.detail,
165 })
166 })
167 .collect();
168 serde_json::json!({
169 "schema_version": 1,
170 "command": "doctor",
171 "exit_code": self.exit_code(),
172 "checks": checks,
173 })
174 .to_string()
175 }
176
177 pub fn render_text(&self) -> String {
179 let name_width = self
180 .checks
181 .iter()
182 .map(|check| check.name.len())
183 .max()
184 .unwrap_or(0);
185 let mut out = String::new();
186 for check in &self.checks {
187 out.push_str(&format!(
188 "{:<4} {:<name_width$} {}\n",
189 check.status.as_str(),
190 check.name,
191 check.detail,
192 ));
193 }
194 let pass = self.count(DoctorStatus::Pass);
195 let warn = self.count(DoctorStatus::Warn);
196 let fail = self.count(DoctorStatus::Fail);
197 out.push_str(&format!(
198 "doctor: {} checks — {pass} pass, {warn} warn, {fail} fail\n",
199 self.checks.len()
200 ));
201 out
202 }
203
204 fn count(&self, status: DoctorStatus) -> usize {
205 self.checks
206 .iter()
207 .filter(|check| check.status == status)
208 .count()
209 }
210}
211
212#[derive(Clone, Debug, Default)]
215pub struct DoctorOptions {
216 pub broker_endpoint: Option<String>,
219 pub service_definition_dir: Option<PathBuf>,
222}
223
224pub fn run_doctor(options: &DoctorOptions) -> DoctorReport {
229 let mut checks = Vec::new();
230 checks.extend(isolated("env", env_var_checks));
231 {
232 let endpoint = options.broker_endpoint.clone();
233 checks.extend(isolated("broker:endpoint", move || {
234 vec![broker_endpoint_check(endpoint.as_deref())]
235 }));
236 }
237 {
238 let dir = options
239 .service_definition_dir
240 .clone()
241 .unwrap_or_else(service_definition_dir);
242 checks.extend(isolated("servicedef:dir", move || {
243 service_definition_checks(&dir)
244 }));
245 }
246 checks.extend(isolated("sockets:runtime-dir", || {
247 vec![socket_hygiene_check()]
248 }));
249 checks.extend(isolated("filesystem:inodes", || {
250 vec![inode_pressure_check()]
251 }));
252 checks.extend(isolated("platform:path-budget", || {
253 vec![platform_path_budget_check()]
254 }));
255 checks.extend(isolated("platform:systemd-killmode", || {
256 vec![systemd_killmode_check()]
257 }));
258 checks.extend(isolated("build:version", || vec![version_check()]));
259 DoctorReport { checks }
260}
261
262fn isolated<F>(area: &str, body: F) -> Vec<DoctorCheck>
264where
265 F: FnOnce() -> Vec<DoctorCheck> + std::panic::UnwindSafe,
266{
267 match std::panic::catch_unwind(body) {
268 Ok(checks) => checks,
269 Err(payload) => vec![DoctorCheck::fail(
270 area,
271 format!("check panicked: {}", panic_message(payload.as_ref())),
272 )],
273 }
274}
275
276fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
277 if let Some(message) = payload.downcast_ref::<&str>() {
278 (*message).to_string()
279 } else if let Some(message) = payload.downcast_ref::<String>() {
280 message.clone()
281 } else {
282 "non-string panic payload".to_string()
283 }
284}
285
286pub fn env_var_checks() -> Vec<DoctorCheck> {
292 let mut checks = vec![disable_env_check(), fake_backend_env_check()];
293 checks.push(informational_env_check(
294 crate::env_vars::NO_TRACKING,
295 "unset (daemon IPC tracking enabled)",
296 "daemon IPC tracking disabled",
297 ));
298 checks.push(informational_env_check(
299 crate::env_vars::DAEMON_SCOPE,
300 "unset (user-scoped daemon)",
301 "CWD-scoped daemon (test-isolation mode)",
302 ));
303 checks.push(informational_env_check(
304 crate::env_vars::SERVICE_DEF_DIR,
305 "unset (platform default service-definition dir)",
306 "service-definition dir overridden",
307 ));
308 checks.push(informational_env_check(
309 crate::env_vars::BROKER_V1_SOCKET,
310 "unset (derived broker endpoint)",
311 "broker admin endpoint overridden",
312 ));
313 checks
314}
315
316fn disable_env_check() -> DoctorCheck {
317 let name = format!("env:{RUNNING_PROCESS_DISABLE_ENV}");
318 match broker_disabled_by_env() {
319 Ok(false) => DoctorCheck::pass(name, "unset (broker enabled)"),
320 Ok(true) => DoctorCheck::warn(
321 name,
322 "set to \"1\" — broker disabled; consumers use their direct fallback path",
323 ),
324 Err(err) => DoctorCheck::fail(name, err.to_string()),
325 }
326}
327
328fn fake_backend_env_check() -> DoctorCheck {
329 let name = format!("env:{RUNNING_PROCESS_FAKE_BACKEND_ENV}");
330 match crate::env_vars::FAKE_BACKEND.os() {
331 None => DoctorCheck::pass(name, "unset"),
332 Some(value) if value.is_empty() => {
333 DoctorCheck::warn(name, "set but empty (seam ignored) — unset it")
334 }
335 Some(value) => DoctorCheck::warn(
336 name,
337 format!(
338 "TEST-ONLY seam is set to {:?} — broker negotiation is bypassed; \
339 never set this in production",
340 value.to_string_lossy()
341 ),
342 ),
343 }
344}
345
346fn informational_env_check(env: EnvVar, unset_detail: &str, set_description: &str) -> DoctorCheck {
347 let name = format!("env:{}", env.name);
348 match env.os() {
349 None => DoctorCheck::pass(name, unset_detail),
350 Some(value) => DoctorCheck::warn(
351 name,
352 format!("set to {:?} — {set_description}", value.to_string_lossy()),
353 ),
354 }
355}
356
357pub fn default_broker_endpoint() -> Result<String, String> {
363 let sid_hash = user_sid_hash().map_err(|err| err.to_string())?;
364 let pipe = shared_broker_pipe(&sid_hash).map_err(|err| err.to_string())?;
365 pipe_path_string(pipe.windows, pipe.unix)
366 .ok_or_else(|| "pipe path has no platform form".to_string())
367}
368
369fn pipe_path_string(windows: Option<String>, unix: Option<PathBuf>) -> Option<String> {
370 windows.or_else(|| unix.map(|path| path.to_string_lossy().into_owned()))
371}
372
373pub fn broker_endpoint_check(endpoint: Option<&str>) -> DoctorCheck {
375 const NAME: &str = "broker:endpoint";
376 let endpoint = match endpoint {
377 Some(endpoint) => endpoint.to_string(),
378 None => match default_broker_endpoint() {
379 Ok(endpoint) => endpoint,
380 Err(err) => {
381 return DoctorCheck::fail(NAME, format!("cannot derive broker endpoint: {err}"));
382 }
383 },
384 };
385 let stream = match connect_local_socket(&endpoint) {
386 Ok(stream) => stream,
387 Err(err) => {
388 return DoctorCheck::warn(NAME, format!("no broker listening at {endpoint} ({err})"));
389 }
390 };
391 match hello_probe(stream) {
392 Ok(ProbeOutcome::Negotiated {
393 daemon_version,
394 negotiated_protocol,
395 server_capabilities,
396 }) => DoctorCheck::pass(
397 NAME,
398 format!(
399 "broker listening at {endpoint}: daemon {daemon_version}, \
400 protocol v{negotiated_protocol}, capabilities {}",
401 describe_capabilities(server_capabilities)
402 ),
403 ),
404 Ok(ProbeOutcome::Refused {
405 code,
406 daemon_min_protocol,
407 daemon_max_protocol,
408 }) => DoctorCheck::pass(
409 NAME,
410 format!(
411 "broker listening at {endpoint}: protocol v{daemon_min_protocol}..v{daemon_max_protocol}, \
412 probe refused with {code:?} (expected for the doctor probe service)"
413 ),
414 ),
415 Err(err) => DoctorCheck::warn(
416 NAME,
417 format!("{endpoint} accepted a connection but the v1 Hello probe failed: {err}"),
418 ),
419 }
420}
421
422enum ProbeOutcome {
423 Negotiated {
424 daemon_version: String,
425 negotiated_protocol: u32,
426 server_capabilities: u64,
427 },
428 Refused {
429 code: ErrorCode,
430 daemon_min_protocol: u32,
431 daemon_max_protocol: u32,
432 },
433}
434
435fn hello_probe(stream: crate::platform::ipc::Stream) -> Result<ProbeOutcome, String> {
441 let (result_tx, result_rx) = mpsc::channel();
442 thread::spawn(move || {
443 let mut stream = stream;
444 let _ = result_tx.send(hello_probe_blocking(&mut stream));
445 });
446 match result_rx.recv_timeout(DOCTOR_PROBE_TIMEOUT) {
447 Ok(outcome) => outcome,
448 Err(_) => Err(format!(
449 "no HelloReply within {DOCTOR_PROBE_TIMEOUT:?} (listener is not a v1 broker?)"
450 )),
451 }
452}
453
454fn hello_probe_blocking(stream: &mut crate::platform::ipc::Stream) -> Result<ProbeOutcome, String> {
455 let hello = Hello {
456 client_min_protocol: PROTOCOL_VERSION,
457 client_max_protocol: PROTOCOL_VERSION,
458 service_name: DOCTOR_PROBE_SERVICE.into(),
459 wanted_version: "0.0.0".into(),
460 client_version: env!("CARGO_PKG_VERSION").into(),
461 client_capabilities: 0,
462 auth_token: Vec::new(),
463 request_id: "doctor-probe".into(),
464 connection_id: 0,
465 peer_pid: std::process::id(),
466 client_lib_name: "running-process-doctor".into(),
467 client_lib_version: env!("CARGO_PKG_VERSION").into(),
468 peer_attestation_nonce: Vec::new(),
469 capability_token: Vec::new(),
470 client_keepalive_secs: 0,
471 };
472 let request_frame = Frame {
473 envelope_version: PROTOCOL_VERSION,
474 kind: FrameKind::Request as i32,
475 payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
476 payload: hello.encode_to_vec(),
477 request_id: 1,
478 payload_encoding: PayloadEncoding::None as i32,
479 deadline_unix_ms: 0,
480 traceparent: String::new(),
481 tracestate: String::new(),
482 };
483 write_frame(stream, &request_frame.encode_to_vec())
484 .map_err(|err| format!("failed to write Hello frame: {err}"))?;
485 let response_bytes =
486 read_frame(stream).map_err(|err| format!("failed to read HelloReply frame: {err}"))?;
487 let response_frame = Frame::decode(response_bytes.as_slice())
488 .map_err(|err| format!("failed to decode response Frame: {err}"))?;
489 let reply = HelloReply::decode(response_frame.payload.as_slice())
490 .map_err(|err| format!("failed to decode HelloReply: {err}"))?;
491 match reply.result.ok_or("HelloReply carried no result")? {
492 HelloReplyResult::Negotiated(negotiated) => Ok(ProbeOutcome::Negotiated {
493 daemon_version: negotiated.daemon_version,
494 negotiated_protocol: negotiated.negotiated_protocol,
495 server_capabilities: negotiated.server_capabilities,
496 }),
497 HelloReplyResult::Refused(refused) => Ok(ProbeOutcome::Refused {
498 code: ErrorCode::try_from(refused.code).unwrap_or(ErrorCode::Unspecified),
499 daemon_min_protocol: refused.daemon_min_protocol,
500 daemon_max_protocol: refused.daemon_max_protocol,
501 }),
502 }
503}
504
505pub fn describe_capabilities(bits: u64) -> String {
507 if bits == 0 {
508 return "none".to_string();
509 }
510 let mut names = Vec::new();
511 if bits & CAP_HANDLE_PASSING != 0 {
512 names.push("HANDLE_PASSING".to_string());
513 }
514 let unknown = bits & !CAP_HANDLE_PASSING;
515 if unknown != 0 {
516 names.push(format!("unknown:0x{unknown:x}"));
517 }
518 format!("0x{bits:x} [{}]", names.join(", "))
519}
520
521pub fn service_definition_checks(dir: &Path) -> Vec<DoctorCheck> {
527 const DIR_CHECK: &str = "servicedef:dir";
528 let display = dir.display();
529 if !dir.exists() {
530 return vec![DoctorCheck::warn(
531 DIR_CHECK,
532 format!("{display} does not exist (no service definitions installed)"),
533 )];
534 }
535 if !dir.is_dir() {
536 return vec![DoctorCheck::fail(
537 DIR_CHECK,
538 format!("{display} exists but is not a directory"),
539 )];
540 }
541 match secure_dir::private_dir_permissions_are_private(dir) {
542 Ok(true) => {}
543 Ok(false) => {
544 return vec![DoctorCheck::fail(
545 DIR_CHECK,
546 format!(
547 "{display} has insecure permissions (must be current-user-only); \
548 the broker refuses to load definitions from it"
549 ),
550 )];
551 }
552 Err(err) => {
553 return vec![DoctorCheck::fail(
554 DIR_CHECK,
555 format!("cannot inspect permissions of {display}: {err}"),
556 )];
557 }
558 }
559
560 let entries = match std::fs::read_dir(dir) {
561 Ok(entries) => entries,
562 Err(err) => {
563 return vec![DoctorCheck::fail(
564 DIR_CHECK,
565 format!("cannot enumerate {display}: {err}"),
566 )];
567 }
568 };
569 let mut files: Vec<PathBuf> = entries
570 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
571 .filter(|path| {
572 path.extension()
573 .map(|ext| ext == SERVICE_DEF_EXTENSION)
574 .unwrap_or(false)
575 })
576 .collect();
577 files.sort();
578
579 let mut checks = vec![DoctorCheck::pass(
580 DIR_CHECK,
581 format!(
582 "{display} (private, {} .{SERVICE_DEF_EXTENSION} file{})",
583 files.len(),
584 if files.len() == 1 { "" } else { "s" }
585 ),
586 )];
587
588 let loader = ServiceDefinitionLoader::new(dir);
589 for path in files {
590 let file_name = path
591 .file_name()
592 .map(|name| name.to_string_lossy().into_owned())
593 .unwrap_or_else(|| path.display().to_string());
594 let check_name = format!("servicedef:{file_name}");
595 let Some(service_name) = path
596 .file_stem()
597 .map(|stem| stem.to_string_lossy().into_owned())
598 else {
599 checks.push(DoctorCheck::fail(check_name, "file has no stem"));
600 continue;
601 };
602 match loader.load(&service_name) {
603 Ok(definition) => checks.push(DoctorCheck::pass(
604 check_name,
605 format!(
606 "valid (service {:?}, binary {:?})",
607 definition.service_name, definition.binary_path
608 ),
609 )),
610 Err(err) => checks.push(DoctorCheck::fail(check_name, err.to_string())),
611 }
612 }
613 checks
614}
615
616pub fn socket_hygiene_check() -> DoctorCheck {
626 const NAME: &str = "sockets:runtime-dir";
627 if !crate::platform::ipc::endpoint_is_filesystem_backed() {
633 return DoctorCheck::pass(
634 NAME,
635 "not applicable: this host's endpoints leave no filesystem residue",
636 );
637 }
638 {
639 let Some(dir) = broker_runtime_dir() else {
640 return DoctorCheck::fail(NAME, "cannot derive broker runtime directory");
641 };
642 let display = dir.display();
643 if !dir.exists() {
644 return DoctorCheck::pass(NAME, format!("{display} does not exist (no sockets)"));
645 }
646 let entries = match std::fs::read_dir(&dir) {
647 Ok(entries) => entries,
648 Err(err) => {
649 return DoctorCheck::fail(NAME, format!("cannot enumerate {display}: {err}"));
650 }
651 };
652 let mut total = 0usize;
653 let mut stale = 0usize;
654 for path in entries.filter_map(|entry| entry.ok().map(|entry| entry.path())) {
655 if path.extension().map(|ext| ext == "sock").unwrap_or(false) {
656 total += 1;
657 let endpoint = path.to_string_lossy();
658 if let Err(err) = connect_local_socket(&endpoint) {
659 if err.kind() == std::io::ErrorKind::ConnectionRefused {
660 stale += 1;
661 }
662 }
663 }
664 }
665 if stale == 0 {
666 DoctorCheck::pass(
667 NAME,
668 format!("{display}: {total} socket file(s), none stale"),
669 )
670 } else {
671 DoctorCheck::warn(
672 NAME,
673 format!(
674 "{display}: {stale} of {total} socket file(s) are stale \
675 (connect refused) — not deleted, doctor is read-only"
676 ),
677 )
678 }
679 }
680}
681
682fn broker_runtime_dir() -> Option<PathBuf> {
689 let sid_hash = user_sid_hash().ok()?;
690 let pipe = shared_broker_pipe(&sid_hash).ok()?;
691 pipe.unix
692 .and_then(|path| path.parent().map(Path::to_path_buf))
693}
694
695const INODE_WARN_FREE_RATIO: f64 = 0.05;
701const INODE_FAIL_FREE_RATIO: f64 = 0.01;
703
704pub fn inode_pressure_check() -> DoctorCheck {
710 const NAME: &str = "filesystem:inodes";
711 let dir = crate::client::paths::data_dir();
712 let display = dir.display();
713 match crate::broker::fs_health::daemon_data_dir_inode_usage() {
714 Ok(Some(usage)) => {
715 let free_ratio = if usage.total == 0 {
716 1.0
717 } else {
718 usage.free as f64 / usage.total as f64
719 };
720 let detail = format!(
721 "{display}: {} of {} inodes free ({:.1}% used)",
722 usage.free,
723 usage.total,
724 usage.used_ratio() * 100.0
725 );
726 if free_ratio < INODE_FAIL_FREE_RATIO {
727 DoctorCheck::fail(
728 NAME,
729 format!("{detail} — inode exhaustion imminent; daemon writes will ENOSPC"),
730 )
731 } else if free_ratio < INODE_WARN_FREE_RATIO {
732 DoctorCheck::warn(NAME, format!("{detail} — low inode headroom"))
733 } else {
734 DoctorCheck::pass(NAME, detail)
735 }
736 }
737 Ok(None) => DoctorCheck::pass(
738 NAME,
739 format!("{display}: filesystem has no fixed inode table (not applicable)"),
740 ),
741 Err(err) => DoctorCheck::warn(
742 NAME,
743 format!("cannot probe inode usage of {display}: {err}"),
744 ),
745 }
746}
747
748const PATH_BUDGET_WARN_SLACK: usize = 8;
754
755pub fn platform_path_budget_check() -> DoctorCheck {
759 const NAME: &str = "platform:path-budget";
760 let budget = crate::platform::ipc::endpoint_name_limit();
761 let (limit, limit_label) = (budget.max_bytes, budget.label);
762 let sid_hash = match user_sid_hash() {
763 Ok(hash) => hash,
764 Err(err) => {
765 return DoctorCheck::fail(NAME, format!("cannot compute user SID hash: {err}"));
766 }
767 };
768 match backend_pipe(&sid_hash, &[0u8; 16]) {
771 Ok(pipe) => {
772 let Some(path) = pipe_path_string(pipe.windows, pipe.unix) else {
773 return DoctorCheck::fail(NAME, "derived pipe path has no platform form");
774 };
775 let len = path.len();
776 let detail =
777 format!("backend pipe path is {len} of {limit} bytes ({limit_label}): {path}");
778 if len + PATH_BUDGET_WARN_SLACK >= limit {
779 DoctorCheck::warn(
780 NAME,
781 format!("{detail} — within {PATH_BUDGET_WARN_SLACK} bytes of the limit"),
782 )
783 } else {
784 DoctorCheck::pass(NAME, detail)
785 }
786 }
787 Err(err @ PipePathError::PathTooLong { .. }) => DoctorCheck::fail(
788 NAME,
789 format!("derived backend pipe path exceeds the {limit_label} budget: {err}"),
790 ),
791 Err(err) => DoctorCheck::fail(NAME, format!("cannot derive backend pipe path: {err}")),
792 }
793}
794
795pub fn systemd_killmode_check() -> DoctorCheck {
803 const NAME: &str = "platform:systemd-killmode";
804 use crate::systemd_killmode::{probe, KillModeAssessment};
805 let assessment = probe();
806 match assessment.warning() {
807 Some(warning) => DoctorCheck::warn(NAME, warning),
808 None => match assessment {
809 KillModeAssessment::Safe { unit, kill_mode } => DoctorCheck::pass(
810 NAME,
811 format!("systemd unit {unit} uses KillMode={kill_mode} (children survive stop)"),
812 ),
813 _ => DoctorCheck::pass(
814 NAME,
815 if crate::platform::host::process_cgroup().is_some() {
816 "not running under systemd"
817 } else {
818 "not applicable on this platform"
819 },
820 ),
821 },
822 }
823}
824
825pub fn version_check() -> DoctorCheck {
831 DoctorCheck::pass(
832 "build:version",
833 format!(
834 "running-process {} — protocol v{PROTOCOL_VERSION}, framing v{FRAMING_VERSION_V1}",
835 env!("CARGO_PKG_VERSION")
836 ),
837 )
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843
844 fn check(status: DoctorStatus) -> DoctorCheck {
845 DoctorCheck {
846 name: "test:check".into(),
847 status,
848 detail: "detail".into(),
849 }
850 }
851
852 #[test]
853 fn exit_code_is_zero_without_failures() {
854 let report = DoctorReport {
855 checks: vec![check(DoctorStatus::Pass), check(DoctorStatus::Warn)],
856 };
857 assert!(!report.has_failures());
858 assert_eq!(report.exit_code(), 0);
859 }
860
861 #[test]
862 fn exit_code_is_one_with_any_failure() {
863 let report = DoctorReport {
864 checks: vec![check(DoctorStatus::Pass), check(DoctorStatus::Fail)],
865 };
866 assert!(report.has_failures());
867 assert_eq!(report.exit_code(), 1);
868 }
869
870 #[test]
871 fn isolated_converts_panics_into_fail_checks() {
872 let checks = isolated("area:test", || panic!("boom"));
873 assert_eq!(checks.len(), 1);
874 assert_eq!(checks[0].status, DoctorStatus::Fail);
875 assert!(checks[0].detail.contains("boom"));
876 }
877
878 #[test]
879 fn describe_capabilities_names_known_bits() {
880 assert_eq!(describe_capabilities(0), "none");
881 assert_eq!(describe_capabilities(1), "0x1 [HANDLE_PASSING]");
882 let mixed = describe_capabilities(0b11);
883 assert!(mixed.contains("HANDLE_PASSING"));
884 assert!(mixed.contains("unknown:0x2"));
885 }
886
887 #[test]
888 fn render_text_includes_summary_line() {
889 let report = DoctorReport {
890 checks: vec![check(DoctorStatus::Pass), check(DoctorStatus::Warn)],
891 };
892 let text = report.render_text();
893 assert!(text.contains("PASS"));
894 assert!(text.contains("WARN"));
895 assert!(text.contains("doctor: 2 checks — 1 pass, 1 warn, 0 fail"));
896 }
897}
898
899#[cfg(test)]
900#[path = "../tests/broker_doctor_coverage.rs"]
901mod coverage_tests;