Skip to main content

alp_core/
build_readiness.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Build-readiness preflight — the toolchains a build (and the Yocto `.wic`
3//! flash) needs, keyed off the OSes the active `board.yaml` declares. Used by
4//! `alp doctor --build` (and, later, as `alp build`'s preflight).
5//!
6//! Scope boundary: `board.yaml` alone does not carry each core's *type*
7//! (Cortex-M vs Cortex-A) — that resolves from the SoM topology in the SDK
8//! metadata (owned by `alp_orchestrate.py`, deliberately not reimplemented here,
9//! see EXTENSION_CLI_INTEGRATION.md §9). So we key off cores' explicit `os:`
10//! fields; when none are declared we check all three backends. The authoritative
11//! per-core resolution stays `west alp-build`'s job — this is advisory.
12
13use std::collections::BTreeSet;
14
15use serde::Serialize;
16
17use crate::debug::{DoctorCheck, DoctorStatus, DoctorSummary};
18use crate::model::BoardModel;
19
20/// A build backend a `board.yaml` can target. Serializes lowercase.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum BuildOs {
24    /// Zephyr RTOS build (west + CMake + Ninja + Zephyr SDK).
25    Zephyr,
26    /// Yocto Linux image build (Linux-only; bitbake).
27    Yocto,
28    /// Baremetal build (CMake + a vendor toolchain).
29    Baremetal,
30}
31
32impl BuildOs {
33    fn parse(raw: &str) -> Option<BuildOs> {
34        match raw {
35            "zephyr" => Some(BuildOs::Zephyr),
36            "yocto" => Some(BuildOs::Yocto),
37            "baremetal" => Some(BuildOs::Baremetal),
38            _ => None,
39        }
40    }
41}
42
43/// The set of build OSes a `board.yaml` declares (top-level `os:` plus each
44/// core's explicit `os:`). When nothing is declared, returns all three — the
45/// SoM-default OS lives in SDK metadata we don't resolve here.
46pub fn board_os_set(board: &BoardModel) -> Vec<BuildOs> {
47    let mut set: BTreeSet<BuildOs> = BTreeSet::new();
48    if let Some(os) = board.os.as_deref().and_then(BuildOs::parse) {
49        set.insert(os);
50    }
51    if let Some(cores) = &board.cores {
52        for core in cores.values() {
53            if let Some(os) = core.os.as_deref().and_then(BuildOs::parse) {
54                set.insert(os);
55            }
56        }
57    }
58    if set.is_empty() {
59        return vec![BuildOs::Zephyr, BuildOs::Yocto, BuildOs::Baremetal];
60    }
61    set.into_iter().collect()
62}
63
64/// Host build-tool presence (probed by the caller; kept IO-free here).
65#[derive(Debug, Clone, Copy)]
66pub struct BuildToolProbe {
67    /// `west` is on PATH (Zephyr build driver).
68    pub west: bool,
69    /// `cmake` is on PATH (Zephyr/baremetal build generator).
70    pub cmake: bool,
71    /// `ninja` is on PATH (Zephyr build backend).
72    pub ninja: bool,
73    /// `bitbake` is on PATH (Yocto build driver).
74    pub bitbake: bool,
75    /// Zephyr SDK toolchain detected (via env / install dir, not PATH).
76    pub zephyr_sdk: bool,
77    /// `bmaptool` — the preferred Yocto `.wic` flasher (sparse-aware).
78    pub bmaptool: bool,
79    /// `dd` — the Yocto `.wic` flash fallback when `bmaptool` is absent.
80    pub dd: bool,
81    /// Host is Linux (gates Yocto builds, which are Linux-only).
82    pub is_linux: bool,
83}
84
85/// The build-readiness preflight result: declared OS set, per-tool checks, a
86/// pass/warn/fail summary, and deduped remediation steps. Serializes camelCase.
87#[derive(Debug, Clone, Serialize)]
88pub struct BuildReadinessReport {
89    /// Report envelope schema version (currently `"1"`).
90    #[serde(rename = "schemaVersion")]
91    pub schema_version: String,
92    /// Timestamp the report was generated (supplied by the caller).
93    #[serde(rename = "generatedAt")]
94    pub generated_at: String,
95    /// The build OSes this report's checks cover.
96    #[serde(rename = "osSet")]
97    pub os_set: Vec<BuildOs>,
98    /// Aggregate pass/warn/fail counts over `checks`.
99    pub summary: DoctorSummary,
100    /// Per-tool readiness checks.
101    pub checks: Vec<DoctorCheck>,
102    /// Deduped fix hints for every non-passing check.
103    #[serde(rename = "nextSteps")]
104    pub next_steps: Vec<String>,
105}
106
107/// Assemble the build-readiness report for an OS set + probed host tools.
108pub fn build_readiness_report(
109    generated_at: String,
110    os_set: Vec<BuildOs>,
111    probe: &BuildToolProbe,
112) -> BuildReadinessReport {
113    let mut checks: Vec<DoctorCheck> = Vec::new();
114    let mut seen: BTreeSet<&'static str> = BTreeSet::new();
115
116    if os_set.contains(&BuildOs::Zephyr) {
117        push_tool(
118            &mut checks,
119            &mut seen,
120            "west",
121            probe.west,
122            "Zephyr",
123            "Install west via `alp bootstrap`.",
124        );
125        push_tool(
126            &mut checks,
127            &mut seen,
128            "cmake",
129            probe.cmake,
130            "Zephyr/baremetal",
131            "Install CMake (>=3.20).",
132        );
133        push_tool(
134            &mut checks,
135            &mut seen,
136            "ninja",
137            probe.ninja,
138            "Zephyr",
139            "Install Ninja.",
140        );
141        // Zephyr SDK is detected (env / install dir), not a PATH binary.
142        checks.push(DoctorCheck {
143            name: "zephyrSdk".to_string(),
144            status: if probe.zephyr_sdk {
145                DoctorStatus::Pass
146            } else {
147                DoctorStatus::Warn
148            },
149            detail: if probe.zephyr_sdk {
150                "Zephyr SDK toolchain detected.".to_string()
151            } else {
152                "Zephyr SDK toolchain not detected (ZEPHYR_SDK_INSTALL_DIR unset).".to_string()
153            },
154            fix: if probe.zephyr_sdk {
155                None
156            } else {
157                Some(
158                    "Install the Zephyr SDK: https://docs.zephyrproject.org/latest/develop/toolchains/zephyr_sdk.html"
159                        .to_string(),
160                )
161            },
162        });
163    }
164
165    if os_set.contains(&BuildOs::Yocto) {
166        if probe.is_linux {
167            push_tool(
168                &mut checks,
169                &mut seen,
170                "bitbake",
171                probe.bitbake,
172                "Yocto",
173                "Install the Yocto host packages (see docs/getting-started.md).",
174            );
175            // Flash prerequisite: `alp flash` writes the Yocto `.wic` to SD/eMMC
176            // via `bmaptool` (sparse-aware, preferred) and falls back to `dd`.
177            // Warn early so the gap shows at doctor time, not mid-flash.
178            let (status, detail, fix) = if probe.bmaptool {
179                (
180                    DoctorStatus::Pass,
181                    "bmaptool is available — fast sparse Yocto .wic flashing.".to_string(),
182                    None,
183                )
184            } else if probe.dd {
185                (
186                    DoctorStatus::Warn,
187                    "bmaptool not found; Yocto .wic flash falls back to dd (slower)."
188                        .to_string(),
189                    Some(
190                        "Install bmaptool for sparse .wic flashing (e.g. `apt install bmap-tools`)."
191                            .to_string(),
192                    ),
193                )
194            } else {
195                (
196                    DoctorStatus::Warn,
197                    "neither bmaptool nor dd on PATH — Yocto .wic flash (`alp flash`) will fail."
198                        .to_string(),
199                    Some(
200                        "Install bmaptool (`apt install bmap-tools`) or dd (coreutils)."
201                            .to_string(),
202                    ),
203                )
204            };
205            checks.push(DoctorCheck {
206                name: "bmaptool".to_string(),
207                status,
208                detail,
209                fix,
210            });
211        } else {
212            checks.push(DoctorCheck {
213                name: "yoctoHost".to_string(),
214                status: DoctorStatus::Warn,
215                detail: "Yocto builds are Linux-only; use WSL2 or a Linux host/container."
216                    .to_string(),
217                fix: Some("Run Yocto builds on Linux (WSL2 / Docker).".to_string()),
218            });
219        }
220    }
221
222    if os_set.contains(&BuildOs::Baremetal) {
223        push_tool(
224            &mut checks,
225            &mut seen,
226            "cmake",
227            probe.cmake,
228            "baremetal",
229            "Install CMake (>=3.20).",
230        );
231        checks.push(DoctorCheck {
232            name: "vendorToolchain".to_string(),
233            status: DoctorStatus::Warn,
234            detail: "Baremetal needs a vendor toolchain (Alif/Renesas/NXP), per SoC family."
235                .to_string(),
236            fix: Some(
237                "Install the vendor toolchain for your SoC (see docs/getting-started.md §8)."
238                    .to_string(),
239            ),
240        });
241    }
242
243    let summary = DoctorSummary {
244        pass: count(&checks, DoctorStatus::Pass),
245        warn: count(&checks, DoctorStatus::Warn),
246        fail: count(&checks, DoctorStatus::Fail),
247    };
248    let next_steps = unique_next_steps(&checks);
249
250    BuildReadinessReport {
251        schema_version: "1".to_string(),
252        generated_at,
253        os_set,
254        summary,
255        checks,
256        next_steps,
257    }
258}
259
260/// Append a PATH-binary readiness check, deduped by `name` (a tool needed by
261/// more than one declared OS is reported once).
262fn push_tool(
263    checks: &mut Vec<DoctorCheck>,
264    seen: &mut BTreeSet<&'static str>,
265    name: &'static str,
266    present: bool,
267    need: &str,
268    fix: &str,
269) {
270    if !seen.insert(name) {
271        return;
272    }
273    checks.push(DoctorCheck {
274        name: name.to_string(),
275        status: if present {
276            DoctorStatus::Pass
277        } else {
278            DoctorStatus::Warn
279        },
280        detail: if present {
281            format!("{name} is available.")
282        } else {
283            format!("{name} not found on PATH — needed for {need} builds.")
284        },
285        fix: if present { None } else { Some(fix.to_string()) },
286    });
287}
288
289fn count(checks: &[DoctorCheck], status: DoctorStatus) -> u32 {
290    checks.iter().filter(|c| c.status == status).count() as u32
291}
292
293fn unique_next_steps(checks: &[DoctorCheck]) -> Vec<String> {
294    let mut steps = Vec::new();
295    for check in checks {
296        if check.status == DoctorStatus::Pass {
297            continue;
298        }
299        if let Some(fix) = &check.fix {
300            if !steps.contains(fix) {
301                steps.push(fix.clone());
302            }
303        }
304    }
305    steps
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::validate::parse_board_model;
312
313    fn probe_all_present() -> BuildToolProbe {
314        BuildToolProbe {
315            west: true,
316            cmake: true,
317            ninja: true,
318            bitbake: true,
319            zephyr_sdk: true,
320            bmaptool: true,
321            dd: true,
322            is_linux: true,
323        }
324    }
325
326    #[test]
327    fn os_set_from_explicit_core_os() {
328        let board = parse_board_model(
329            "schema_version: 2\ncores:\n  m55_hp:\n    os: zephyr\n    app: ./src\n  a32:\n    os: yocto\n    app: ./a\n",
330        )
331        .unwrap();
332        assert_eq!(board_os_set(&board), vec![BuildOs::Zephyr, BuildOs::Yocto]);
333    }
334
335    #[test]
336    fn os_set_falls_back_to_all_when_undeclared() {
337        let board =
338            parse_board_model("schema_version: 2\ncores:\n  m55_hp:\n    app: ./src\n").unwrap();
339        assert_eq!(
340            board_os_set(&board),
341            vec![BuildOs::Zephyr, BuildOs::Yocto, BuildOs::Baremetal]
342        );
343    }
344
345    #[test]
346    fn zephyr_checks_present_pass_clean() {
347        let report =
348            build_readiness_report("t".to_string(), vec![BuildOs::Zephyr], &probe_all_present());
349        assert_eq!(report.summary.fail, 0);
350        assert!(report.summary.warn == 0);
351        assert!(
352            report
353                .checks
354                .iter()
355                .any(|c| c.name == "west" && c.status == DoctorStatus::Pass)
356        );
357        assert!(report.checks.iter().any(|c| c.name == "zephyrSdk"));
358    }
359
360    #[test]
361    fn missing_tools_warn_with_next_steps() {
362        let probe = BuildToolProbe {
363            west: false,
364            cmake: false,
365            ninja: false,
366            bitbake: false,
367            zephyr_sdk: false,
368            bmaptool: false,
369            dd: false,
370            is_linux: true,
371        };
372        let report = build_readiness_report("t".to_string(), vec![BuildOs::Zephyr], &probe);
373        assert!(report.summary.warn >= 4); // west, cmake, ninja, zephyrSdk
374        assert_eq!(report.summary.fail, 0);
375        assert!(!report.next_steps.is_empty());
376    }
377
378    #[test]
379    fn cmake_not_duplicated_across_zephyr_and_baremetal() {
380        let report = build_readiness_report(
381            "t".to_string(),
382            vec![BuildOs::Zephyr, BuildOs::Baremetal],
383            &probe_all_present(),
384        );
385        assert_eq!(
386            report.checks.iter().filter(|c| c.name == "cmake").count(),
387            1
388        );
389    }
390
391    #[test]
392    fn yocto_on_non_linux_warns() {
393        let probe = BuildToolProbe {
394            is_linux: false,
395            ..probe_all_present()
396        };
397        let report = build_readiness_report("t".to_string(), vec![BuildOs::Yocto], &probe);
398        assert!(report.checks.iter().any(|c| c.name == "yoctoHost"));
399    }
400
401    #[test]
402    fn yocto_flash_checks_bmaptool() {
403        // bmaptool present → a passing flash-prereq check.
404        let pass = build_readiness_report("t".to_string(), vec![BuildOs::Yocto], &probe_all_present());
405        assert!(
406            pass.checks
407                .iter()
408                .any(|c| c.name == "bmaptool" && c.status == DoctorStatus::Pass)
409        );
410
411        // Neither bmaptool nor dd → warn + a next step (and no bmaptool check for
412        // a Zephyr-only project).
413        let probe = BuildToolProbe {
414            bmaptool: false,
415            dd: false,
416            ..probe_all_present()
417        };
418        let warn = build_readiness_report("t".to_string(), vec![BuildOs::Yocto], &probe);
419        assert!(
420            warn.checks
421                .iter()
422                .any(|c| c.name == "bmaptool" && c.status == DoctorStatus::Warn)
423        );
424        let zephyr_only =
425            build_readiness_report("t".to_string(), vec![BuildOs::Zephyr], &probe_all_present());
426        assert!(!zephyr_only.checks.iter().any(|c| c.name == "bmaptool"));
427    }
428}