use std::path::{Path, PathBuf};
use std::process::Command;
use alp_core::{
Outcome, ProjectContext, ValidatorExecution, analyze_validation_result,
validate_board_yaml_local,
};
use super::CommandRun;
use crate::cli::{GlobalArgs, ValidateArgs};
use crate::envelope::{Envelope, Issue, Project};
use crate::exit::ExitCode;
use crate::util::resolve_cli_project_context;
#[derive(serde::Serialize)]
struct ValidateData {
#[serde(rename = "schemaVersion")]
schema_version: String,
outcome: String,
#[serde(rename = "issueCount")]
issue_count: usize,
#[serde(rename = "commandLine")]
command_line: String,
#[serde(rename = "boardYamlPath")]
board_yaml_path: String,
}
pub fn run(g: &GlobalArgs, args: &ValidateArgs) -> CommandRun {
if args.offline {
run_offline(g)
} else {
run_spawn(g)
}
}
fn validation_outcome_exit_code(outcome: Outcome) -> ExitCode {
match outcome {
Outcome::Clean => ExitCode::Success,
Outcome::MissingPreset | Outcome::SchemaViolation | Outcome::HardwareRevision => {
ExitCode::ValidationFailure
}
Outcome::Failed => ExitCode::RuntimeFailure,
}
}
fn to_cli_issues(outcome: Outcome, issues: &[alp_core::ValidationIssue]) -> Vec<Issue> {
let mapped: Vec<Issue> = issues
.iter()
.map(|i| Issue {
code: format!("validate.{}", outcome.as_str()),
severity: i.severity.as_str().to_string(),
message: i.message.clone(),
})
.collect();
if !mapped.is_empty() {
return mapped;
}
if outcome == Outcome::Clean {
return Vec::new();
}
vec![Issue {
code: format!("validate.{}", outcome.as_str()),
severity: "error".to_string(),
message: format!("Validation ended with outcome '{}'.", outcome.as_str()),
}]
}
fn run_spawn(g: &GlobalArgs) -> CommandRun {
let context = resolve_cli_project_context(g);
let project = Project {
root: context.workspace_root.clone(),
board_yaml: context.board_yaml_path.clone(),
};
let board_path = match &context.board_yaml_path {
Some(path) if Path::new(path).exists() => path.clone(),
_ => {
return validation_guard_failure(
g,
project,
&context,
"board-yaml-missing",
"board.yaml path could not be resolved or the file does not exist.",
);
}
};
let Some(sdk_root) = context.sdk_root.clone() else {
return validation_guard_failure(
g,
project,
&context,
"sdk-root-unresolved",
"alp-sdk root is unresolved. Use --sdk-root or place project near alp-sdk checkout.",
);
};
let script_path = Path::new(&sdk_root)
.join("scripts")
.join("validate_board_yaml.py")
.to_string_lossy()
.to_string();
let command_line = format!(
"{} {} --input {}",
context.python_binary, script_path, board_path
);
let execution = match Command::new(&context.python_binary)
.arg(&script_path)
.arg("--input")
.arg(&board_path)
.output()
{
Ok(out) => ValidatorExecution {
status: out.status.code(),
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
stderr: String::from_utf8_lossy(&out.stderr).to_string(),
},
Err(_) => ValidatorExecution {
status: None,
stdout: String::new(),
stderr: String::new(),
},
};
let result = analyze_validation_result(&execution);
let exit = validation_outcome_exit_code(result.outcome);
let issues = to_cli_issues(result.outcome, &result.issues);
let data = ValidateData {
schema_version: "1".to_string(),
outcome: result.outcome.as_str().to_string(),
issue_count: issues.len(),
command_line: command_line.clone(),
board_yaml_path: board_path.clone(),
};
let text = if g.is_json() {
Vec::new()
} else {
spawn_text(result.outcome, &issues, g, &board_path, &command_line)
};
let json = g
.is_json()
.then(|| Envelope::new("validate", project, data, issues, exit.code()).to_json());
CommandRun { exit, text, json }
}
fn validation_guard_failure(
g: &GlobalArgs,
project: Project,
context: &ProjectContext,
code: &str,
message: &str,
) -> CommandRun {
let board_yaml_path = context.board_yaml_path.clone().unwrap_or_default();
let issues = vec![Issue {
code: format!("validate.{code}"),
severity: "error".to_string(),
message: message.to_string(),
}];
let data = ValidateData {
schema_version: "1".to_string(),
outcome: "failed".to_string(),
issue_count: 1,
command_line: String::new(),
board_yaml_path,
};
let text = if g.is_json() {
Vec::new()
} else {
vec![
"validate: validation failure".to_string(),
message.to_string(),
]
};
let json = g.is_json().then(|| {
Envelope::new(
"validate",
project,
data,
issues,
ExitCode::ValidationFailure.code(),
)
.to_json()
});
CommandRun {
exit: ExitCode::ValidationFailure,
text,
json,
}
}
fn spawn_text(
outcome: Outcome,
issues: &[Issue],
g: &GlobalArgs,
board_path: &str,
command_line: &str,
) -> Vec<String> {
let mut lines = Vec::new();
if outcome == Outcome::Clean {
lines.push("validate: clean".to_string());
if !g.quiet {
lines.push(format!("board.yaml: {board_path}"));
}
} else {
lines.push(format!("validate: {}", outcome.as_str()));
if !g.quiet {
for issue in issues {
lines.push(format!("[{}] {}", issue.severity, issue.message));
}
}
}
if g.verbose {
lines.push(format!("cmd: {command_line}"));
}
lines
}
fn resolve_offline_board_path(g: &GlobalArgs) -> PathBuf {
if let Some(b) = &g.board_yaml {
return PathBuf::from(b);
}
let root = g.project.clone().unwrap_or_else(|| ".".to_string());
Path::new(&root).join("board.yaml")
}
fn run_offline(g: &GlobalArgs) -> CommandRun {
let board_path = resolve_offline_board_path(g);
let board_str = board_path.to_string_lossy().to_string();
let project = Project {
root: g.project.clone().or_else(|| Some(".".to_string())),
board_yaml: Some(board_str.clone()),
};
if !board_path.exists() {
return offline_failure(
g,
project,
ExitCode::ValidationFailure,
"board-yaml-missing",
"board.yaml path could not be resolved or the file does not exist.",
&board_str,
);
}
let text = match std::fs::read_to_string(&board_path) {
Ok(t) => t,
Err(e) => {
return offline_failure(
g,
project,
ExitCode::InternalFailure,
"internal-failure",
&format!("could not read board.yaml: {e}"),
&board_str,
);
}
};
match validate_board_yaml_local(&text) {
Ok(result) => {
let exit = validation_outcome_exit_code(result.outcome);
let issues = to_cli_issues(result.outcome, &result.issues);
let data = ValidateData {
schema_version: "1".to_string(),
outcome: result.outcome.as_str().to_string(),
issue_count: issues.len(),
command_line: String::new(),
board_yaml_path: board_str.clone(),
};
let text_lines = if g.is_json() {
Vec::new()
} else {
spawn_text(result.outcome, &issues, g, &board_str, "")
};
let json = g
.is_json()
.then(|| Envelope::new("validate", project, data, issues, exit.code()).to_json());
CommandRun {
exit,
text: text_lines,
json,
}
}
Err(e) => offline_failure(
g,
project,
ExitCode::InternalFailure,
"internal-failure",
&e.to_string(),
&board_str,
),
}
}
fn offline_failure(
g: &GlobalArgs,
project: Project,
exit: ExitCode,
code: &str,
message: &str,
board_path: &str,
) -> CommandRun {
let issues = vec![Issue {
code: format!("validate.{code}"),
severity: "error".to_string(),
message: message.to_string(),
}];
let data = ValidateData {
schema_version: "1".to_string(),
outcome: "failed".to_string(),
issue_count: 1,
command_line: String::new(),
board_yaml_path: board_path.to_string(),
};
let text = if g.is_json() {
Vec::new()
} else {
vec![
"validate: validation failure".to_string(),
message.to_string(),
]
};
let json = g
.is_json()
.then(|| Envelope::new("validate", project, data, issues, exit.code()).to_json());
CommandRun { exit, text, json }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exit_code_mapping_matches_contract() {
assert_eq!(
validation_outcome_exit_code(Outcome::Clean),
ExitCode::Success
);
assert_eq!(
validation_outcome_exit_code(Outcome::SchemaViolation),
ExitCode::ValidationFailure
);
assert_eq!(
validation_outcome_exit_code(Outcome::MissingPreset),
ExitCode::ValidationFailure
);
assert_eq!(
validation_outcome_exit_code(Outcome::HardwareRevision),
ExitCode::ValidationFailure
);
assert_eq!(
validation_outcome_exit_code(Outcome::Failed),
ExitCode::RuntimeFailure
);
}
#[test]
fn non_clean_without_issues_synthesizes_one() {
let issues = to_cli_issues(Outcome::Failed, &[]);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, "validate.failed");
assert!(issues[0].message.contains("outcome 'failed'"));
assert!(to_cli_issues(Outcome::Clean, &[]).is_empty());
}
}