Skip to main content

alp_core/
build_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Build-plan contract (Wave C) — the **consumed** shape of the SDK's
3//! `alp_orchestrate.py --emit build-plan` JSON, locked as ADR 0014
4//! (alp-sdk `docs/adr/0014-build-plan-emit-cli-contract.md`, 2026-06-04).
5//!
6//! The CLI *consumes* this plan; it does **not** compute it. The planner — the
7//! fast-moving, vendor-heavy part (partition allocation, sysbuild, TF-M) — stays
8//! the SDK's single source of truth (see `docs/BUILD_ORCHESTRATION.md` and
9//! `docs/PROPOSAL-alp-build-core.md`). This module is pure: it only models +
10//! parses the plan JSON. Materialise / execute / schedule live in the CLI
11//! (`alp-cli`).
12//!
13//! Contract notes (ADR 0014, mirrored here so the types stay honest):
14//!   * camelCase keys; `schemaVersion` independent of board.yaml's version.
15//!   * Every artefact carries its `contents` (`GeneratedFile`) so materialise is
16//!     pure byte-write IO — no content-derivation leaks to the consumer.
17//!   * **No `inputHash`** (the consumer computes its own cache key over the plan)
18//!     and **no `sequential`** (parallelism policy is the consumer's scheduler).
19//!   * One slice per non-`off` core, sorted by `coreId`. A slice the script
20//!     cannot build yet carries `command: null` + a `no-command` warning — never
21//!     dropped.
22//!   * The per-slice `command` shape is **not frozen** (it will grow, e.g.
23//!     `--sysbuild`); we never assume a fixed arg layout.
24
25use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28
29/// The build-plan schema version this CLI knows how to consume. A plan with a
30/// different `schemaVersion` is rejected rather than silently mis-applied.
31pub const BUILD_PLAN_SCHEMA_VERSION: u32 = 1;
32
33/// Per-core build backend. Serialized lowercase to match the emit + `BuildOs`.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum Backend {
37    /// Zephyr RTOS build (`west`).
38    Zephyr,
39    /// Yocto/OpenEmbedded build (`bitbake`).
40    Yocto,
41    /// Bare-metal build (`cmake`).
42    Baremetal,
43}
44
45impl Backend {
46    /// The lowercase wire string for this backend (matches the serde encoding).
47    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/// A file the SDK's planner wants written verbatim (config or shared artefact).
57/// `contents` is REQUIRED — the CLI byte-writes it; it never derives content.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct GeneratedFile {
60    /// Destination path (relative to the build root) the consumer writes to.
61    pub path: String,
62    /// Verbatim file body to write; never derived by the consumer.
63    pub contents: String,
64}
65
66/// One concrete tool invocation (`west` / `bitbake` / `cmake`). Its shape is
67/// **not frozen** — it comes from the emit and will grow (e.g. `--sysbuild`).
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ToolStep {
70    /// Executable to run (e.g. `west`, `bitbake`, `cmake`).
71    pub tool: String,
72    /// Arguments passed to `tool`, in order.
73    pub args: Vec<String>,
74    /// Working directory the invocation runs in.
75    pub cwd: String,
76}
77
78impl ToolStep {
79    /// `tool arg arg ...` for display.
80    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/// One build slice — a single non-`off` core. Lean by contract (ADR 0014): the
90/// command already encodes the board/app, so the consumer needs only what it
91/// runs + writes, not the planner's intermediate fields.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct BuildSlice {
95    /// Identifier of the core this slice builds (e.g. `m55_hp`).
96    pub core_id: String,
97    /// Build backend driving this slice.
98    pub backend: Backend,
99    /// Output directory for this slice's build.
100    pub build_dir: String,
101    /// Per-slice config files to materialise before running `command`.
102    #[serde(default)]
103    pub config_artefacts: Vec<GeneratedFile>,
104    /// `None` when the planner cannot build this core yet (paired with a
105    /// `no-command` warning); the slice is reported, never dropped.
106    #[serde(default)]
107    pub command: Option<ToolStep>,
108    /// Environment overrides applied when running `command`.
109    #[serde(default)]
110    pub env: BTreeMap<String, String>,
111}
112
113/// A non-fatal note from the planner (e.g. "core X has no build command").
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct PlanWarning {
117    /// Machine-readable warning code (e.g. `no-command`).
118    pub code: String,
119    /// Set when the warning is about a specific core (e.g. `no-command`).
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub core_id: Option<String>,
122    /// Human-readable warning text.
123    pub message: String,
124}
125
126/// The whole plan — the deserialization target for `--emit build-plan`.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct BuildPlan {
130    /// Plan schema version; must equal `BUILD_PLAN_SCHEMA_VERSION` to be consumed.
131    pub schema_version: u32,
132    /// Tool/script that emitted the plan (e.g. `scripts/alp_orchestrate.py`).
133    #[serde(default)]
134    pub generated_by: String,
135    /// Path to the source `board.yaml` the plan was derived from.
136    pub board_yaml: String,
137    /// Board SKU the plan targets.
138    pub sku: String,
139    /// Root directory for all build output.
140    pub build_root: String,
141    /// One slice per non-`off` core, sorted by `coreId`.
142    pub slices: Vec<BuildSlice>,
143    /// Cross-slice generated files (e.g. IPC headers, DTS overlays).
144    #[serde(default)]
145    pub shared_artefacts: Vec<GeneratedFile>,
146    /// Non-fatal planner notes.
147    #[serde(default)]
148    pub warnings: Vec<PlanWarning>,
149}
150
151impl BuildPlan {
152    /// Every generated file the consumer must materialise, in a deterministic
153    /// order: shared artefacts first, then each slice's config artefacts in
154    /// slice order. Pure — the CLI does the byte-writes. The SDK guarantees
155    /// these `contents` match what `west alp-build` would write itself, so
156    /// materialising them cannot drift from the on-disk build.
157    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/// Why a build-plan JSON could not be consumed.
167#[derive(Debug, thiserror::Error)]
168pub enum BuildPlanError {
169    /// The document failed JSON deserialization; holds the parse error text.
170    #[error("build plan is not valid JSON: {0}")]
171    Json(String),
172    /// The plan's `schemaVersion` differs from the version this CLI consumes.
173    #[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
180/// Parse + version-guard a build-plan JSON document. Pure: no IO.
181pub 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
193/// Human-readable, deterministic summary lines for `alp build --plan` (text
194/// mode). Pure so it is unit-testable without the CLI.
195pub 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    // Mirrors the ADR 0014 emit shape: lean slices (sorted by coreId), nullable
251    // command, `generatedBy`, no `sequential`/`inputHash`, warnings carry coreId.
252    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        // One slice per core, sorted by coreId (m55_he before m55_hp).
291        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        // A core the planner can't build yet: command is null, paired with a
329        // `no-command` warning that names the core. The slice is still present.
330        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        // Defaulted optionals on the lean slice.
348        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        // 3 shared + 1 config per slice (2 slices) = 5, shared first.
361        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}