1use serde::Serialize;
12
13use crate::project::ProjectContext;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum DebugTargetKind {
19 ZephyrMcu,
21 BaremetalMcu,
23 YoctoUserspace,
25 NativeHost,
27}
28
29impl DebugTargetKind {
30 pub fn as_str(self) -> &'static str {
32 match self {
33 DebugTargetKind::ZephyrMcu => "zephyr-mcu",
34 DebugTargetKind::BaremetalMcu => "baremetal-mcu",
35 DebugTargetKind::YoctoUserspace => "yocto-userspace",
36 DebugTargetKind::NativeHost => "native-host",
37 }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "lowercase")]
44pub enum DebugServerKind {
45 Jlink,
47 Openocd,
49 Pyocd,
51 Gdbserver,
53 None,
55}
56
57impl DebugServerKind {
58 pub fn as_str(self) -> &'static str {
60 match self {
61 DebugServerKind::Jlink => "jlink",
62 DebugServerKind::Openocd => "openocd",
63 DebugServerKind::Pyocd => "pyocd",
64 DebugServerKind::Gdbserver => "gdbserver",
65 DebugServerKind::None => "none",
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "lowercase")]
73pub enum DoctorStatus {
74 Pass,
76 Warn,
78 Fail,
80}
81
82pub fn parse_target_kind(raw: Option<&str>) -> Result<DebugTargetKind, String> {
85 match raw.unwrap_or("") {
86 "" | "native-host" => Ok(DebugTargetKind::NativeHost),
87 "zephyr-mcu" => Ok(DebugTargetKind::ZephyrMcu),
88 "baremetal-mcu" => Ok(DebugTargetKind::BaremetalMcu),
89 "yocto-userspace" => Ok(DebugTargetKind::YoctoUserspace),
90 other => Err(format!(
91 "Unsupported --target-kind '{other}'. Allowed values: zephyr-mcu, baremetal-mcu, yocto-userspace, native-host."
92 )),
93 }
94}
95
96pub fn parse_server_kind(raw: Option<&str>) -> Result<DebugServerKind, String> {
99 match raw.unwrap_or("") {
100 "" | "none" => Ok(DebugServerKind::None),
101 "jlink" => Ok(DebugServerKind::Jlink),
102 "openocd" => Ok(DebugServerKind::Openocd),
103 "pyocd" => Ok(DebugServerKind::Pyocd),
104 "gdbserver" => Ok(DebugServerKind::Gdbserver),
105 other => Err(format!(
106 "Unsupported --server '{other}'. Allowed values: jlink, openocd, pyocd, gdbserver, none."
107 )),
108 }
109}
110
111pub fn server_choices_for_target(target: DebugTargetKind) -> &'static [DebugServerKind] {
113 match target {
114 DebugTargetKind::ZephyrMcu | DebugTargetKind::BaremetalMcu => &[
115 DebugServerKind::Jlink,
116 DebugServerKind::Openocd,
117 DebugServerKind::Pyocd,
118 ],
119 DebugTargetKind::YoctoUserspace => &[DebugServerKind::Gdbserver],
120 DebugTargetKind::NativeHost => &[DebugServerKind::None],
121 }
122}
123
124pub fn is_server_supported_for_target(target: DebugTargetKind, server: DebugServerKind) -> bool {
126 server_choices_for_target(target).contains(&server)
127}
128
129#[derive(Debug, Clone, Copy, Serialize)]
131#[serde(rename_all = "camelCase")]
132pub struct DebuggerExtensionsState {
133 pub cortex_debug: bool,
135 pub cpp_tools: bool,
137 #[serde(rename = "codeLLDB")]
139 pub code_lldb: bool,
140}
141
142#[derive(Debug, Clone)]
144pub struct DebugRuntimeCapabilities {
145 pub python_available: bool,
147 pub jlink_executable: Option<String>,
149 pub open_ocd_executable: Option<String>,
151 pub pyocd_executable: Option<String>,
153 pub gdb_executable: Option<String>,
155 pub lldb_executable: Option<String>,
157}
158
159#[derive(Debug, Clone, Serialize)]
163#[serde(rename_all = "camelCase")]
164pub struct DebugWorkspaceContext {
165 pub generated_at: String,
167 pub workspace_root: Option<String>,
169 pub sdk_root: Option<String>,
171 pub board_yaml_path: Option<String>,
173 pub west_cwd: Option<String>,
175 pub python_binary: String,
177 pub board_yaml_exists: bool,
179 pub debugger_extensions: DebuggerExtensionsState,
181}
182
183pub fn create_debug_workspace_context(
186 project: &ProjectContext,
187 generated_at: String,
188 board_yaml_exists: impl Fn(&str) -> bool,
189 debugger_extensions: DebuggerExtensionsState,
190) -> DebugWorkspaceContext {
191 let exists = match &project.board_yaml_path {
192 Some(path) => board_yaml_exists(path),
193 None => false,
194 };
195 DebugWorkspaceContext {
196 generated_at,
197 workspace_root: project.workspace_root.clone(),
198 sdk_root: project.sdk_root.clone(),
199 board_yaml_path: project.board_yaml_path.clone(),
200 west_cwd: project.west_cwd.clone(),
201 python_binary: project.python_binary.clone(),
202 board_yaml_exists: exists,
203 debugger_extensions,
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
211#[serde(rename_all = "lowercase")]
212pub enum DebugValueSource {
213 Workspace,
215 Setting,
217 Default,
219 Runtime,
221 Derived,
223 Unresolved,
225}
226
227#[derive(Debug, Clone, Serialize)]
229pub struct DebugResolvedValue {
230 pub key: String,
232 pub value: serde_json::Value,
234 pub source: DebugValueSource,
236 pub detail: String,
238}
239
240pub fn collect_resolved_values(context: &DebugWorkspaceContext) -> Vec<DebugResolvedValue> {
243 use serde_json::Value;
244
245 let opt_value = |v: &Option<String>| match v {
246 Some(s) => Value::String(s.clone()),
247 None => Value::Null,
248 };
249
250 vec![
251 DebugResolvedValue {
252 key: "workspaceRoot".to_string(),
253 value: opt_value(&context.workspace_root),
254 source: if context.workspace_root.is_some() {
255 DebugValueSource::Workspace
256 } else {
257 DebugValueSource::Unresolved
258 },
259 detail: if context.workspace_root.is_some() {
260 "Resolved from the active workspace folder."
261 } else {
262 "No workspace folder is open."
263 }
264 .to_string(),
265 },
266 DebugResolvedValue {
267 key: "sdkRoot".to_string(),
268 value: opt_value(&context.sdk_root),
269 source: if context.sdk_root.is_some() {
270 DebugValueSource::Workspace
271 } else {
272 DebugValueSource::Unresolved
273 },
274 detail: if context.sdk_root.is_some() {
275 "Resolved alp-sdk root used for scripts and schemas."
276 } else {
277 "Set alpSdk.path when automatic discovery is ambiguous."
278 }
279 .to_string(),
280 },
281 DebugResolvedValue {
282 key: "boardYamlPath".to_string(),
283 value: opt_value(&context.board_yaml_path),
284 source: if context.board_yaml_path.is_some() {
285 DebugValueSource::Setting
286 } else {
287 DebugValueSource::Unresolved
288 },
289 detail: if context.board_yaml_path.is_some() {
290 "Resolved board.yaml path from project settings."
291 } else {
292 "board.yaml path is unresolved."
293 }
294 .to_string(),
295 },
296 DebugResolvedValue {
297 key: "boardYamlExists".to_string(),
298 value: serde_json::Value::Bool(context.board_yaml_exists),
299 source: DebugValueSource::Runtime,
300 detail: if context.board_yaml_exists {
301 "board.yaml exists at the resolved path."
302 } else {
303 "board.yaml is missing at the resolved path."
304 }
305 .to_string(),
306 },
307 DebugResolvedValue {
308 key: "westCwd".to_string(),
309 value: opt_value(&context.west_cwd),
310 source: if context.west_cwd.is_some() {
311 DebugValueSource::Setting
312 } else {
313 DebugValueSource::Default
314 },
315 detail: if context.west_cwd.is_some() {
316 "Working directory used for west commands."
317 } else {
318 "Defaults to the workspace root."
319 }
320 .to_string(),
321 },
322 DebugResolvedValue {
323 key: "pythonBinary".to_string(),
324 value: serde_json::Value::String(context.python_binary.clone()),
325 source: if context.python_binary == "python3" || context.python_binary == "python" {
326 DebugValueSource::Default
327 } else {
328 DebugValueSource::Setting
329 },
330 detail: "Interpreter used for loader and validation scripts.".to_string(),
331 },
332 ]
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
339#[serde(rename_all = "lowercase")]
340pub enum DebugTraceOutcome {
341 Planned,
343 Written,
345 Failed,
347}
348
349#[derive(Debug, Clone, Serialize)]
351pub struct DebugGenerationTraceDecision {
352 pub key: String,
354 pub outcome: DebugTraceOutcome,
356 #[serde(rename = "outputPath", skip_serializing_if = "Option::is_none")]
358 pub output_path: Option<String>,
359 pub detail: String,
361}
362
363pub fn collect_runtime_capabilities_from_commands(
365 project: &ProjectContext,
366 command_on_path: impl Fn(&str) -> bool,
367) -> DebugRuntimeCapabilities {
368 let first_available = |commands: &[&str]| -> Option<String> {
369 commands
370 .iter()
371 .find(|c| command_on_path(c))
372 .map(|c| (*c).to_string())
373 };
374
375 DebugRuntimeCapabilities {
376 python_available: command_on_path(&project.python_binary),
377 jlink_executable: first_available(&["JLinkGDBServerCL", "JLinkGDBServer"]),
378 open_ocd_executable: first_available(&["openocd"]),
379 pyocd_executable: first_available(&["pyocd"]),
380 gdb_executable: first_available(&["gdb", "arm-none-eabi-gdb"]),
381 lldb_executable: first_available(&["lldb-dap", "lldb"]),
382 }
383}
384
385#[derive(Debug, Clone, Serialize)]
387pub struct DoctorCheck {
388 pub name: String,
390 pub status: DoctorStatus,
392 pub detail: String,
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub fix: Option<String>,
397}
398
399impl DoctorCheck {
400 fn new(name: &str, status: DoctorStatus, detail: String, fix: Option<String>) -> Self {
401 Self {
402 name: name.to_string(),
403 status,
404 detail,
405 fix,
406 }
407 }
408}
409
410#[derive(Debug, Clone, Serialize)]
412pub struct DoctorSummary {
413 pub pass: u32,
415 pub warn: u32,
417 pub fail: u32,
419}
420
421#[derive(Debug, Clone, Serialize)]
423pub struct DoctorReport {
424 #[serde(rename = "generatedAt")]
426 pub generated_at: String,
427 #[serde(rename = "targetKind")]
429 pub target_kind: DebugTargetKind,
430 pub server: DebugServerKind,
432 pub summary: DoctorSummary,
434 pub checks: Vec<DoctorCheck>,
436 #[serde(rename = "nextSteps")]
438 pub next_steps: Vec<String>,
439}
440
441pub fn build_doctor_report(
443 context: &DebugWorkspaceContext,
444 target: DebugTargetKind,
445 server: DebugServerKind,
446 runtime: &DebugRuntimeCapabilities,
447) -> DoctorReport {
448 let has_workspace = is_present(&context.workspace_root);
449 let has_sdk = is_present(&context.sdk_root);
450
451 let mut checks: Vec<DoctorCheck> = vec![
452 DoctorCheck::new(
453 "workspaceRoot",
454 status_pass_fail(has_workspace),
455 context
456 .workspace_root
457 .clone()
458 .unwrap_or_else(|| "No workspace folder is open.".to_string()),
459 fix_when(
460 !has_workspace,
461 "Open a workspace containing an ALP project.",
462 ),
463 ),
464 DoctorCheck::new(
465 "sdkRoot",
466 status_pass_fail(has_sdk),
467 context.sdk_root.clone().unwrap_or_else(|| {
468 "The extension could not resolve an alp-sdk checkout.".to_string()
469 }),
470 fix_when(
471 !has_sdk,
472 "Configure alpSdk.path or open a workspace near an alp-sdk checkout.",
473 ),
474 ),
475 DoctorCheck::new(
476 "boardYaml",
477 status_pass_fail(context.board_yaml_exists),
478 context
479 .board_yaml_path
480 .clone()
481 .unwrap_or_else(|| "board.yaml path is unresolved.".to_string()),
482 fix_when(
483 !context.board_yaml_exists,
484 "Create board.yaml or configure alpSdk.boardYamlPath.",
485 ),
486 ),
487 DoctorCheck::new(
488 "python",
489 status_pass_warn(runtime.python_available),
490 format!("Interpreter probe: {}", context.python_binary),
491 fix_when(
492 !runtime.python_available,
493 "Install the configured Python interpreter or update alpSdk.pythonPath.",
494 ),
495 ),
496 ];
497
498 if !is_server_supported_for_target(target, server) {
499 checks.push(DoctorCheck::new(
500 "serverCompatibility",
501 DoctorStatus::Fail,
502 format!(
503 "{} is not supported for {}.",
504 server.as_str(),
505 target.as_str()
506 ),
507 Some("Pick a supported backend for the selected target class.".to_string()),
508 ));
509 return finalize_report(context.generated_at.clone(), target, server, checks);
510 }
511
512 match target {
513 DebugTargetKind::ZephyrMcu | DebugTargetKind::BaremetalMcu => {
514 let installed = context.debugger_extensions.cortex_debug;
515 checks.push(DoctorCheck::new(
516 "cortexDebugExtension",
517 status_pass_fail(installed),
518 if installed {
519 "marus25.cortex-debug is installed."
520 } else {
521 "marus25.cortex-debug is not installed."
522 }
523 .to_string(),
524 fix_when(!installed, "Install marus25.cortex-debug."),
525 ));
526 checks.push(create_backend_check(server, runtime));
527 }
528 DebugTargetKind::YoctoUserspace => {
529 let installed = context.debugger_extensions.cpp_tools;
530 checks.push(DoctorCheck::new(
531 "cppToolsExtension",
532 status_pass_fail(installed),
533 if installed {
534 "ms-vscode.cpptools is installed."
535 } else {
536 "ms-vscode.cpptools is not installed."
537 }
538 .to_string(),
539 fix_when(!installed, "Install ms-vscode.cpptools."),
540 ));
541 let gdb = runtime.gdb_executable.clone();
542 checks.push(DoctorCheck::new(
543 "gdb",
544 status_pass_warn(gdb.is_some()),
545 gdb.unwrap_or_else(|| "No local gdb executable was found on PATH.".to_string()),
546 fix_when(
547 runtime.gdb_executable.is_none(),
548 "Install gdb locally for symbolized remote debugging.",
549 ),
550 ));
551 }
552 DebugTargetKind::NativeHost => {
553 let installed = context.debugger_extensions.code_lldb;
554 checks.push(DoctorCheck::new(
555 "codeLLDBExtension",
556 status_pass_fail(installed),
557 if installed {
558 "vadimcn.vscode-lldb is installed."
559 } else {
560 "vadimcn.vscode-lldb is not installed."
561 }
562 .to_string(),
563 fix_when(!installed, "Install vadimcn.vscode-lldb."),
564 ));
565 let lldb = runtime.lldb_executable.clone();
566 checks.push(DoctorCheck::new(
567 "lldb",
568 status_pass_warn(lldb.is_some()),
569 lldb.unwrap_or_else(|| "No local LLDB executable was found on PATH.".to_string()),
570 fix_when(
571 runtime.lldb_executable.is_none(),
572 "Install LLDB or lldb-dap for native-host debug flows.",
573 ),
574 ));
575 }
576 }
577
578 finalize_report(context.generated_at.clone(), target, server, checks)
579}
580
581fn create_backend_check(
582 server: DebugServerKind,
583 runtime: &DebugRuntimeCapabilities,
584) -> DoctorCheck {
585 let executable = resolve_backend_executable(server, runtime);
586 let found = executable.is_some();
587 DoctorCheck::new(
588 &format!("{}Backend", server.as_str()),
589 status_pass_warn(found),
590 executable
591 .unwrap_or_else(|| format!("No {} executable was found on PATH.", server.as_str())),
592 fix_when_owned(
593 !found,
594 format!("Install {} and make sure it is on PATH.", server.as_str()),
595 ),
596 )
597}
598
599fn resolve_backend_executable(
600 server: DebugServerKind,
601 runtime: &DebugRuntimeCapabilities,
602) -> Option<String> {
603 match server {
604 DebugServerKind::Jlink => runtime.jlink_executable.clone(),
605 DebugServerKind::Openocd => runtime.open_ocd_executable.clone(),
606 DebugServerKind::Pyocd => runtime.pyocd_executable.clone(),
607 DebugServerKind::Gdbserver => runtime.gdb_executable.clone(),
608 DebugServerKind::None => runtime.lldb_executable.clone(),
609 }
610}
611
612fn finalize_report(
613 generated_at: String,
614 target: DebugTargetKind,
615 server: DebugServerKind,
616 checks: Vec<DoctorCheck>,
617) -> DoctorReport {
618 let summary = DoctorSummary {
619 pass: count_status(&checks, DoctorStatus::Pass),
620 warn: count_status(&checks, DoctorStatus::Warn),
621 fail: count_status(&checks, DoctorStatus::Fail),
622 };
623 let next_steps = unique_next_steps(&checks);
624 DoctorReport {
625 generated_at,
626 target_kind: target,
627 server,
628 summary,
629 checks,
630 next_steps,
631 }
632}
633
634fn count_status(checks: &[DoctorCheck], status: DoctorStatus) -> u32 {
635 checks.iter().filter(|c| c.status == status).count() as u32
636}
637
638fn unique_next_steps(checks: &[DoctorCheck]) -> Vec<String> {
639 let mut steps: Vec<String> = Vec::new();
640 for check in checks {
641 if check.status == DoctorStatus::Pass {
642 continue;
643 }
644 if let Some(fix) = &check.fix {
645 if !steps.contains(fix) {
646 steps.push(fix.clone());
647 }
648 }
649 }
650 steps
651}
652
653fn is_present(value: &Option<String>) -> bool {
654 value.as_deref().is_some_and(|s| !s.is_empty())
655}
656
657fn status_pass_fail(ok: bool) -> DoctorStatus {
658 if ok {
659 DoctorStatus::Pass
660 } else {
661 DoctorStatus::Fail
662 }
663}
664
665fn status_pass_warn(ok: bool) -> DoctorStatus {
666 if ok {
667 DoctorStatus::Pass
668 } else {
669 DoctorStatus::Warn
670 }
671}
672
673fn fix_when(unhealthy: bool, fix: &str) -> Option<String> {
674 unhealthy.then(|| fix.to_string())
675}
676
677fn fix_when_owned(unhealthy: bool, fix: String) -> Option<String> {
678 unhealthy.then_some(fix)
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 fn extensions_all_installed() -> DebuggerExtensionsState {
686 DebuggerExtensionsState {
687 cortex_debug: true,
688 cpp_tools: true,
689 code_lldb: true,
690 }
691 }
692
693 fn healthy_context() -> DebugWorkspaceContext {
694 DebugWorkspaceContext {
695 generated_at: "1970-01-01T00:00:00.000Z".to_string(),
696 workspace_root: Some("/work/proj".to_string()),
697 sdk_root: Some("/work/alp-sdk".to_string()),
698 board_yaml_path: Some("/work/proj/board.yaml".to_string()),
699 west_cwd: Some("/work/proj".to_string()),
700 python_binary: "python3".to_string(),
701 board_yaml_exists: true,
702 debugger_extensions: extensions_all_installed(),
703 }
704 }
705
706 fn runtime_all_present() -> DebugRuntimeCapabilities {
707 DebugRuntimeCapabilities {
708 python_available: true,
709 jlink_executable: Some("JLinkGDBServerCL".to_string()),
710 open_ocd_executable: Some("openocd".to_string()),
711 pyocd_executable: Some("pyocd".to_string()),
712 gdb_executable: Some("gdb".to_string()),
713 lldb_executable: Some("lldb".to_string()),
714 }
715 }
716
717 fn runtime_none() -> DebugRuntimeCapabilities {
718 DebugRuntimeCapabilities {
719 python_available: false,
720 jlink_executable: None,
721 open_ocd_executable: None,
722 pyocd_executable: None,
723 gdb_executable: None,
724 lldb_executable: None,
725 }
726 }
727
728 #[test]
729 fn parse_defaults_match_ts() {
730 assert_eq!(
731 parse_target_kind(None).unwrap(),
732 DebugTargetKind::NativeHost
733 );
734 assert_eq!(
735 parse_target_kind(Some("")).unwrap(),
736 DebugTargetKind::NativeHost
737 );
738 assert_eq!(parse_server_kind(None).unwrap(), DebugServerKind::None);
739 assert_eq!(
740 parse_target_kind(Some("zephyr-mcu")).unwrap(),
741 DebugTargetKind::ZephyrMcu
742 );
743 }
744
745 #[test]
746 fn parse_unknown_values_error_with_ts_message() {
747 let err = parse_target_kind(Some("bogus")).unwrap_err();
748 assert!(err.contains("Unsupported --target-kind 'bogus'"));
749 assert!(err.contains("zephyr-mcu, baremetal-mcu, yocto-userspace, native-host"));
750 let err = parse_server_kind(Some("bogus")).unwrap_err();
751 assert!(err.contains("Unsupported --server 'bogus'"));
752 }
753
754 #[test]
755 fn server_support_matrix() {
756 assert!(is_server_supported_for_target(
757 DebugTargetKind::ZephyrMcu,
758 DebugServerKind::Jlink
759 ));
760 assert!(!is_server_supported_for_target(
761 DebugTargetKind::NativeHost,
762 DebugServerKind::Jlink
763 ));
764 assert!(is_server_supported_for_target(
765 DebugTargetKind::YoctoUserspace,
766 DebugServerKind::Gdbserver
767 ));
768 }
769
770 #[test]
771 fn native_host_all_green_passes() {
772 let report = build_doctor_report(
773 &healthy_context(),
774 DebugTargetKind::NativeHost,
775 DebugServerKind::None,
776 &runtime_all_present(),
777 );
778 assert_eq!(report.checks.len(), 6);
780 assert_eq!(report.summary.pass, 6);
781 assert_eq!(report.summary.warn, 0);
782 assert_eq!(report.summary.fail, 0);
783 assert!(report.next_steps.is_empty());
784 assert_eq!(report.target_kind, DebugTargetKind::NativeHost);
785 }
786
787 #[test]
788 fn zephyr_with_missing_runtime_warns_backend() {
789 let report = build_doctor_report(
790 &healthy_context(),
791 DebugTargetKind::ZephyrMcu,
792 DebugServerKind::Jlink,
793 &runtime_none(),
794 );
795 let backend = report
797 .checks
798 .iter()
799 .find(|c| c.name == "jlinkBackend")
800 .expect("backend check present");
801 assert_eq!(backend.status, DoctorStatus::Warn);
802 assert!(backend.detail.contains("No jlink executable"));
803 assert_eq!(report.summary.warn, 2);
805 assert_eq!(report.summary.fail, 0);
806 assert!(
807 report
808 .next_steps
809 .iter()
810 .any(|s| s.contains("Install jlink"))
811 );
812 }
813
814 #[test]
815 fn missing_workspace_and_sdk_fail() {
816 let mut ctx = healthy_context();
817 ctx.workspace_root = None;
818 ctx.sdk_root = None;
819 ctx.board_yaml_exists = false;
820 let report = build_doctor_report(
821 &ctx,
822 DebugTargetKind::NativeHost,
823 DebugServerKind::None,
824 &runtime_all_present(),
825 );
826 assert_eq!(report.summary.fail, 3); let workspace = &report.checks[0];
828 assert_eq!(workspace.status, DoctorStatus::Fail);
829 assert_eq!(workspace.detail, "No workspace folder is open.");
830 assert!(workspace.fix.is_some());
831 }
832
833 #[test]
834 fn unsupported_server_branch_short_circuits() {
835 let report = build_doctor_report(
836 &healthy_context(),
837 DebugTargetKind::NativeHost,
838 DebugServerKind::Jlink,
839 &runtime_all_present(),
840 );
841 let compat = report.checks.last().expect("a check present");
842 assert_eq!(compat.name, "serverCompatibility");
843 assert_eq!(compat.status, DoctorStatus::Fail);
844 assert_eq!(compat.detail, "jlink is not supported for native-host.");
845 assert_eq!(report.summary.fail, 1);
846 assert_eq!(report.checks.len(), 5);
848 }
849
850 #[test]
851 fn report_serializes_with_contract_field_names() {
852 let report = build_doctor_report(
853 &healthy_context(),
854 DebugTargetKind::NativeHost,
855 DebugServerKind::None,
856 &runtime_all_present(),
857 );
858 let json = serde_json::to_string(&report).unwrap();
859 assert!(json.contains("\"generatedAt\":\"1970-01-01T00:00:00.000Z\""));
860 assert!(json.contains("\"targetKind\":\"native-host\""));
861 assert!(json.contains("\"server\":\"none\""));
862 assert!(json.contains("\"nextSteps\":[]"));
863 assert!(!json.contains("\"fix\""));
865 }
866}