Skip to main content

alp_core/
debug_launch.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Debug launch-config generation — a port of TS `createDebugProfile` +
3//! `debugProfileToLaunchDraft` + `createLaunchPreview` and the `launchJsonCore`
4//! merge plan. Produces VS Code `launch.json` drafts for `alp debug-config`.
5//!
6//! Relies on `serde_json`'s `preserve_order` feature so emitted JSON keeps the
7//! same key order as the TypeScript CLI.
8
9use serde_json::{Value, json};
10
11use crate::debug::{DebugServerKind, DebugTargetKind, is_server_supported_for_target};
12
13fn server_label(server: DebugServerKind) -> &'static str {
14    match server {
15        DebugServerKind::Jlink => "J-Link",
16        DebugServerKind::Openocd => "OpenOCD",
17        DebugServerKind::Pyocd => "pyOCD",
18        DebugServerKind::Gdbserver => "gdbserver",
19        DebugServerKind::None => "local",
20    }
21}
22
23/// Build the VS Code launch configuration draft for a target/server, mirroring
24/// TS `createDebugProfile` → `debugProfileToLaunchDraft`. Errors (with the TS
25/// message) when the server is not valid for the target.
26pub fn create_launch_draft(
27    target: DebugTargetKind,
28    server: DebugServerKind,
29) -> Result<Value, String> {
30    if !is_server_supported_for_target(target, server) {
31        return Err(format!(
32            "Unsupported debug backend '{}' for target '{}'.",
33            server.as_str(),
34            target.as_str()
35        ));
36    }
37
38    let draft = match target {
39        DebugTargetKind::ZephyrMcu => {
40            let name = format!("ALP: Zephyr Debug ({})", server_label(server));
41            match server {
42                DebugServerKind::Openocd => json!({
43                    "name": name,
44                    "type": "cortex-debug",
45                    "request": "launch",
46                    "cwd": "${workspaceFolder}",
47                    "executable": "${workspaceFolder}/build/app/zephyr/zephyr.elf",
48                    "runToEntryPoint": "main",
49                    "preLaunchTask": "alp: build active target",
50                    "servertype": "openocd",
51                    "configFiles": ["<resolved-openocd-board-cfg>"],
52                }),
53                DebugServerKind::Pyocd => json!({
54                    "name": name,
55                    "type": "cortex-debug",
56                    "request": "launch",
57                    "cwd": "${workspaceFolder}",
58                    "executable": "${workspaceFolder}/build/app/zephyr/zephyr.elf",
59                    "runToEntryPoint": "main",
60                    "preLaunchTask": "alp: build active target",
61                    "servertype": "pyocd",
62                    "targetId": "<resolved-target-id>",
63                }),
64                _ => json!({
65                    "name": name,
66                    "type": "cortex-debug",
67                    "request": "launch",
68                    "cwd": "${workspaceFolder}",
69                    "executable": "${workspaceFolder}/build/app/zephyr/zephyr.elf",
70                    "runToEntryPoint": "main",
71                    "preLaunchTask": "alp: build active target",
72                    "servertype": "jlink",
73                    "device": "<resolved-device>",
74                    "interface": "swd",
75                }),
76            }
77        }
78        DebugTargetKind::BaremetalMcu => json!({
79            "name": format!("ALP: Baremetal Debug ({})", server_label(server)),
80            "type": "cortex-debug",
81            "request": "launch",
82            "servertype": server.as_str(),
83            "cwd": "${workspaceFolder}",
84            "executable": "${workspaceFolder}/build/baremetal/app.elf",
85            "device": "<resolved-device>",
86            "interface": "swd",
87            "svdFile": "<resolved-svd>",
88            "preLaunchTask": "alp: build baremetal target",
89        }),
90        DebugTargetKind::YoctoUserspace => json!({
91            "name": "ALP: Yocto Remote Debug",
92            "type": "cppdbg",
93            "request": "launch",
94            "program": "${workspaceFolder}/build/yocto/app",
95            "cwd": "${workspaceFolder}",
96            "MIMode": "gdb",
97            "miDebuggerServerAddress": "<host>:<port>",
98            "miDebuggerPath": "<resolved-gdb>",
99            "setupCommands": [{ "text": "-enable-pretty-printing" }],
100            "preLaunchTask": "alp: deploy and start gdbserver",
101        }),
102        DebugTargetKind::NativeHost => json!({
103            "name": "ALP: Native Sim Debug",
104            "type": "codelldb",
105            "request": "launch",
106            "program": "${workspaceFolder}/build/native_sim/zephyr/zephyr.exe",
107            "cwd": "${workspaceFolder}",
108            "preLaunchTask": "alp: build native_sim target",
109        }),
110    };
111    Ok(draft)
112}
113
114/// The static advisory notes attached to a launch preview (TS `createLaunchPreview`).
115pub fn launch_preview_notes() -> Vec<String> {
116    vec![
117        "This is a draft launch configuration generated by the extension.".to_string(),
118        "Placeholder fields such as <resolved-device> still need project-specific resolution."
119            .to_string(),
120        "The long-term target is to resolve these values from the shared debug model.".to_string(),
121    ]
122}
123
124/// The `launch.json`-shaped preview document: `{version, configurations:[draft]}`.
125pub fn launch_preview_document(draft: Value) -> Value {
126    json!({
127        "version": "0.2.0",
128        "configurations": [draft],
129    })
130}
131
132/// Result of merging a draft into `launch.json`: the serialized document plus
133/// whether a same-named configuration was overwritten.
134#[derive(Debug)]
135pub struct LaunchJsonWritePlan {
136    /// Pretty-printed `launch.json` content, trailing newline included.
137    pub content: String,
138    /// `true` if an existing same-named configuration was replaced; `false` if appended.
139    pub replaced: bool,
140}
141
142/// Merge `draft` into an existing `launch.json` (or a fresh document), replacing
143/// any configuration with the same `name`. Mirrors TS `createLaunchJsonWritePlan`.
144pub fn create_launch_json_write_plan(
145    existing_content: Option<&str>,
146    draft: &Value,
147) -> Result<LaunchJsonWritePlan, String> {
148    let mut document = parse_launch_json_or_default(existing_content)?;
149    let next_name = configuration_name(draft)?;
150
151    let configs = document
152        .get_mut("configurations")
153        .and_then(Value::as_array_mut)
154        .expect("configurations is always an array");
155
156    let existing_index = configs
157        .iter()
158        .position(|c| c.get("name").and_then(Value::as_str) == Some(next_name));
159
160    let replaced = match existing_index {
161        Some(index) => {
162            configs[index] = draft.clone();
163            true
164        }
165        None => {
166            configs.push(draft.clone());
167            false
168        }
169    };
170
171    let content = format!(
172        "{}\n",
173        serde_json::to_string_pretty(&document).expect("launch document is serializable")
174    );
175    Ok(LaunchJsonWritePlan { content, replaced })
176}
177
178fn parse_launch_json_or_default(content: Option<&str>) -> Result<Value, String> {
179    let trimmed = content.map(str::trim).unwrap_or("");
180    if trimmed.is_empty() {
181        return Ok(json!({ "version": "0.2.0", "configurations": [] }));
182    }
183
184    let parsed: Value = serde_json::from_str(trimmed)
185        .map_err(|_| "Alp: .vscode/launch.json is not valid JSON.".to_string())?;
186    let Value::Object(mut candidate) = parsed else {
187        return Err("Alp: .vscode/launch.json must be a JSON object.".to_string());
188    };
189
190    let version = match candidate.get("version").and_then(Value::as_str) {
191        Some(v) if !v.trim().is_empty() => Value::String(v.to_string()),
192        _ => Value::String("0.2.0".to_string()),
193    };
194    let configurations = match candidate.get("configurations") {
195        Some(Value::Array(entries)) => {
196            Value::Array(entries.iter().filter(|e| e.is_object()).cloned().collect())
197        }
198        _ => Value::Array(Vec::new()),
199    };
200
201    // {...candidate, version, configurations}: keep order, override the two keys.
202    candidate.insert("version".to_string(), version);
203    candidate.insert("configurations".to_string(), configurations);
204    Ok(Value::Object(candidate))
205}
206
207fn configuration_name(configuration: &Value) -> Result<&str, String> {
208    match configuration.get("name").and_then(Value::as_str) {
209        Some(name) if !name.trim().is_empty() => Ok(name),
210        _ => Err("Alp: debug launch draft is missing a valid name.".to_string()),
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn unsupported_server_errors() {
220        let err =
221            create_launch_draft(DebugTargetKind::NativeHost, DebugServerKind::Jlink).unwrap_err();
222        assert!(err.contains("Unsupported debug backend 'jlink' for target 'native-host'"));
223    }
224
225    #[test]
226    fn zephyr_jlink_draft_key_order_preserved() {
227        let draft =
228            create_launch_draft(DebugTargetKind::ZephyrMcu, DebugServerKind::Jlink).unwrap();
229        let json = serde_json::to_string(&draft).unwrap();
230        // Keys must stay in insertion order (preserve_order), not sorted.
231        assert!(json.starts_with(
232            "{\"name\":\"ALP: Zephyr Debug (J-Link)\",\"type\":\"cortex-debug\",\"request\":\"launch\""
233        ));
234        assert!(json.ends_with(
235            "\"servertype\":\"jlink\",\"device\":\"<resolved-device>\",\"interface\":\"swd\"}"
236        ));
237    }
238
239    #[test]
240    fn write_plan_appends_then_replaces_by_name() {
241        let draft =
242            create_launch_draft(DebugTargetKind::NativeHost, DebugServerKind::None).unwrap();
243        let plan = create_launch_json_write_plan(None, &draft).unwrap();
244        assert!(!plan.replaced);
245        assert!(plan.content.ends_with("\n"));
246        assert!(plan.content.contains("\"version\": \"0.2.0\""));
247
248        // Re-applying the same-named config replaces it (still one entry).
249        let plan2 = create_launch_json_write_plan(Some(&plan.content), &draft).unwrap();
250        assert!(plan2.replaced);
251        let doc: Value = serde_json::from_str(&plan2.content).unwrap();
252        assert_eq!(doc["configurations"].as_array().unwrap().len(), 1);
253    }
254
255    #[test]
256    fn preserves_unknown_top_level_keys() {
257        let existing = "{\"version\":\"0.1.0\",\"inputs\":[],\"configurations\":[]}";
258        let draft =
259            create_launch_draft(DebugTargetKind::NativeHost, DebugServerKind::None).unwrap();
260        let plan = create_launch_json_write_plan(Some(existing), &draft).unwrap();
261        // `inputs` is preserved; version kept as the (valid) existing value.
262        assert!(plan.content.contains("\"inputs\""));
263        assert!(plan.content.contains("\"version\": \"0.1.0\""));
264    }
265
266    #[test]
267    fn invalid_json_errors() {
268        let err =
269            create_launch_json_write_plan(Some("{not json"), &json!({"name": "x"})).unwrap_err();
270        assert!(err.contains("not valid JSON"));
271    }
272}