Skip to main content

alp_core/
validate.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local (offline) board.yaml validation.
3//!
4//! Mirrors the TypeScript `validateBoardYamlLocally` in
5//! `@alp-sdk/core/validation/service.ts`. This is the offline parity
6//! target shared by the conformance fixtures; full SDK-spawn validation
7//! arrives in a later phase.
8
9use crate::model::BoardModel;
10
11/// Validation outcome — stable string identifiers shared with the CLI
12/// envelope and the TS implementation.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Outcome {
15    /// Validation passed with no issues.
16    Clean,
17    /// SoM/preset could not be resolved (validator exit 2); treated as a warning.
18    MissingPreset,
19    /// Schema/structural violation (validator exit 1).
20    SchemaViolation,
21    /// Hardware-revision incompatibility (validator exit 3).
22    HardwareRevision,
23    /// Validation could not be completed (validator crashed / unknown exit status).
24    Failed,
25}
26
27impl Outcome {
28    /// Stable string identifier for this outcome, as emitted in the CLI envelope.
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Outcome::Clean => "clean",
32            Outcome::MissingPreset => "missing-preset",
33            Outcome::SchemaViolation => "schema-violation",
34            Outcome::HardwareRevision => "hardware-revision",
35            Outcome::Failed => "failed",
36        }
37    }
38}
39
40/// Per-issue severity level.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Severity {
43    Error,
44    Warning,
45    Suggestion,
46}
47
48impl Severity {
49    /// Stable lowercase string identifier for this severity.
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Severity::Error => "error",
53            Severity::Warning => "warning",
54            Severity::Suggestion => "suggestion",
55        }
56    }
57}
58
59/// A single validation finding: human-readable `message` plus its `severity`.
60#[derive(Debug, Clone)]
61pub struct ValidationIssue {
62    pub message: String,
63    pub severity: Severity,
64}
65
66/// Aggregate validation result: overall `outcome` plus the list of `issues`.
67#[derive(Debug, Clone)]
68pub struct ValidationResult {
69    pub outcome: Outcome,
70    pub issues: Vec<ValidationIssue>,
71}
72
73/// Error returned when board.yaml text cannot be parsed into a `BoardModel`.
74#[derive(Debug, thiserror::Error)]
75pub enum ParseError {
76    /// The input was not syntactically valid YAML.
77    #[error("board.yaml is not valid YAML: {0}")]
78    Yaml(#[from] serde_yaml::Error),
79}
80
81/// Parse board.yaml text into a [`BoardModel`].
82///
83/// Matches TS `parseBoardModel`: a YAML scalar/null parses to the default
84/// model rather than an error; only true syntax errors fail.
85pub fn parse_board_model(text: &str) -> Result<BoardModel, ParseError> {
86    match serde_yaml::from_str::<Option<BoardModel>>(text) {
87        Ok(Some(model)) => Ok(model),
88        Ok(None) => Ok(BoardModel::default()),
89        Err(e) => Err(ParseError::Yaml(e)),
90    }
91}
92
93/// Local structural validation. Mirrors `validateBoardYamlLocally`:
94/// for schema_version >= 2, top-level `os:` is rejected and a non-empty
95/// `cores:` block is required.
96pub fn validate_board_yaml_local(text: &str) -> Result<ValidationResult, ParseError> {
97    let model = parse_board_model(text)?;
98    let mut issues = Vec::new();
99
100    if model.effective_schema_version() >= 2 {
101        if model.os.is_some() {
102            issues.push(ValidationIssue {
103                message:
104                    "board.yaml v2: top-level 'os:' is not valid; move it into a 'cores:' block"
105                        .to_string(),
106                severity: Severity::Error,
107            });
108        }
109        let has_cores = model.cores.as_ref().is_some_and(|c| !c.is_empty());
110        if !has_cores {
111            issues.push(ValidationIssue {
112                message:
113                    "board.yaml v2: 'cores:' block is required and must have at least one entry"
114                        .to_string(),
115                severity: Severity::Error,
116            });
117        }
118    }
119
120    let outcome = if issues.is_empty() {
121        Outcome::Clean
122    } else {
123        Outcome::SchemaViolation
124    };
125
126    Ok(ValidationResult { outcome, issues })
127}
128
129// ───────────────────────── full (spawn) validation ─────────────────────────
130//
131// The real `alp validate` shells out to `<sdk>/scripts/validate_board_yaml.py`.
132// The analysis below mirrors TS `analyzeValidationResult` + `parseValidationIssues`:
133// classify by the process exit status, then turn stderr into structured issues.
134
135/// Result of spawning the Python validator (mirrors TS `ValidatorExecutionResult`).
136pub struct ValidatorExecution {
137    /// Process exit status; `None` if the process was killed / never started.
138    pub status: Option<i32>,
139    /// Captured standard output of the validator process.
140    pub stdout: String,
141    /// Captured standard error; the source of structured issues.
142    pub stderr: String,
143}
144
145/// Map the validator's exit status to an [`Outcome`] (TS `classifyValidationOutcome`).
146pub fn classify_validation_outcome(status: Option<i32>) -> Outcome {
147    match status {
148        Some(0) => Outcome::Clean,
149        Some(2) => Outcome::MissingPreset,
150        Some(3) => Outcome::HardwareRevision,
151        Some(1) => Outcome::SchemaViolation,
152        _ => Outcome::Failed,
153    }
154}
155
156fn severity_for_outcome(outcome: Outcome) -> Severity {
157    if outcome == Outcome::MissingPreset {
158        Severity::Warning
159    } else {
160        Severity::Error
161    }
162}
163
164/// Classify + parse a validator execution into a [`ValidationResult`]
165/// (TS `analyzeValidationResult`).
166pub fn analyze_validation_result(execution: &ValidatorExecution) -> ValidationResult {
167    let outcome = classify_validation_outcome(execution.status);
168    let issues = parse_validation_issues(&execution.stderr, severity_for_outcome(outcome));
169    ValidationResult { outcome, issues }
170}
171
172/// Parse validator stderr into structured issues — a faithful port of TS
173/// `parseValidationIssues`. Handles rich `error[ALP-B*]` blocks (with `-->`
174/// location arrows + indented source/hint continuation), legacy `FAIL`/`WARN`
175/// lines with indented continuations, and standalone `hint:` suggestions.
176///
177/// The CLI only consumes `message` + `severity` (it rewrites the issue code to
178/// `validate.<outcome>`), so block code/line/col are parsed for correct line
179/// skipping but not retained.
180fn parse_validation_issues(stderr: &str, severity: Severity) -> Vec<ValidationIssue> {
181    let lines: Vec<&str> = stderr
182        .split('\n')
183        .map(|l| l.strip_suffix('\r').unwrap_or(l))
184        .collect();
185
186    let mut issues = Vec::new();
187    let mut i = 0;
188    while i < lines.len() {
189        let line = lines[i];
190
191        if line.trim().is_empty() || is_summary_line(line) {
192            i += 1;
193            continue;
194        }
195
196        // Rich ALP-B* block.
197        if let Some((sev, message)) = parse_rich_header(line) {
198            let issue_severity = match sev {
199                "error" => Severity::Error,
200                "warning" => Severity::Warning,
201                _ => Severity::Suggestion,
202            };
203            let issue = ValidationIssue {
204                message: message.trim().to_string(),
205                severity: issue_severity,
206            };
207            if i + 1 < lines.len() && is_arrow_line(lines[i + 1]) {
208                i += 2;
209                while i < lines.len() && is_block_continuation(lines[i]) {
210                    i += 1;
211                }
212                issues.push(issue);
213                continue;
214            }
215            issues.push(issue);
216            i += 1;
217            continue;
218        }
219
220        // Legacy FAIL / WARN lines.
221        if let Some((level, rest)) = parse_fail_warn(line) {
222            let issue_severity = if level == "WARN" {
223                Severity::Warning
224            } else {
225                severity
226            };
227            let mut parts = vec![rest.trim().to_string()];
228            while i + 1 < lines.len() && is_fail_continuation(lines[i + 1]) {
229                i += 1;
230                parts.push(lines[i].trim().to_string());
231            }
232            issues.push(ValidationIssue {
233                message: parts.join("  "),
234                severity: issue_severity,
235            });
236            i += 1;
237            continue;
238        }
239
240        // Standalone hint / suggestion lines.
241        if is_hint_line(line) {
242            issues.push(ValidationIssue {
243                message: line.trim().to_string(),
244                severity: Severity::Suggestion,
245            });
246            i += 1;
247            continue;
248        }
249
250        i += 1;
251    }
252
253    issues
254}
255
256/// `^\S.*\.yaml:\s+(missing-preset|hardware|capability)` — non-actionable
257/// summary lines emitted by `validate_board_yaml.py main()`.
258fn is_summary_line(line: &str) -> bool {
259    let Some(first) = line.chars().next() else {
260        return false;
261    };
262    if first.is_whitespace() {
263        return false;
264    }
265    let after_first = &line[first.len_utf8()..];
266    let Some(rel) = after_first.find(".yaml:") else {
267        return false;
268    };
269    let rest = &after_first[rel + ".yaml:".len()..];
270    let trimmed = rest.trim_start();
271    if trimmed.len() == rest.len() {
272        return false; // needs at least one whitespace (\s+)
273    }
274    trimmed.starts_with("missing-preset")
275        || trimmed.starts_with("hardware")
276        || trimmed.starts_with("capability")
277}
278
279/// `^(error|warning|note)\[(ALP-[A-Z]\d+)\]:\s*(.+)` — returns (severity, message).
280fn parse_rich_header(line: &str) -> Option<(&str, &str)> {
281    for kw in ["error", "warning", "note"] {
282        if let Some(rest) = line.strip_prefix(kw) {
283            if let Some(rest) = rest.strip_prefix('[') {
284                if let Some(close) = rest.find("]:") {
285                    if is_alp_code(&rest[..close]) {
286                        let message = rest[close + 2..].trim_start();
287                        if !message.is_empty() {
288                            return Some((kw, message));
289                        }
290                    }
291                }
292            }
293        }
294    }
295    None
296}
297
298/// `ALP-[A-Z]\d+`
299fn is_alp_code(code: &str) -> bool {
300    let Some(rest) = code.strip_prefix("ALP-") else {
301        return false;
302    };
303    let mut chars = rest.chars();
304    match chars.next() {
305        Some(c) if c.is_ascii_uppercase() => {}
306        _ => return false,
307    }
308    let digits = chars.as_str();
309    !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
310}
311
312/// `^\s+-->\s+\S+:(\d+):(\d+)` — the location arrow inside a rich block.
313fn is_arrow_line(line: &str) -> bool {
314    if !line.starts_with(char::is_whitespace) {
315        return false;
316    }
317    let Some(after) = line.trim_start().strip_prefix("-->") else {
318        return false;
319    };
320    if !after.starts_with(char::is_whitespace) {
321        return false;
322    }
323    // `split_whitespace` already skips the leading `\s+` after `-->`.
324    let Some(token) = after.split_whitespace().next() else {
325        return false;
326    };
327    let parts: Vec<&str> = token.rsplitn(3, ':').collect();
328    if parts.len() < 3 {
329        return false;
330    }
331    let is_num = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
332    !parts[2].is_empty() && is_num(parts[0]) && is_num(parts[1])
333}
334
335/// `^\s+[|0-9^= ]` — indented source/underline/hint continuation of a rich block.
336fn is_block_continuation(line: &str) -> bool {
337    let mut chars = line.chars();
338    match chars.next() {
339        Some(c) if c.is_whitespace() => {}
340        _ => return false,
341    }
342    match chars.next() {
343        Some(c) if c.is_whitespace() => true, // ≥2 leading whitespace (space ∈ set)
344        Some(c) if c == '|' || c == '^' || c == '=' || c.is_ascii_digit() => true,
345        _ => false,
346    }
347}
348
349/// `^(FAIL|WARN)\s+(.+)` — returns (level, message-without-leading-ws).
350fn parse_fail_warn(line: &str) -> Option<(&str, &str)> {
351    for level in ["FAIL", "WARN"] {
352        if let Some(rest) = line.strip_prefix(level) {
353            if rest.starts_with(char::is_whitespace) {
354                let message = rest.trim_start();
355                if !message.is_empty() {
356                    return Some((level, message));
357                }
358            }
359        }
360    }
361    None
362}
363
364/// `^\s{2,}\S` — indented continuation of a legacy FAIL/WARN line.
365fn is_fail_continuation(line: &str) -> bool {
366    let leading_ws = line.chars().take_while(|c| c.is_whitespace()).count();
367    leading_ws >= 2 && !line.trim_start().is_empty()
368}
369
370/// `^\s*(hint|suggestion|suggest):` (case-insensitive)
371fn is_hint_line(line: &str) -> bool {
372    let lowered = line.trim_start().to_ascii_lowercase();
373    lowered.starts_with("hint:")
374        || lowered.starts_with("suggestion:")
375        || lowered.starts_with("suggest:")
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::model::{Carrier, Inference, Iot, Som, normalize_board_model};
382    use std::collections::BTreeMap;
383
384    #[test]
385    fn v1_board_passes_without_errors() {
386        let text = "som:\n  sku: E1M-AEN701\npreset: e1m-evk\n";
387        let r = validate_board_yaml_local(text).unwrap();
388        assert_eq!(r.outcome, Outcome::Clean);
389        assert!(r.issues.is_empty());
390    }
391
392    #[test]
393    fn v2_clean_board_passes() {
394        let text =
395            "schema_version: 2\nsom:\n  sku: E1M-AEN701\ncores:\n  m55_hp:\n    app: ./src\n";
396        let r = validate_board_yaml_local(text).unwrap();
397        assert_eq!(r.outcome, Outcome::Clean);
398    }
399
400    #[test]
401    fn v2_top_level_os_is_rejected() {
402        let text = "schema_version: 2\nos: zephyr\ncores:\n  m55_hp:\n    app: ./src\n";
403        let r = validate_board_yaml_local(text).unwrap();
404        assert_eq!(r.outcome, Outcome::SchemaViolation);
405        assert_eq!(r.issues.len(), 1);
406    }
407
408    #[test]
409    fn v2_without_cores_is_rejected() {
410        let text = "schema_version: 2\nsom:\n  sku: E1M-AEN701\n";
411        let r = validate_board_yaml_local(text).unwrap();
412        assert_eq!(r.outcome, Outcome::SchemaViolation);
413        assert_eq!(r.issues.len(), 1);
414    }
415
416    #[test]
417    fn parse_rich_board_fields() {
418        let text = r#"
419schema_version: 2
420som:
421  sku: E1M-AEN701
422cores:
423  m55_hp:
424    os: zephyr
425    app: ./src
426    image: app.bin
427    peripherals: [i2c, spi]
428    libraries: [mbedtls]
429    inference:
430      backend: ethos_u
431      default_arena_kib: 256
432    iot:
433      wifi: true
434ipc:
435  - name: telemetry
436    endpoints: [m55_hp, a32_cluster]
437    size_kib: 64
438"#;
439        let model = parse_board_model(text).unwrap();
440        let core = model.cores.unwrap().remove("m55_hp").unwrap();
441        assert_eq!(core.os.as_deref(), Some("zephyr"));
442        assert_eq!(core.peripherals.unwrap(), vec!["i2c", "spi"]);
443        assert_eq!(core.inference.unwrap().default_arena_kib, Some(256));
444        assert_eq!(model.ipc.unwrap()[0].size_kib, 64);
445    }
446
447    #[test]
448    fn normalize_v1_removes_empty_optional_blocks() {
449        let model = BoardModel {
450            schema_version: Some(1),
451            som: Some(Som {
452                sku: Some("E1M-AEN701".to_string()),
453            }),
454            carrier: Some(Carrier {
455                name: Some("E1M-EVK".to_string()),
456                populated: Some(BTreeMap::new()),
457            }),
458            inference: Some(Inference::default()),
459            libraries: Some(Vec::new()),
460            iot: Some(Iot::default()),
461            ..BoardModel::default()
462        };
463
464        let normalized = normalize_board_model(model);
465        assert!(normalized.libraries.is_none());
466        assert!(normalized.iot.is_none());
467        assert!(normalized.inference.is_none());
468        assert!(normalized.carrier.unwrap().populated.is_none());
469    }
470
471    #[test]
472    fn normalize_v2_removes_top_level_os() {
473        let model = BoardModel {
474            schema_version: Some(2),
475            os: Some("zephyr".to_string()),
476            ..BoardModel::default()
477        };
478
479        assert!(normalize_board_model(model).os.is_none());
480    }
481
482    #[test]
483    fn classify_maps_exit_status_to_outcome() {
484        assert_eq!(classify_validation_outcome(Some(0)), Outcome::Clean);
485        assert_eq!(
486            classify_validation_outcome(Some(1)),
487            Outcome::SchemaViolation
488        );
489        assert_eq!(classify_validation_outcome(Some(2)), Outcome::MissingPreset);
490        assert_eq!(
491            classify_validation_outcome(Some(3)),
492            Outcome::HardwareRevision
493        );
494        assert_eq!(classify_validation_outcome(Some(9)), Outcome::Failed);
495        assert_eq!(classify_validation_outcome(None), Outcome::Failed);
496    }
497
498    #[test]
499    fn analyze_parses_rich_alp_block() {
500        let stderr = "error[ALP-B005]: SoM SKU 'E1M-NX9999' does not resolve\n  --> board.yaml:3:8\n   |\n 3 | som: {sku: E1M-NX9999}\n   |          ^^^^^^^^^^^^\n   = hint: did you mean E1M-NX9?\n   = see: docs/diagnostics/ALP-B005.md\n";
501        let execution = ValidatorExecution {
502            status: Some(1),
503            stdout: String::new(),
504            stderr: stderr.to_string(),
505        };
506        let result = analyze_validation_result(&execution);
507        assert_eq!(result.outcome, Outcome::SchemaViolation);
508        assert_eq!(result.issues.len(), 1, "block continuation must be skipped");
509        assert_eq!(result.issues[0].severity, Severity::Error);
510        assert_eq!(
511            result.issues[0].message,
512            "SoM SKU 'E1M-NX9999' does not resolve"
513        );
514    }
515
516    #[test]
517    fn analyze_parses_legacy_fail_warn_with_continuation() {
518        let stderr = "FAIL som preset: no preset for E1M-NX9999\n     expected shared definition at metadata/boards/...\nWARN hw_compat: minor version mismatch\n";
519        let execution = ValidatorExecution {
520            status: Some(2),
521            stdout: String::new(),
522            stderr: stderr.to_string(),
523        };
524        let result = analyze_validation_result(&execution);
525        assert_eq!(result.outcome, Outcome::MissingPreset);
526        assert_eq!(result.issues.len(), 2);
527        // FAIL line folds in its indented continuation; severity follows outcome.
528        assert_eq!(
529            result.issues[0].message,
530            "som preset: no preset for E1M-NX9999  expected shared definition at metadata/boards/..."
531        );
532        assert_eq!(result.issues[0].severity, Severity::Warning); // missing-preset outcome
533        // WARN line is always a warning regardless of outcome.
534        assert_eq!(result.issues[1].severity, Severity::Warning);
535        assert_eq!(
536            result.issues[1].message,
537            "hw_compat: minor version mismatch"
538        );
539    }
540
541    #[test]
542    fn analyze_skips_summary_lines_and_keeps_hints() {
543        let stderr = "board.yaml: missing-preset\nhint: run `alp presets` to list valid SKUs\n";
544        let execution = ValidatorExecution {
545            status: Some(2),
546            stdout: String::new(),
547            stderr: stderr.to_string(),
548        };
549        let result = analyze_validation_result(&execution);
550        assert_eq!(result.issues.len(), 1);
551        assert_eq!(result.issues[0].severity, Severity::Suggestion);
552        assert!(result.issues[0].message.starts_with("hint:"));
553    }
554
555    #[test]
556    fn clean_execution_has_no_issues() {
557        let execution = ValidatorExecution {
558            status: Some(0),
559            stdout: String::new(),
560            stderr: String::new(),
561        };
562        let result = analyze_validation_result(&execution);
563        assert_eq!(result.outcome, Outcome::Clean);
564        assert!(result.issues.is_empty());
565    }
566
567    #[test]
568    fn rich_header_rejects_non_alp_code() {
569        assert!(parse_rich_header("error[B005]: nope").is_none());
570        assert!(parse_rich_header("error[ALP-B005]: ok").is_some());
571        assert!(parse_rich_header("note[ALP-Z9]: hi").is_some());
572    }
573}