use std::path::{Path, PathBuf};
use crate::error::TarnError;
use crate::model::Location;
use crate::parser;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationCode {
YamlSyntax,
TarnParse,
TarnValidation,
}
impl ValidationCode {
pub fn as_str(&self) -> &'static str {
match self {
ValidationCode::YamlSyntax => "yaml_syntax",
ValidationCode::TarnParse => "tarn_parse",
ValidationCode::TarnValidation => "tarn_validation",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationMessage {
pub severity: Severity,
pub code: ValidationCode,
pub message: String,
pub location: Option<Location>,
}
pub fn validate_document(path: &Path, source: &str) -> Vec<ValidationMessage> {
if let Err(yaml_err) = serde_yaml::from_str::<serde_yaml::Value>(source) {
let location = yaml_err.location().map(|loc| Location {
file: path.display().to_string(),
line: loc.line(),
column: loc.column(),
});
return vec![ValidationMessage {
severity: Severity::Error,
code: ValidationCode::YamlSyntax,
message: yaml_err.to_string(),
location,
}];
}
match parser::parse_str(source, path) {
Ok(_) => Vec::new(),
Err(err) => vec![tarn_error_to_message(path, err)],
}
}
pub(crate) fn tarn_error_to_message(path: &Path, err: TarnError) -> ValidationMessage {
let code = match &err {
TarnError::Parse(_) => ValidationCode::TarnParse,
TarnError::Validation(_) => ValidationCode::TarnValidation,
_ => ValidationCode::TarnParse,
};
let raw = err.to_string();
let stripped = strip_thiserror_prefix(&raw);
let (message, location) = extract_location_prefix(stripped, path);
ValidationMessage {
severity: Severity::Error,
code,
message,
location,
}
}
fn strip_thiserror_prefix(raw: &str) -> &str {
const PREFIXES: &[&str] = &["Parse error: ", "Validation error: "];
for prefix in PREFIXES {
if let Some(rest) = raw.strip_prefix(prefix) {
return rest;
}
}
raw
}
fn extract_location_prefix(message: &str, path: &Path) -> (String, Option<Location>) {
let prefix = format!("{}:", path.display());
let Some(rest) = message.strip_prefix(&prefix) else {
let bare = format!("{}: ", path.display());
let cleaned = message.strip_prefix(&bare).unwrap_or(message).to_string();
return (cleaned, None);
};
let mut parts = rest.splitn(3, ':');
let line_part = parts.next();
let col_part = parts.next();
let tail = parts.next();
let (Some(line_str), Some(col_str), Some(tail)) = (line_part, col_part, tail) else {
let stripped = message
.strip_prefix(&format!("{}: ", path.display()))
.unwrap_or(message)
.to_string();
return (stripped, None);
};
let (Ok(line), Ok(column)) = (
line_str.trim().parse::<usize>(),
col_str.trim().parse::<usize>(),
) else {
return (message.to_string(), None);
};
let location = Location {
file: PathBuf::from(path).display().to_string(),
line,
column,
};
(tail.trim_start().to_string(), Some(location))
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_PATH: &str = "test.tarn.yaml";
#[test]
fn empty_source_yields_semantic_error() {
let msgs = validate_document(Path::new(TEST_PATH), "");
assert_eq!(msgs.len(), 1);
let msg = &msgs[0];
assert_eq!(msg.severity, Severity::Error);
assert!(matches!(
msg.code,
ValidationCode::TarnParse | ValidationCode::TarnValidation
));
assert!(!msg.message.is_empty());
}
#[test]
fn valid_minimal_document_produces_no_messages() {
let source = "name: smoke\nsteps:\n - name: ping\n request:\n method: GET\n url: http://example.com\n assert:\n status: 200\n";
let msgs = validate_document(Path::new(TEST_PATH), source);
assert!(msgs.is_empty(), "expected no diagnostics, got {:?}", msgs);
}
#[test]
fn yaml_syntax_error_carries_location() {
let source = "name: broken\nsteps: [\n";
let msgs = validate_document(Path::new(TEST_PATH), source);
assert_eq!(msgs.len(), 1);
let msg = &msgs[0];
assert_eq!(msg.severity, Severity::Error);
assert_eq!(msg.code, ValidationCode::YamlSyntax);
assert!(
msg.location.is_some(),
"expected serde_yaml to report a location"
);
}
#[test]
fn tarn_shape_error_on_unknown_top_level_field() {
let source = "name: typo\nstep: []\n";
let msgs = validate_document(Path::new(TEST_PATH), source);
assert_eq!(msgs.len(), 1);
let msg = &msgs[0];
assert_eq!(msg.severity, Severity::Error);
assert_eq!(msg.code, ValidationCode::TarnValidation);
assert!(
!msg.message.starts_with("test.tarn.yaml"),
"path prefix should be stripped from message: {}",
msg.message
);
}
#[test]
fn tarn_validation_error_on_wrong_type() {
let source = "name: typo\nsteps: not-a-list\n";
let msgs = validate_document(Path::new(TEST_PATH), source);
assert_eq!(msgs.len(), 1);
let msg = &msgs[0];
assert_eq!(msg.severity, Severity::Error);
assert_eq!(msg.code, ValidationCode::TarnValidation);
}
#[test]
fn tarn_validation_error_for_empty_steps_and_tests() {
let source = "name: nothing\n";
let msgs = validate_document(Path::new(TEST_PATH), source);
assert_eq!(msgs.len(), 1);
let msg = &msgs[0];
assert_eq!(msg.severity, Severity::Error);
assert_eq!(msg.code, ValidationCode::TarnParse);
assert!(msg.message.contains("steps") || msg.message.contains("tests"));
}
#[test]
fn strip_thiserror_prefix_removes_parse_and_validation() {
assert_eq!(strip_thiserror_prefix("Parse error: hello"), "hello");
assert_eq!(strip_thiserror_prefix("Validation error: hi"), "hi");
assert_eq!(strip_thiserror_prefix("Something else"), "Something else");
}
#[test]
fn extract_location_prefix_parses_line_and_column() {
let (msg, loc) =
extract_location_prefix("test.tarn.yaml:3:5: something broke", Path::new(TEST_PATH));
let loc = loc.expect("expected a location");
assert_eq!(loc.line, 3);
assert_eq!(loc.column, 5);
assert_eq!(msg, "something broke");
}
#[test]
fn extract_location_prefix_handles_bare_path_prefix() {
let (msg, loc) = extract_location_prefix(
"test.tarn.yaml: Step 'x' has empty URL",
Path::new(TEST_PATH),
);
assert!(loc.is_none());
assert_eq!(msg, "Step 'x' has empty URL");
}
#[test]
fn severity_and_code_enums_are_distinct() {
assert_ne!(Severity::Error, Severity::Warning);
assert_eq!(ValidationCode::YamlSyntax.as_str(), "yaml_syntax");
assert_eq!(ValidationCode::TarnParse.as_str(), "tarn_parse");
assert_eq!(ValidationCode::TarnValidation.as_str(), "tarn_validation");
}
}