use crate::model::BoardModel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Clean,
MissingPreset,
SchemaViolation,
HardwareRevision,
Failed,
}
impl Outcome {
pub fn as_str(self) -> &'static str {
match self {
Outcome::Clean => "clean",
Outcome::MissingPreset => "missing-preset",
Outcome::SchemaViolation => "schema-violation",
Outcome::HardwareRevision => "hardware-revision",
Outcome::Failed => "failed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Suggestion,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Suggestion => "suggestion",
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationIssue {
pub message: String,
pub severity: Severity,
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub outcome: Outcome,
pub issues: Vec<ValidationIssue>,
}
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("board.yaml is not valid YAML: {0}")]
Yaml(#[from] serde_yaml::Error),
}
pub fn parse_board_model(text: &str) -> Result<BoardModel, ParseError> {
match serde_yaml::from_str::<Option<BoardModel>>(text) {
Ok(Some(model)) => Ok(model),
Ok(None) => Ok(BoardModel::default()),
Err(e) => Err(ParseError::Yaml(e)),
}
}
pub fn validate_board_yaml_local(text: &str) -> Result<ValidationResult, ParseError> {
let model = parse_board_model(text)?;
let mut issues = Vec::new();
if model.effective_schema_version() >= 2 {
if model.os.is_some() {
issues.push(ValidationIssue {
message:
"board.yaml v2: top-level 'os:' is not valid; move it into a 'cores:' block"
.to_string(),
severity: Severity::Error,
});
}
let has_cores = model.cores.as_ref().is_some_and(|c| !c.is_empty());
if !has_cores {
issues.push(ValidationIssue {
message:
"board.yaml v2: 'cores:' block is required and must have at least one entry"
.to_string(),
severity: Severity::Error,
});
}
}
let outcome = if issues.is_empty() {
Outcome::Clean
} else {
Outcome::SchemaViolation
};
Ok(ValidationResult { outcome, issues })
}
pub struct ValidatorExecution {
pub status: Option<i32>,
pub stdout: String,
pub stderr: String,
}
pub fn classify_validation_outcome(status: Option<i32>) -> Outcome {
match status {
Some(0) => Outcome::Clean,
Some(2) => Outcome::MissingPreset,
Some(3) => Outcome::HardwareRevision,
Some(1) => Outcome::SchemaViolation,
_ => Outcome::Failed,
}
}
fn severity_for_outcome(outcome: Outcome) -> Severity {
if outcome == Outcome::MissingPreset {
Severity::Warning
} else {
Severity::Error
}
}
pub fn analyze_validation_result(execution: &ValidatorExecution) -> ValidationResult {
let outcome = classify_validation_outcome(execution.status);
let issues = parse_validation_issues(&execution.stderr, severity_for_outcome(outcome));
ValidationResult { outcome, issues }
}
fn parse_validation_issues(stderr: &str, severity: Severity) -> Vec<ValidationIssue> {
let lines: Vec<&str> = stderr
.split('\n')
.map(|l| l.strip_suffix('\r').unwrap_or(l))
.collect();
let mut issues = Vec::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if line.trim().is_empty() || is_summary_line(line) {
i += 1;
continue;
}
if let Some((sev, message)) = parse_rich_header(line) {
let issue_severity = match sev {
"error" => Severity::Error,
"warning" => Severity::Warning,
_ => Severity::Suggestion,
};
let issue = ValidationIssue {
message: message.trim().to_string(),
severity: issue_severity,
};
if i + 1 < lines.len() && is_arrow_line(lines[i + 1]) {
i += 2;
while i < lines.len() && is_block_continuation(lines[i]) {
i += 1;
}
issues.push(issue);
continue;
}
issues.push(issue);
i += 1;
continue;
}
if let Some((level, rest)) = parse_fail_warn(line) {
let issue_severity = if level == "WARN" {
Severity::Warning
} else {
severity
};
let mut parts = vec![rest.trim().to_string()];
while i + 1 < lines.len() && is_fail_continuation(lines[i + 1]) {
i += 1;
parts.push(lines[i].trim().to_string());
}
issues.push(ValidationIssue {
message: parts.join(" "),
severity: issue_severity,
});
i += 1;
continue;
}
if is_hint_line(line) {
issues.push(ValidationIssue {
message: line.trim().to_string(),
severity: Severity::Suggestion,
});
i += 1;
continue;
}
i += 1;
}
issues
}
fn is_summary_line(line: &str) -> bool {
let Some(first) = line.chars().next() else {
return false;
};
if first.is_whitespace() {
return false;
}
let after_first = &line[first.len_utf8()..];
let Some(rel) = after_first.find(".yaml:") else {
return false;
};
let rest = &after_first[rel + ".yaml:".len()..];
let trimmed = rest.trim_start();
if trimmed.len() == rest.len() {
return false; }
trimmed.starts_with("missing-preset")
|| trimmed.starts_with("hardware")
|| trimmed.starts_with("capability")
}
fn parse_rich_header(line: &str) -> Option<(&str, &str)> {
for kw in ["error", "warning", "note"] {
if let Some(rest) = line.strip_prefix(kw) {
if let Some(rest) = rest.strip_prefix('[') {
if let Some(close) = rest.find("]:") {
if is_alp_code(&rest[..close]) {
let message = rest[close + 2..].trim_start();
if !message.is_empty() {
return Some((kw, message));
}
}
}
}
}
}
None
}
fn is_alp_code(code: &str) -> bool {
let Some(rest) = code.strip_prefix("ALP-") else {
return false;
};
let mut chars = rest.chars();
match chars.next() {
Some(c) if c.is_ascii_uppercase() => {}
_ => return false,
}
let digits = chars.as_str();
!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
}
fn is_arrow_line(line: &str) -> bool {
if !line.starts_with(char::is_whitespace) {
return false;
}
let Some(after) = line.trim_start().strip_prefix("-->") else {
return false;
};
if !after.starts_with(char::is_whitespace) {
return false;
}
let Some(token) = after.split_whitespace().next() else {
return false;
};
let parts: Vec<&str> = token.rsplitn(3, ':').collect();
if parts.len() < 3 {
return false;
}
let is_num = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
!parts[2].is_empty() && is_num(parts[0]) && is_num(parts[1])
}
fn is_block_continuation(line: &str) -> bool {
let mut chars = line.chars();
match chars.next() {
Some(c) if c.is_whitespace() => {}
_ => return false,
}
match chars.next() {
Some(c) if c.is_whitespace() => true, Some(c) if c == '|' || c == '^' || c == '=' || c.is_ascii_digit() => true,
_ => false,
}
}
fn parse_fail_warn(line: &str) -> Option<(&str, &str)> {
for level in ["FAIL", "WARN"] {
if let Some(rest) = line.strip_prefix(level) {
if rest.starts_with(char::is_whitespace) {
let message = rest.trim_start();
if !message.is_empty() {
return Some((level, message));
}
}
}
}
None
}
fn is_fail_continuation(line: &str) -> bool {
let leading_ws = line.chars().take_while(|c| c.is_whitespace()).count();
leading_ws >= 2 && !line.trim_start().is_empty()
}
fn is_hint_line(line: &str) -> bool {
let lowered = line.trim_start().to_ascii_lowercase();
lowered.starts_with("hint:")
|| lowered.starts_with("suggestion:")
|| lowered.starts_with("suggest:")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Carrier, Inference, Iot, Som, normalize_board_model};
use std::collections::BTreeMap;
#[test]
fn v1_board_passes_without_errors() {
let text = "som:\n sku: E1M-AEN701\npreset: e1m-evk\n";
let r = validate_board_yaml_local(text).unwrap();
assert_eq!(r.outcome, Outcome::Clean);
assert!(r.issues.is_empty());
}
#[test]
fn v2_clean_board_passes() {
let text =
"schema_version: 2\nsom:\n sku: E1M-AEN701\ncores:\n m55_hp:\n app: ./src\n";
let r = validate_board_yaml_local(text).unwrap();
assert_eq!(r.outcome, Outcome::Clean);
}
#[test]
fn v2_top_level_os_is_rejected() {
let text = "schema_version: 2\nos: zephyr\ncores:\n m55_hp:\n app: ./src\n";
let r = validate_board_yaml_local(text).unwrap();
assert_eq!(r.outcome, Outcome::SchemaViolation);
assert_eq!(r.issues.len(), 1);
}
#[test]
fn v2_without_cores_is_rejected() {
let text = "schema_version: 2\nsom:\n sku: E1M-AEN701\n";
let r = validate_board_yaml_local(text).unwrap();
assert_eq!(r.outcome, Outcome::SchemaViolation);
assert_eq!(r.issues.len(), 1);
}
#[test]
fn parse_rich_board_fields() {
let text = r#"
schema_version: 2
som:
sku: E1M-AEN701
cores:
m55_hp:
os: zephyr
app: ./src
image: app.bin
peripherals: [i2c, spi]
libraries: [mbedtls]
inference:
backend: ethos_u
default_arena_kib: 256
iot:
wifi: true
ipc:
- name: telemetry
endpoints: [m55_hp, a32_cluster]
size_kib: 64
"#;
let model = parse_board_model(text).unwrap();
let core = model.cores.unwrap().remove("m55_hp").unwrap();
assert_eq!(core.os.as_deref(), Some("zephyr"));
assert_eq!(core.peripherals.unwrap(), vec!["i2c", "spi"]);
assert_eq!(core.inference.unwrap().default_arena_kib, Some(256));
assert_eq!(model.ipc.unwrap()[0].size_kib, 64);
}
#[test]
fn normalize_v1_removes_empty_optional_blocks() {
let model = BoardModel {
schema_version: Some(1),
som: Some(Som {
sku: Some("E1M-AEN701".to_string()),
}),
carrier: Some(Carrier {
name: Some("E1M-EVK".to_string()),
populated: Some(BTreeMap::new()),
}),
inference: Some(Inference::default()),
libraries: Some(Vec::new()),
iot: Some(Iot::default()),
..BoardModel::default()
};
let normalized = normalize_board_model(model);
assert!(normalized.libraries.is_none());
assert!(normalized.iot.is_none());
assert!(normalized.inference.is_none());
assert!(normalized.carrier.unwrap().populated.is_none());
}
#[test]
fn normalize_v2_removes_top_level_os() {
let model = BoardModel {
schema_version: Some(2),
os: Some("zephyr".to_string()),
..BoardModel::default()
};
assert!(normalize_board_model(model).os.is_none());
}
#[test]
fn classify_maps_exit_status_to_outcome() {
assert_eq!(classify_validation_outcome(Some(0)), Outcome::Clean);
assert_eq!(
classify_validation_outcome(Some(1)),
Outcome::SchemaViolation
);
assert_eq!(classify_validation_outcome(Some(2)), Outcome::MissingPreset);
assert_eq!(
classify_validation_outcome(Some(3)),
Outcome::HardwareRevision
);
assert_eq!(classify_validation_outcome(Some(9)), Outcome::Failed);
assert_eq!(classify_validation_outcome(None), Outcome::Failed);
}
#[test]
fn analyze_parses_rich_alp_block() {
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";
let execution = ValidatorExecution {
status: Some(1),
stdout: String::new(),
stderr: stderr.to_string(),
};
let result = analyze_validation_result(&execution);
assert_eq!(result.outcome, Outcome::SchemaViolation);
assert_eq!(result.issues.len(), 1, "block continuation must be skipped");
assert_eq!(result.issues[0].severity, Severity::Error);
assert_eq!(
result.issues[0].message,
"SoM SKU 'E1M-NX9999' does not resolve"
);
}
#[test]
fn analyze_parses_legacy_fail_warn_with_continuation() {
let stderr = "FAIL som preset: no preset for E1M-NX9999\n expected shared definition at metadata/boards/...\nWARN hw_compat: minor version mismatch\n";
let execution = ValidatorExecution {
status: Some(2),
stdout: String::new(),
stderr: stderr.to_string(),
};
let result = analyze_validation_result(&execution);
assert_eq!(result.outcome, Outcome::MissingPreset);
assert_eq!(result.issues.len(), 2);
assert_eq!(
result.issues[0].message,
"som preset: no preset for E1M-NX9999 expected shared definition at metadata/boards/..."
);
assert_eq!(result.issues[0].severity, Severity::Warning); assert_eq!(result.issues[1].severity, Severity::Warning);
assert_eq!(
result.issues[1].message,
"hw_compat: minor version mismatch"
);
}
#[test]
fn analyze_skips_summary_lines_and_keeps_hints() {
let stderr = "board.yaml: missing-preset\nhint: run `alp presets` to list valid SKUs\n";
let execution = ValidatorExecution {
status: Some(2),
stdout: String::new(),
stderr: stderr.to_string(),
};
let result = analyze_validation_result(&execution);
assert_eq!(result.issues.len(), 1);
assert_eq!(result.issues[0].severity, Severity::Suggestion);
assert!(result.issues[0].message.starts_with("hint:"));
}
#[test]
fn clean_execution_has_no_issues() {
let execution = ValidatorExecution {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
};
let result = analyze_validation_result(&execution);
assert_eq!(result.outcome, Outcome::Clean);
assert!(result.issues.is_empty());
}
#[test]
fn rich_header_rejects_non_alp_code() {
assert!(parse_rich_header("error[B005]: nope").is_none());
assert!(parse_rich_header("error[ALP-B005]: ok").is_some());
assert!(parse_rich_header("note[ALP-Z9]: hi").is_some());
}
}