use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const BUILD_PLAN_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
Zephyr,
Yocto,
Baremetal,
}
impl Backend {
pub fn as_str(self) -> &'static str {
match self {
Backend::Zephyr => "zephyr",
Backend::Yocto => "yocto",
Backend::Baremetal => "baremetal",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeneratedFile {
pub path: String,
pub contents: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolStep {
pub tool: String,
pub args: Vec<String>,
pub cwd: String,
}
impl ToolStep {
pub fn display(&self) -> String {
if self.args.is_empty() {
self.tool.clone()
} else {
format!("{} {}", self.tool, self.args.join(" "))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildSlice {
pub core_id: String,
pub backend: Backend,
pub build_dir: String,
#[serde(default)]
pub config_artefacts: Vec<GeneratedFile>,
#[serde(default)]
pub command: Option<ToolStep>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanWarning {
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub core_id: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildPlan {
pub schema_version: u32,
#[serde(default)]
pub generated_by: String,
pub board_yaml: String,
pub sku: String,
pub build_root: String,
pub slices: Vec<BuildSlice>,
#[serde(default)]
pub shared_artefacts: Vec<GeneratedFile>,
#[serde(default)]
pub warnings: Vec<PlanWarning>,
}
impl BuildPlan {
pub fn all_artefacts(&self) -> Vec<&GeneratedFile> {
let mut out: Vec<&GeneratedFile> = self.shared_artefacts.iter().collect();
for slice in &self.slices {
out.extend(slice.config_artefacts.iter());
}
out
}
}
#[derive(Debug, thiserror::Error)]
pub enum BuildPlanError {
#[error("build plan is not valid JSON: {0}")]
Json(String),
#[error(
"unsupported build-plan schemaVersion {found} (this CLI consumes v{supported}); \
upgrade the CLI or the SDK so the versions match"
)]
UnsupportedSchemaVersion { found: u32, supported: u32 },
}
pub fn parse_build_plan(json: &str) -> Result<BuildPlan, BuildPlanError> {
let plan: BuildPlan =
serde_json::from_str(json).map_err(|e| BuildPlanError::Json(e.to_string()))?;
if plan.schema_version != BUILD_PLAN_SCHEMA_VERSION {
return Err(BuildPlanError::UnsupportedSchemaVersion {
found: plan.schema_version,
supported: BUILD_PLAN_SCHEMA_VERSION,
});
}
Ok(plan)
}
pub fn summarize_plan(plan: &BuildPlan) -> Vec<String> {
let mut lines = Vec::new();
lines.push(format!(
"build plan (schema v{}) — {}",
plan.schema_version, plan.sku
));
lines.push(format!(" board.yaml: {}", plan.board_yaml));
lines.push(format!(" build root: {}", plan.build_root));
lines.push(format!(" slices ({}):", plan.slices.len()));
for s in &plan.slices {
let cmd = s
.command
.as_ref()
.map(ToolStep::display)
.unwrap_or_else(|| "(no command)".to_string());
lines.push(format!(
" - {} [{}] {} -> {}",
s.core_id,
s.backend.as_str(),
cmd,
s.build_dir
));
}
let shared: Vec<&str> = plan
.shared_artefacts
.iter()
.map(|f| f.path.as_str())
.collect();
lines.push(format!(
" shared artefacts ({}): {}",
shared.len(),
if shared.is_empty() {
"-".to_string()
} else {
shared.join(", ")
}
));
if plan.warnings.is_empty() {
lines.push(" warnings: 0".to_string());
} else {
lines.push(format!(" warnings ({}):", plan.warnings.len()));
for w in &plan.warnings {
match &w.core_id {
Some(c) => lines.push(format!(" - [{}] {}: {}", w.code, c, w.message)),
None => lines.push(format!(" - [{}] {}", w.code, w.message)),
}
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"schemaVersion": 1,
"generatedBy": "scripts/alp_orchestrate.py",
"boardYaml": "/proj/board.yaml",
"sku": "E1M-AEN701",
"buildRoot": "build",
"slices": [
{
"coreId": "m55_he",
"backend": "baremetal",
"buildDir": "build/m55_he-baremetal",
"configArtefacts": [{ "path": "build/m55_he-baremetal/cmake-args.txt", "contents": "-DALP_CORE_ID=m55_he\n" }],
"command": { "tool": "cmake", "args": ["-S", "he_app", "-B", "build/m55_he-baremetal"], "cwd": "build/m55_he-baremetal" },
"env": { "ALP_SDK_ROOT": "/sdk" }
},
{
"coreId": "m55_hp",
"backend": "zephyr",
"buildDir": "build/m55_hp-zephyr",
"configArtefacts": [{ "path": "build/m55_hp-zephyr/alp.conf", "contents": "CONFIG_GPIO=y\n" }],
"command": { "tool": "west", "args": ["build", "-b", "alif_e7_dk_rtss_hp", "app"], "cwd": "build/m55_hp-zephyr" },
"env": { "ALP_SDK_ROOT": "/sdk" }
}
],
"sharedArtefacts": [
{ "path": "build/generated/alp/system_ipc.h", "contents": "/* ipc */\n" },
{ "path": "build/generated/dts-reservations.dtsi", "contents": "/* res */\n" },
{ "path": "build/generated/dts-partitions.dtsi", "contents": "/* parts */\n" }
],
"warnings": []
}"#;
#[test]
fn parses_a_well_formed_plan() {
let plan = parse_build_plan(SAMPLE).expect("sample should parse");
assert_eq!(plan.schema_version, 1);
assert_eq!(plan.generated_by, "scripts/alp_orchestrate.py");
assert_eq!(plan.sku, "E1M-AEN701");
assert_eq!(
plan.slices
.iter()
.map(|s| s.core_id.as_str())
.collect::<Vec<_>>(),
vec!["m55_he", "m55_hp"]
);
assert_eq!(plan.slices[0].backend, Backend::Baremetal);
assert_eq!(plan.slices[1].backend, Backend::Zephyr);
assert_eq!(
plan.slices[1].env.get("ALP_SDK_ROOT").map(String::as_str),
Some("/sdk")
);
assert_eq!(plan.shared_artefacts.len(), 3);
assert!(plan.warnings.is_empty());
}
#[test]
fn round_trips_through_json() {
let plan = parse_build_plan(SAMPLE).unwrap();
let json = serde_json::to_string(&plan).unwrap();
let again = parse_build_plan(&json).unwrap();
assert_eq!(plan, again);
}
#[test]
fn command_display_joins_args() {
let plan = parse_build_plan(SAMPLE).unwrap();
let cmd = plan.slices[1]
.command
.as_ref()
.expect("zephyr slice has a command");
assert_eq!(cmd.display(), "west build -b alif_e7_dk_rtss_hp app");
}
#[test]
fn carries_commandless_slice_with_warning() {
let json = r#"{
"schemaVersion": 1,
"boardYaml": "/p/board.yaml",
"sku": "E1M-X",
"buildRoot": "build",
"slices": [
{ "coreId": "m33_sm", "backend": "zephyr", "buildDir": "build/m33_sm-zephyr", "command": null }
],
"warnings": [
{ "code": "no-command", "coreId": "m33_sm", "message": "no build command for core 'm33_sm'" }
]
}"#;
let plan = parse_build_plan(json).unwrap();
assert_eq!(plan.slices.len(), 1);
assert!(plan.slices[0].command.is_none());
assert_eq!(plan.warnings[0].code, "no-command");
assert_eq!(plan.warnings[0].core_id.as_deref(), Some("m33_sm"));
assert!(plan.slices[0].config_artefacts.is_empty());
assert!(plan.slices[0].env.is_empty());
let summary = summarize_plan(&plan).join("\n");
assert!(summary.contains("m33_sm [zephyr] (no command)"));
assert!(summary.contains("[no-command] m33_sm: no build command"));
}
#[test]
fn all_artefacts_collects_shared_then_per_slice() {
let plan = parse_build_plan(SAMPLE).unwrap();
let arts = plan.all_artefacts();
assert_eq!(arts.len(), 5);
assert_eq!(arts[0].path, "build/generated/alp/system_ipc.h");
let paths: Vec<&str> = arts.iter().map(|a| a.path.as_str()).collect();
assert!(paths.contains(&"build/m55_he-baremetal/cmake-args.txt"));
assert!(paths.contains(&"build/m55_hp-zephyr/alp.conf"));
}
#[test]
fn rejects_unsupported_schema_version() {
let bumped = SAMPLE.replace("\"schemaVersion\": 1", "\"schemaVersion\": 99");
match parse_build_plan(&bumped) {
Err(BuildPlanError::UnsupportedSchemaVersion { found, supported }) => {
assert_eq!(found, 99);
assert_eq!(supported, BUILD_PLAN_SCHEMA_VERSION);
}
other => panic!("expected schema-version error, got {other:?}"),
}
}
#[test]
fn rejects_malformed_json() {
assert!(matches!(
parse_build_plan("{not json"),
Err(BuildPlanError::Json(_))
));
}
#[test]
fn summary_lists_each_slice() {
let plan = parse_build_plan(SAMPLE).unwrap();
let joined = summarize_plan(&plan).join("\n");
assert!(joined.contains("E1M-AEN701"));
assert!(joined.contains("m55_he [baremetal] cmake -S he_app -B build/m55_he-baremetal"));
assert!(joined.contains("m55_hp [zephyr] west build -b alif_e7_dk_rtss_hp app"));
assert!(joined.contains("shared artefacts (3):"));
assert!(joined.contains("warnings: 0"));
}
}