1use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28
29pub const BUILD_PLAN_SCHEMA_VERSION: u32 = 1;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum Backend {
37 Zephyr,
39 Yocto,
41 Baremetal,
43}
44
45impl Backend {
46 pub fn as_str(self) -> &'static str {
48 match self {
49 Backend::Zephyr => "zephyr",
50 Backend::Yocto => "yocto",
51 Backend::Baremetal => "baremetal",
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct GeneratedFile {
60 pub path: String,
62 pub contents: String,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ToolStep {
70 pub tool: String,
72 pub args: Vec<String>,
74 pub cwd: String,
76}
77
78impl ToolStep {
79 pub fn display(&self) -> String {
81 if self.args.is_empty() {
82 self.tool.clone()
83 } else {
84 format!("{} {}", self.tool, self.args.join(" "))
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct BuildSlice {
95 pub core_id: String,
97 pub backend: Backend,
99 pub build_dir: String,
101 #[serde(default)]
103 pub config_artefacts: Vec<GeneratedFile>,
104 #[serde(default)]
107 pub command: Option<ToolStep>,
108 #[serde(default)]
110 pub env: BTreeMap<String, String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct PlanWarning {
117 pub code: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub core_id: Option<String>,
122 pub message: String,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct BuildPlan {
130 pub schema_version: u32,
132 #[serde(default)]
134 pub generated_by: String,
135 pub board_yaml: String,
137 pub sku: String,
139 pub build_root: String,
141 pub slices: Vec<BuildSlice>,
143 #[serde(default)]
145 pub shared_artefacts: Vec<GeneratedFile>,
146 #[serde(default)]
148 pub warnings: Vec<PlanWarning>,
149}
150
151impl BuildPlan {
152 pub fn all_artefacts(&self) -> Vec<&GeneratedFile> {
158 let mut out: Vec<&GeneratedFile> = self.shared_artefacts.iter().collect();
159 for slice in &self.slices {
160 out.extend(slice.config_artefacts.iter());
161 }
162 out
163 }
164}
165
166#[derive(Debug, thiserror::Error)]
168pub enum BuildPlanError {
169 #[error("build plan is not valid JSON: {0}")]
171 Json(String),
172 #[error(
174 "unsupported build-plan schemaVersion {found} (this CLI consumes v{supported}); \
175 upgrade the CLI or the SDK so the versions match"
176 )]
177 UnsupportedSchemaVersion { found: u32, supported: u32 },
178}
179
180pub fn parse_build_plan(json: &str) -> Result<BuildPlan, BuildPlanError> {
182 let plan: BuildPlan =
183 serde_json::from_str(json).map_err(|e| BuildPlanError::Json(e.to_string()))?;
184 if plan.schema_version != BUILD_PLAN_SCHEMA_VERSION {
185 return Err(BuildPlanError::UnsupportedSchemaVersion {
186 found: plan.schema_version,
187 supported: BUILD_PLAN_SCHEMA_VERSION,
188 });
189 }
190 Ok(plan)
191}
192
193pub fn summarize_plan(plan: &BuildPlan) -> Vec<String> {
196 let mut lines = Vec::new();
197 lines.push(format!(
198 "build plan (schema v{}) — {}",
199 plan.schema_version, plan.sku
200 ));
201 lines.push(format!(" board.yaml: {}", plan.board_yaml));
202 lines.push(format!(" build root: {}", plan.build_root));
203 lines.push(format!(" slices ({}):", plan.slices.len()));
204 for s in &plan.slices {
205 let cmd = s
206 .command
207 .as_ref()
208 .map(ToolStep::display)
209 .unwrap_or_else(|| "(no command)".to_string());
210 lines.push(format!(
211 " - {} [{}] {} -> {}",
212 s.core_id,
213 s.backend.as_str(),
214 cmd,
215 s.build_dir
216 ));
217 }
218 let shared: Vec<&str> = plan
219 .shared_artefacts
220 .iter()
221 .map(|f| f.path.as_str())
222 .collect();
223 lines.push(format!(
224 " shared artefacts ({}): {}",
225 shared.len(),
226 if shared.is_empty() {
227 "-".to_string()
228 } else {
229 shared.join(", ")
230 }
231 ));
232 if plan.warnings.is_empty() {
233 lines.push(" warnings: 0".to_string());
234 } else {
235 lines.push(format!(" warnings ({}):", plan.warnings.len()));
236 for w in &plan.warnings {
237 match &w.core_id {
238 Some(c) => lines.push(format!(" - [{}] {}: {}", w.code, c, w.message)),
239 None => lines.push(format!(" - [{}] {}", w.code, w.message)),
240 }
241 }
242 }
243 lines
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 const SAMPLE: &str = r#"{
253 "schemaVersion": 1,
254 "generatedBy": "scripts/alp_orchestrate.py",
255 "boardYaml": "/proj/board.yaml",
256 "sku": "E1M-AEN701",
257 "buildRoot": "build",
258 "slices": [
259 {
260 "coreId": "m55_he",
261 "backend": "baremetal",
262 "buildDir": "build/m55_he-baremetal",
263 "configArtefacts": [{ "path": "build/m55_he-baremetal/cmake-args.txt", "contents": "-DALP_CORE_ID=m55_he\n" }],
264 "command": { "tool": "cmake", "args": ["-S", "he_app", "-B", "build/m55_he-baremetal"], "cwd": "build/m55_he-baremetal" },
265 "env": { "ALP_SDK_ROOT": "/sdk" }
266 },
267 {
268 "coreId": "m55_hp",
269 "backend": "zephyr",
270 "buildDir": "build/m55_hp-zephyr",
271 "configArtefacts": [{ "path": "build/m55_hp-zephyr/alp.conf", "contents": "CONFIG_GPIO=y\n" }],
272 "command": { "tool": "west", "args": ["build", "-b", "alif_e7_dk_rtss_hp", "app"], "cwd": "build/m55_hp-zephyr" },
273 "env": { "ALP_SDK_ROOT": "/sdk" }
274 }
275 ],
276 "sharedArtefacts": [
277 { "path": "build/generated/alp/system_ipc.h", "contents": "/* ipc */\n" },
278 { "path": "build/generated/dts-reservations.dtsi", "contents": "/* res */\n" },
279 { "path": "build/generated/dts-partitions.dtsi", "contents": "/* parts */\n" }
280 ],
281 "warnings": []
282 }"#;
283
284 #[test]
285 fn parses_a_well_formed_plan() {
286 let plan = parse_build_plan(SAMPLE).expect("sample should parse");
287 assert_eq!(plan.schema_version, 1);
288 assert_eq!(plan.generated_by, "scripts/alp_orchestrate.py");
289 assert_eq!(plan.sku, "E1M-AEN701");
290 assert_eq!(
292 plan.slices
293 .iter()
294 .map(|s| s.core_id.as_str())
295 .collect::<Vec<_>>(),
296 vec!["m55_he", "m55_hp"]
297 );
298 assert_eq!(plan.slices[0].backend, Backend::Baremetal);
299 assert_eq!(plan.slices[1].backend, Backend::Zephyr);
300 assert_eq!(
301 plan.slices[1].env.get("ALP_SDK_ROOT").map(String::as_str),
302 Some("/sdk")
303 );
304 assert_eq!(plan.shared_artefacts.len(), 3);
305 assert!(plan.warnings.is_empty());
306 }
307
308 #[test]
309 fn round_trips_through_json() {
310 let plan = parse_build_plan(SAMPLE).unwrap();
311 let json = serde_json::to_string(&plan).unwrap();
312 let again = parse_build_plan(&json).unwrap();
313 assert_eq!(plan, again);
314 }
315
316 #[test]
317 fn command_display_joins_args() {
318 let plan = parse_build_plan(SAMPLE).unwrap();
319 let cmd = plan.slices[1]
320 .command
321 .as_ref()
322 .expect("zephyr slice has a command");
323 assert_eq!(cmd.display(), "west build -b alif_e7_dk_rtss_hp app");
324 }
325
326 #[test]
327 fn carries_commandless_slice_with_warning() {
328 let json = r#"{
331 "schemaVersion": 1,
332 "boardYaml": "/p/board.yaml",
333 "sku": "E1M-X",
334 "buildRoot": "build",
335 "slices": [
336 { "coreId": "m33_sm", "backend": "zephyr", "buildDir": "build/m33_sm-zephyr", "command": null }
337 ],
338 "warnings": [
339 { "code": "no-command", "coreId": "m33_sm", "message": "no build command for core 'm33_sm'" }
340 ]
341 }"#;
342 let plan = parse_build_plan(json).unwrap();
343 assert_eq!(plan.slices.len(), 1);
344 assert!(plan.slices[0].command.is_none());
345 assert_eq!(plan.warnings[0].code, "no-command");
346 assert_eq!(plan.warnings[0].core_id.as_deref(), Some("m33_sm"));
347 assert!(plan.slices[0].config_artefacts.is_empty());
349 assert!(plan.slices[0].env.is_empty());
350
351 let summary = summarize_plan(&plan).join("\n");
352 assert!(summary.contains("m33_sm [zephyr] (no command)"));
353 assert!(summary.contains("[no-command] m33_sm: no build command"));
354 }
355
356 #[test]
357 fn all_artefacts_collects_shared_then_per_slice() {
358 let plan = parse_build_plan(SAMPLE).unwrap();
359 let arts = plan.all_artefacts();
360 assert_eq!(arts.len(), 5);
362 assert_eq!(arts[0].path, "build/generated/alp/system_ipc.h");
363 let paths: Vec<&str> = arts.iter().map(|a| a.path.as_str()).collect();
364 assert!(paths.contains(&"build/m55_he-baremetal/cmake-args.txt"));
365 assert!(paths.contains(&"build/m55_hp-zephyr/alp.conf"));
366 }
367
368 #[test]
369 fn rejects_unsupported_schema_version() {
370 let bumped = SAMPLE.replace("\"schemaVersion\": 1", "\"schemaVersion\": 99");
371 match parse_build_plan(&bumped) {
372 Err(BuildPlanError::UnsupportedSchemaVersion { found, supported }) => {
373 assert_eq!(found, 99);
374 assert_eq!(supported, BUILD_PLAN_SCHEMA_VERSION);
375 }
376 other => panic!("expected schema-version error, got {other:?}"),
377 }
378 }
379
380 #[test]
381 fn rejects_malformed_json() {
382 assert!(matches!(
383 parse_build_plan("{not json"),
384 Err(BuildPlanError::Json(_))
385 ));
386 }
387
388 #[test]
389 fn summary_lists_each_slice() {
390 let plan = parse_build_plan(SAMPLE).unwrap();
391 let joined = summarize_plan(&plan).join("\n");
392 assert!(joined.contains("E1M-AEN701"));
393 assert!(joined.contains("m55_he [baremetal] cmake -S he_app -B build/m55_he-baremetal"));
394 assert!(joined.contains("m55_hp [zephyr] west build -b alif_e7_dk_rtss_hp app"));
395 assert!(joined.contains("shared artefacts (3):"));
396 assert!(joined.contains("warnings: 0"));
397 }
398}