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