1use crate::model::BoardModel;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Outcome {
15 Clean,
17 MissingPreset,
19 SchemaViolation,
21 HardwareRevision,
23 Failed,
25}
26
27impl Outcome {
28 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Severity {
43 Error,
44 Warning,
45 Suggestion,
46}
47
48impl Severity {
49 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#[derive(Debug, Clone)]
61pub struct ValidationIssue {
62 pub message: String,
63 pub severity: Severity,
64}
65
66#[derive(Debug, Clone)]
68pub struct ValidationResult {
69 pub outcome: Outcome,
70 pub issues: Vec<ValidationIssue>,
71}
72
73#[derive(Debug, thiserror::Error)]
75pub enum ParseError {
76 #[error("board.yaml is not valid YAML: {0}")]
78 Yaml(#[from] serde_yaml::Error),
79}
80
81pub 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
93pub 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
129pub struct ValidatorExecution {
137 pub status: Option<i32>,
139 pub stdout: String,
141 pub stderr: String,
143}
144
145pub 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
164fn is_interpreter_crash(stderr: &str) -> bool {
171 stderr.lines().any(|line| {
172 line.trim_start()
173 .starts_with("Traceback (most recent call last):")
174 })
175}
176
177pub fn analyze_validation_result(execution: &ValidatorExecution) -> ValidationResult {
180 let mut outcome = classify_validation_outcome(execution.status);
181 if outcome == Outcome::SchemaViolation && is_interpreter_crash(&execution.stderr) {
186 outcome = Outcome::Failed;
187 }
188 let issues = parse_validation_issues(&execution.stderr, severity_for_outcome(outcome));
189 ValidationResult { outcome, issues }
190}
191
192fn parse_validation_issues(stderr: &str, severity: Severity) -> Vec<ValidationIssue> {
201 let lines: Vec<&str> = stderr
202 .split('\n')
203 .map(|l| l.strip_suffix('\r').unwrap_or(l))
204 .collect();
205
206 let mut issues = Vec::new();
207 let mut i = 0;
208 while i < lines.len() {
209 let line = lines[i];
210
211 if line.trim().is_empty() || is_summary_line(line) {
212 i += 1;
213 continue;
214 }
215
216 if let Some((sev, message)) = parse_rich_header(line) {
218 let issue_severity = match sev {
219 "error" => Severity::Error,
220 "warning" => Severity::Warning,
221 _ => Severity::Suggestion,
222 };
223 let issue = ValidationIssue {
224 message: message.trim().to_string(),
225 severity: issue_severity,
226 };
227 if i + 1 < lines.len() && is_arrow_line(lines[i + 1]) {
228 i += 2;
229 while i < lines.len() && is_block_continuation(lines[i]) {
230 i += 1;
231 }
232 issues.push(issue);
233 continue;
234 }
235 issues.push(issue);
236 i += 1;
237 continue;
238 }
239
240 if let Some((level, rest)) = parse_fail_warn(line) {
242 let issue_severity = if level == "WARN" {
243 Severity::Warning
244 } else {
245 severity
246 };
247 let mut parts = vec![rest.trim().to_string()];
248 while i + 1 < lines.len() && is_fail_continuation(lines[i + 1]) {
249 i += 1;
250 parts.push(lines[i].trim().to_string());
251 }
252 issues.push(ValidationIssue {
253 message: parts.join(" "),
254 severity: issue_severity,
255 });
256 i += 1;
257 continue;
258 }
259
260 if is_hint_line(line) {
262 issues.push(ValidationIssue {
263 message: line.trim().to_string(),
264 severity: Severity::Suggestion,
265 });
266 i += 1;
267 continue;
268 }
269
270 i += 1;
271 }
272
273 issues
274}
275
276fn is_summary_line(line: &str) -> bool {
279 let Some(first) = line.chars().next() else {
280 return false;
281 };
282 if first.is_whitespace() {
283 return false;
284 }
285 let after_first = &line[first.len_utf8()..];
286 let Some(rel) = after_first.find(".yaml:") else {
287 return false;
288 };
289 let rest = &after_first[rel + ".yaml:".len()..];
290 let trimmed = rest.trim_start();
291 if trimmed.len() == rest.len() {
292 return false; }
294 trimmed.starts_with("missing-preset")
295 || trimmed.starts_with("hardware")
296 || trimmed.starts_with("capability")
297}
298
299fn parse_rich_header(line: &str) -> Option<(&str, &str)> {
301 for kw in ["error", "warning", "note"] {
302 if let Some(rest) = line.strip_prefix(kw) {
303 if let Some(rest) = rest.strip_prefix('[') {
304 if let Some(close) = rest.find("]:") {
305 if is_alp_code(&rest[..close]) {
306 let message = rest[close + 2..].trim_start();
307 if !message.is_empty() {
308 return Some((kw, message));
309 }
310 }
311 }
312 }
313 }
314 }
315 None
316}
317
318fn is_alp_code(code: &str) -> bool {
320 let Some(rest) = code.strip_prefix("ALP-") else {
321 return false;
322 };
323 let mut chars = rest.chars();
324 match chars.next() {
325 Some(c) if c.is_ascii_uppercase() => {}
326 _ => return false,
327 }
328 let digits = chars.as_str();
329 !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
330}
331
332fn is_arrow_line(line: &str) -> bool {
334 if !line.starts_with(char::is_whitespace) {
335 return false;
336 }
337 let Some(after) = line.trim_start().strip_prefix("-->") else {
338 return false;
339 };
340 if !after.starts_with(char::is_whitespace) {
341 return false;
342 }
343 let Some(token) = after.split_whitespace().next() else {
345 return false;
346 };
347 let parts: Vec<&str> = token.rsplitn(3, ':').collect();
348 if parts.len() < 3 {
349 return false;
350 }
351 let is_num = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
352 !parts[2].is_empty() && is_num(parts[0]) && is_num(parts[1])
353}
354
355fn is_block_continuation(line: &str) -> bool {
357 let mut chars = line.chars();
358 match chars.next() {
359 Some(c) if c.is_whitespace() => {}
360 _ => return false,
361 }
362 match chars.next() {
363 Some(c) if c.is_whitespace() => true, Some(c) if c == '|' || c == '^' || c == '=' || c.is_ascii_digit() => true,
365 _ => false,
366 }
367}
368
369fn parse_fail_warn(line: &str) -> Option<(&str, &str)> {
371 for level in ["FAIL", "WARN"] {
372 if let Some(rest) = line.strip_prefix(level) {
373 if rest.starts_with(char::is_whitespace) {
374 let message = rest.trim_start();
375 if !message.is_empty() {
376 return Some((level, message));
377 }
378 }
379 }
380 }
381 None
382}
383
384fn is_fail_continuation(line: &str) -> bool {
386 let leading_ws = line.chars().take_while(|c| c.is_whitespace()).count();
387 leading_ws >= 2 && !line.trim_start().is_empty()
388}
389
390fn is_hint_line(line: &str) -> bool {
392 let lowered = line.trim_start().to_ascii_lowercase();
393 lowered.starts_with("hint:")
394 || lowered.starts_with("suggestion:")
395 || lowered.starts_with("suggest:")
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::model::{Carrier, Inference, Iot, Som, normalize_board_model};
402 use std::collections::BTreeMap;
403
404 #[test]
405 fn v1_board_passes_without_errors() {
406 let text = "som:\n sku: E1M-AEN701\npreset: e1m-evk\n";
407 let r = validate_board_yaml_local(text).unwrap();
408 assert_eq!(r.outcome, Outcome::Clean);
409 assert!(r.issues.is_empty());
410 }
411
412 #[test]
413 fn v2_clean_board_passes() {
414 let text =
415 "schema_version: 2\nsom:\n sku: E1M-AEN701\ncores:\n m55_hp:\n app: ./src\n";
416 let r = validate_board_yaml_local(text).unwrap();
417 assert_eq!(r.outcome, Outcome::Clean);
418 }
419
420 #[test]
421 fn v2_top_level_os_is_rejected() {
422 let text = "schema_version: 2\nos: zephyr\ncores:\n m55_hp:\n app: ./src\n";
423 let r = validate_board_yaml_local(text).unwrap();
424 assert_eq!(r.outcome, Outcome::SchemaViolation);
425 assert_eq!(r.issues.len(), 1);
426 }
427
428 #[test]
429 fn v2_without_cores_is_rejected() {
430 let text = "schema_version: 2\nsom:\n sku: E1M-AEN701\n";
431 let r = validate_board_yaml_local(text).unwrap();
432 assert_eq!(r.outcome, Outcome::SchemaViolation);
433 assert_eq!(r.issues.len(), 1);
434 }
435
436 #[test]
437 fn parse_rich_board_fields() {
438 let text = r#"
439schema_version: 2
440som:
441 sku: E1M-AEN701
442cores:
443 m55_hp:
444 os: zephyr
445 app: ./src
446 image: app.bin
447 peripherals: [i2c, spi]
448 libraries: [mbedtls]
449 inference:
450 backend: ethos_u
451 default_arena_kib: 256
452 iot:
453 wifi: true
454ipc:
455 - name: telemetry
456 endpoints: [m55_hp, a32_cluster]
457 size_kib: 64
458"#;
459 let model = parse_board_model(text).unwrap();
460 let core = model.cores.unwrap().remove("m55_hp").unwrap();
461 assert_eq!(core.os.as_deref(), Some("zephyr"));
462 assert_eq!(core.peripherals.unwrap(), vec!["i2c", "spi"]);
463 assert_eq!(core.inference.unwrap().default_arena_kib, Some(256));
464 assert_eq!(model.ipc.unwrap()[0].size_kib, 64);
465 }
466
467 #[test]
468 fn normalize_v1_removes_empty_optional_blocks() {
469 let model = BoardModel {
470 schema_version: Some(1),
471 som: Some(Som {
472 sku: Some("E1M-AEN701".to_string()),
473 }),
474 carrier: Some(Carrier {
475 name: Some("E1M-EVK".to_string()),
476 populated: Some(BTreeMap::new()),
477 }),
478 inference: Some(Inference::default()),
479 libraries: Some(Vec::new()),
480 iot: Some(Iot::default()),
481 ..BoardModel::default()
482 };
483
484 let normalized = normalize_board_model(model);
485 assert!(normalized.libraries.is_none());
486 assert!(normalized.iot.is_none());
487 assert!(normalized.inference.is_none());
488 assert!(normalized.carrier.unwrap().populated.is_none());
489 }
490
491 #[test]
492 fn normalize_v2_removes_top_level_os() {
493 let model = BoardModel {
494 schema_version: Some(2),
495 os: Some("zephyr".to_string()),
496 ..BoardModel::default()
497 };
498
499 assert!(normalize_board_model(model).os.is_none());
500 }
501
502 #[test]
503 fn classify_maps_exit_status_to_outcome() {
504 assert_eq!(classify_validation_outcome(Some(0)), Outcome::Clean);
505 assert_eq!(
506 classify_validation_outcome(Some(1)),
507 Outcome::SchemaViolation
508 );
509 assert_eq!(classify_validation_outcome(Some(2)), Outcome::MissingPreset);
510 assert_eq!(
511 classify_validation_outcome(Some(3)),
512 Outcome::HardwareRevision
513 );
514 assert_eq!(classify_validation_outcome(Some(9)), Outcome::Failed);
515 assert_eq!(classify_validation_outcome(None), Outcome::Failed);
516 }
517
518 #[test]
519 fn analyze_parses_rich_alp_block() {
520 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";
521 let execution = ValidatorExecution {
522 status: Some(1),
523 stdout: String::new(),
524 stderr: stderr.to_string(),
525 };
526 let result = analyze_validation_result(&execution);
527 assert_eq!(result.outcome, Outcome::SchemaViolation);
528 assert_eq!(result.issues.len(), 1, "block continuation must be skipped");
529 assert_eq!(result.issues[0].severity, Severity::Error);
530 assert_eq!(
531 result.issues[0].message,
532 "SoM SKU 'E1M-NX9999' does not resolve"
533 );
534 }
535
536 #[test]
537 fn analyze_parses_legacy_fail_warn_with_continuation() {
538 let stderr = "FAIL som preset: no preset for E1M-NX9999\n expected shared definition at metadata/boards/...\nWARN hw_compat: minor version mismatch\n";
539 let execution = ValidatorExecution {
540 status: Some(2),
541 stdout: String::new(),
542 stderr: stderr.to_string(),
543 };
544 let result = analyze_validation_result(&execution);
545 assert_eq!(result.outcome, Outcome::MissingPreset);
546 assert_eq!(result.issues.len(), 2);
547 assert_eq!(
549 result.issues[0].message,
550 "som preset: no preset for E1M-NX9999 expected shared definition at metadata/boards/..."
551 );
552 assert_eq!(result.issues[0].severity, Severity::Warning); assert_eq!(result.issues[1].severity, Severity::Warning);
555 assert_eq!(
556 result.issues[1].message,
557 "hw_compat: minor version mismatch"
558 );
559 }
560
561 #[test]
562 fn analyze_skips_summary_lines_and_keeps_hints() {
563 let stderr = "board.yaml: missing-preset\nhint: run `alp presets` to list valid SKUs\n";
564 let execution = ValidatorExecution {
565 status: Some(2),
566 stdout: String::new(),
567 stderr: stderr.to_string(),
568 };
569 let result = analyze_validation_result(&execution);
570 assert_eq!(result.issues.len(), 1);
571 assert_eq!(result.issues[0].severity, Severity::Suggestion);
572 assert!(result.issues[0].message.starts_with("hint:"));
573 }
574
575 #[test]
576 fn clean_execution_has_no_issues() {
577 let execution = ValidatorExecution {
578 status: Some(0),
579 stdout: String::new(),
580 stderr: String::new(),
581 };
582 let result = analyze_validation_result(&execution);
583 assert_eq!(result.outcome, Outcome::Clean);
584 assert!(result.issues.is_empty());
585 }
586
587 #[test]
588 fn rich_header_rejects_non_alp_code() {
589 assert!(parse_rich_header("error[B005]: nope").is_none());
590 assert!(parse_rich_header("error[ALP-B005]: ok").is_some());
591 assert!(parse_rich_header("note[ALP-Z9]: hi").is_some());
592 }
593
594 #[test]
595 fn crashed_validator_reclassifies_exit1_traceback_as_failed() {
596 let stderr = "Traceback (most recent call last):\n File \"/sdk/scripts/validate_board_yaml.py\", line 7, in <module>\n import jsonschema\nModuleNotFoundError: No module named 'jsonschema'\n";
600 let execution = ValidatorExecution {
601 status: Some(1),
602 stdout: String::new(),
603 stderr: stderr.to_string(),
604 };
605 let result = analyze_validation_result(&execution);
606 assert_eq!(result.outcome, Outcome::Failed);
607 assert!(result.issues.is_empty());
609 }
610
611 #[test]
612 fn genuine_schema_violation_on_exit1_stays_schema_violation() {
613 let stderr = "FAIL som preset: no preset for E1M-NX9999\n";
615 let execution = ValidatorExecution {
616 status: Some(1),
617 stdout: String::new(),
618 stderr: stderr.to_string(),
619 };
620 let result = analyze_validation_result(&execution);
621 assert_eq!(result.outcome, Outcome::SchemaViolation);
622 assert_eq!(result.issues.len(), 1);
623 }
624
625 #[test]
626 fn crash_guard_detects_indented_traceback_and_ignores_other_exits() {
627 assert!(is_interpreter_crash(
628 " Traceback (most recent call last):\n ...\n"
629 ));
630 assert!(!is_interpreter_crash("FAIL som preset: nope\n"));
631 let execution = ValidatorExecution {
634 status: Some(2),
635 stdout: String::new(),
636 stderr: "Traceback (most recent call last):\n".to_string(),
637 };
638 assert_eq!(
639 analyze_validation_result(&execution).outcome,
640 Outcome::MissingPreset
641 );
642 }
643}