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
164pub 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
172fn 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 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 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 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
256fn 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; }
274 trimmed.starts_with("missing-preset")
275 || trimmed.starts_with("hardware")
276 || trimmed.starts_with("capability")
277}
278
279fn 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
298fn 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
312fn 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 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
335fn 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, Some(c) if c == '|' || c == '^' || c == '=' || c.is_ascii_digit() => true,
345 _ => false,
346 }
347}
348
349fn 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
364fn 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
370fn 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 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); 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}