Skip to main content

alp_core/
debug.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Debug `doctor` domain — a port of the TypeScript `@alp-sdk/core/debug`
3//! doctor logic (`buildDoctorReport`, `serverChoicesForTarget`, and the
4//! runtime-capability/workspace-context adapters).
5//!
6//! All logic here is pure: runtime capabilities and `board.yaml` existence are
7//! injected as predicates, so doctor parity with the TS implementation is
8//! guaranteed by deterministic unit tests rather than by the (machine-
9//! dependent) golden-fixture harness.
10
11use serde::Serialize;
12
13use crate::project::ProjectContext;
14
15/// Class of debug target a project produces; drives which servers and checks apply.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum DebugTargetKind {
19    /// Zephyr RTOS firmware on an MCU.
20    ZephyrMcu,
21    /// Bare-metal firmware on an MCU.
22    BaremetalMcu,
23    /// A userspace process on a Yocto/Linux image.
24    YoctoUserspace,
25    /// A native executable on the host machine.
26    NativeHost,
27}
28
29impl DebugTargetKind {
30    /// The kebab-case wire string for this kind (matches the serde representation).
31    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/// Debug-server / GDB backend used to attach to a target.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "lowercase")]
44pub enum DebugServerKind {
45    /// SEGGER J-Link GDB server.
46    Jlink,
47    /// OpenOCD.
48    Openocd,
49    /// pyOCD.
50    Pyocd,
51    /// A remote `gdbserver` (Yocto userspace).
52    Gdbserver,
53    /// No server (native-host debugging).
54    None,
55}
56
57impl DebugServerKind {
58    /// The lowercase wire string for this kind (matches the serde representation).
59    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/// Outcome of a single doctor check.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "lowercase")]
73pub enum DoctorStatus {
74    /// Check passed.
75    Pass,
76    /// Non-fatal issue (degraded but usable).
77    Warn,
78    /// Blocking failure.
79    Fail,
80}
81
82/// Parse `--target-kind`. Mirrors TS `parseTargetKind`: an absent/empty value
83/// defaults to `native-host`; an unknown value is an error.
84pub 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
96/// Parse `--server`. Mirrors TS `parseServerKind`: absent/empty defaults to
97/// `none`; an unknown value is an error.
98pub 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
111/// The debug servers valid for a given target kind.
112pub 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
124/// Whether `server` is among the valid choices for `target`.
125pub fn is_server_supported_for_target(target: DebugTargetKind, server: DebugServerKind) -> bool {
126    server_choices_for_target(target).contains(&server)
127}
128
129/// Which VS Code debugger extensions are installed in the host.
130#[derive(Debug, Clone, Copy, Serialize)]
131#[serde(rename_all = "camelCase")]
132pub struct DebuggerExtensionsState {
133    /// `marus25.cortex-debug` is installed.
134    pub cortex_debug: bool,
135    /// `ms-vscode.cpptools` is installed.
136    pub cpp_tools: bool,
137    /// `vadimcn.vscode-lldb` is installed.
138    #[serde(rename = "codeLLDB")]
139    pub code_lldb: bool,
140}
141
142/// Probed availability of debug tooling on the host (PATH lookups + interpreter).
143#[derive(Debug, Clone)]
144pub struct DebugRuntimeCapabilities {
145    /// The configured Python interpreter is on PATH.
146    pub python_available: bool,
147    /// Resolved J-Link GDB-server executable name, if found.
148    pub jlink_executable: Option<String>,
149    /// Resolved OpenOCD executable name, if found.
150    pub open_ocd_executable: Option<String>,
151    /// Resolved pyOCD executable name, if found.
152    pub pyocd_executable: Option<String>,
153    /// Resolved GDB executable name, if found.
154    pub gdb_executable: Option<String>,
155    /// Resolved LLDB executable name, if found.
156    pub lldb_executable: Option<String>,
157}
158
159/// Context fed to `build_doctor_report`. Serialized (camelCase) only inside the
160/// support-bundle file; the doctor/inspect envelopes emit derived reports, not
161/// the context itself.
162#[derive(Debug, Clone, Serialize)]
163#[serde(rename_all = "camelCase")]
164pub struct DebugWorkspaceContext {
165    /// ISO-8601 timestamp the context was created.
166    pub generated_at: String,
167    /// Active workspace folder, if any.
168    pub workspace_root: Option<String>,
169    /// Resolved alp-sdk checkout root.
170    pub sdk_root: Option<String>,
171    /// Resolved `board.yaml` path.
172    pub board_yaml_path: Option<String>,
173    /// Working directory used for `west` commands.
174    pub west_cwd: Option<String>,
175    /// Python interpreter used for loader/validation scripts.
176    pub python_binary: String,
177    /// Whether `board.yaml` exists at the resolved path (probed).
178    pub board_yaml_exists: bool,
179    /// Installed-extension state for the host.
180    pub debugger_extensions: DebuggerExtensionsState,
181}
182
183/// Mirror of TS `createDebugWorkspaceContext`: `board.yaml` existence is probed
184/// via the injected predicate only when a path resolved.
185pub 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// ───────────────────────── inspect: resolved values ─────────────────────────
208
209/// Where a resolved `alp inspect` value originated.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
211#[serde(rename_all = "lowercase")]
212pub enum DebugValueSource {
213    /// Derived from the active workspace folder.
214    Workspace,
215    /// From an explicit extension/user setting.
216    Setting,
217    /// A built-in default.
218    Default,
219    /// Probed at runtime (e.g. file existence).
220    Runtime,
221    /// Computed from other values.
222    Derived,
223    /// Could not be resolved.
224    Unresolved,
225}
226
227/// A single resolved project value surfaced by `alp inspect`.
228#[derive(Debug, Clone, Serialize)]
229pub struct DebugResolvedValue {
230    /// Stable key (e.g. `workspaceRoot`).
231    pub key: String,
232    /// The resolved value (string, bool, or null).
233    pub value: serde_json::Value,
234    /// How the value was obtained.
235    pub source: DebugValueSource,
236    /// Human-readable explanation.
237    pub detail: String,
238}
239
240/// Mirror of TS `collectResolvedValues`: the project-context fields surfaced by
241/// `alp inspect`, each tagged with a source + human detail.
242pub 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// ───────────────────────── trace: generation decisions ─────────────────────
336
337/// Result of a single generation decision in an `alp trace`.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
339#[serde(rename_all = "lowercase")]
340pub enum DebugTraceOutcome {
341    /// Output was planned but not written.
342    Planned,
343    /// Output was written to disk.
344    Written,
345    /// Generation failed.
346    Failed,
347}
348
349/// One generation decision recorded by `alp trace`.
350#[derive(Debug, Clone, Serialize)]
351pub struct DebugGenerationTraceDecision {
352    /// Identifier of the artifact (e.g. `zephyr-conf`).
353    pub key: String,
354    /// What happened to the artifact.
355    pub outcome: DebugTraceOutcome,
356    /// Target path, when an output path applies.
357    #[serde(rename = "outputPath", skip_serializing_if = "Option::is_none")]
358    pub output_path: Option<String>,
359    /// Human-readable explanation.
360    pub detail: String,
361}
362
363/// Mirror of TS `collectRuntimeCapabilitiesFromCommands`.
364pub 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/// A single doctor check with its status and an optional remediation.
386#[derive(Debug, Clone, Serialize)]
387pub struct DoctorCheck {
388    /// Stable check name (e.g. `boardYaml`).
389    pub name: String,
390    /// The check outcome.
391    pub status: DoctorStatus,
392    /// Human-readable detail for the outcome.
393    pub detail: String,
394    /// Suggested fix; omitted from JSON when absent (e.g. on `Pass`).
395    #[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/// Per-status check counts for a doctor report.
411#[derive(Debug, Clone, Serialize)]
412pub struct DoctorSummary {
413    /// Number of passing checks.
414    pub pass: u32,
415    /// Number of warning checks.
416    pub warn: u32,
417    /// Number of failing checks.
418    pub fail: u32,
419}
420
421/// Full `alp doctor` report for a target/server combination.
422#[derive(Debug, Clone, Serialize)]
423pub struct DoctorReport {
424    /// ISO-8601 timestamp the report was generated.
425    #[serde(rename = "generatedAt")]
426    pub generated_at: String,
427    /// Target kind the report was run for.
428    #[serde(rename = "targetKind")]
429    pub target_kind: DebugTargetKind,
430    /// Server backend the report was run for.
431    pub server: DebugServerKind,
432    /// Aggregate pass/warn/fail counts.
433    pub summary: DoctorSummary,
434    /// Individual checks in evaluation order.
435    pub checks: Vec<DoctorCheck>,
436    /// Deduplicated remediation steps for non-passing checks.
437    #[serde(rename = "nextSteps")]
438    pub next_steps: Vec<String>,
439}
440
441/// Mirror of TS `buildDoctorReport`.
442pub 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        // 4 base checks + codeLLDBExtension + lldb = 6 checks, all pass.
779        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        // 4 base (python warn) + cortexDebugExtension pass + jlinkBackend warn.
796        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        // python warn + backend warn.
804        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); // workspaceRoot, sdkRoot, boardYaml
827        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        // base 4 checks + serverCompatibility, no target-specific checks.
847        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        // pass checks omit the optional `fix` field.
864        assert!(!json.contains("\"fix\""));
865    }
866}