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,
BrittlePattern,
}
impl ValidationCode {
pub fn as_str(&self) -> &'static str {
match self {
ValidationCode::YamlSyntax => "yaml_syntax",
ValidationCode::TarnParse => "tarn_parse",
ValidationCode::TarnValidation => "tarn_validation",
ValidationCode::BrittlePattern => "brittle_pattern",
}
}
}
#[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(test_file) => lint_brittle_patterns(&test_file),
Err(err) => vec![tarn_error_to_message(path, err)],
}
}
fn lint_brittle_patterns(test_file: &crate::model::TestFile) -> Vec<ValidationMessage> {
let mut messages: Vec<ValidationMessage> = Vec::new();
let mut visit = |steps: &[crate::model::Step]| {
for step in steps {
lint_step(step, &mut messages);
}
};
visit(&test_file.setup);
visit(&test_file.steps);
for test in test_file.tests.values() {
visit(&test.steps);
}
visit(&test_file.teardown);
messages
}
fn lint_step(step: &crate::model::Step, messages: &mut Vec<ValidationMessage>) {
if let Some(ref assertion) = step.assertions {
if let Some(ref body) = assertion.body {
for (jsonpath, value) in body {
if !looks_like_list_path(jsonpath) {
continue;
}
if let serde_yaml::Value::Mapping(map) = value {
for (k, _) in map {
if k.as_str() == Some("length") {
messages.push(ValidationMessage {
severity: Severity::Warning,
code: ValidationCode::BrittlePattern,
message: format!(
"Exact array length assertion on `{}` is brittle on shared endpoints. \
Consider `length_gte: N`, `exists_where: {{ ... }}`, or `contains_object: {{ ... }}` to assert by identity instead of count.",
jsonpath
),
location: step.location.clone(),
});
}
}
}
}
}
}
for (name, spec) in &step.capture {
let path_str = match spec {
crate::model::CaptureSpec::JsonPath(s) => Some(s.as_str()),
crate::model::CaptureSpec::Extended(ext) => ext.jsonpath.as_deref(),
};
if let Some(path_str) = path_str {
if is_positional_array_path(path_str) {
messages.push(ValidationMessage {
severity: Severity::Warning,
code: ValidationCode::BrittlePattern,
message: format!(
"Capture `{}` uses positional index `{}` — shared list endpoints can reorder or grow. \
Capture by identity instead, e.g. `jsonpath: \"$.items\"` plus `where: {{ id: \"...\" }}`.",
name, path_str
),
location: step.location.clone(),
});
}
}
}
let method = step.request.method.to_ascii_uppercase();
let is_mutating = matches!(method.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
if is_mutating {
if let Some(ident) = find_static_identifier(&step.request.url) {
messages.push(ValidationMessage {
severity: Severity::Warning,
code: ValidationCode::BrittlePattern,
message: format!(
"Static opaque identifier `{}` embedded in a {method} URL. \
Integration runs that re-execute this test will collide — prefer capturing or generating the id with `$uuid`.",
ident
),
location: step.location.clone(),
});
}
if let Some(ref body) = step.request.body {
if let Some(ident) = find_static_identifier_in_json(body) {
messages.push(ValidationMessage {
severity: Severity::Warning,
code: ValidationCode::BrittlePattern,
message: format!(
"Static opaque identifier `{}` embedded in a {method} request body. \
Consider `{{{{ $uuid }}}}` or a captured id so parallel/repeated runs don't collide.",
ident
),
location: step.location.clone(),
});
}
}
}
}
fn looks_like_list_path(path: &str) -> bool {
let trimmed = path.trim();
if trimmed == "$" {
return true;
}
if trimmed.ends_with("[*]") {
return true;
}
if let Some(rest) = trimmed.strip_prefix("$.") {
if !rest.contains('[') && !rest.contains('?') && !rest.contains('*') {
return true;
}
}
false
}
fn is_positional_array_path(path: &str) -> bool {
let trimmed = path.trim();
let mut chars = trimmed.chars().peekable();
while let Some(c) = chars.next() {
if c == '[' {
let mut inner = String::new();
for &next in chars.clone().collect::<Vec<_>>().iter() {
if next == ']' {
break;
}
inner.push(next);
chars.next();
}
if !inner.is_empty() && inner.chars().all(|c| c.is_ascii_digit()) {
return true;
}
}
}
false
}
fn find_static_identifier(s: &str) -> Option<String> {
if s.contains("{{") {
return None;
}
static UUID_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let uuid = UUID_RE.get_or_init(|| {
regex::Regex::new(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
)
.expect("valid UUID regex")
});
if let Some(m) = uuid.find(s) {
return Some(m.as_str().to_string());
}
static HEX_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let hex =
HEX_RE.get_or_init(|| regex::Regex::new(r"\b[0-9a-fA-F]{24,}\b").expect("valid hex regex"));
if let Some(m) = hex.find(s) {
return Some(m.as_str().to_string());
}
None
}
fn find_static_identifier_in_json(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(s) => find_static_identifier(s),
serde_json::Value::Array(arr) => arr.iter().find_map(find_static_identifier_in_json),
serde_json::Value::Object(obj) => obj.values().find_map(find_static_identifier_in_json),
_ => None,
}
}
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");
assert_eq!(ValidationCode::BrittlePattern.as_str(), "brittle_pattern");
}
#[test]
fn lint_flags_exact_length_on_list_endpoint() {
let source = r#"
name: brittle length
steps:
- name: list users
request:
method: GET
url: http://example.com/users
assert:
status: 200
body:
"$.users":
length: 3
"#;
let msgs = validate_document(Path::new(TEST_PATH), source);
assert_eq!(msgs.len(), 1, "{:#?}", msgs);
assert_eq!(msgs[0].severity, Severity::Warning);
assert_eq!(msgs[0].code, ValidationCode::BrittlePattern);
assert!(msgs[0].message.contains("length"));
assert!(msgs[0].message.contains("$.users"));
}
#[test]
fn lint_allows_length_on_specific_record() {
let source = r#"
name: scoped length
steps:
- name: get user tags
request:
method: GET
url: http://example.com/users/me/tags
assert:
status: 200
body:
"$[0].tags":
length: 2
"#;
let msgs = validate_document(Path::new(TEST_PATH), source);
assert!(
msgs.iter()
.all(|m| m.code != ValidationCode::BrittlePattern),
"unexpected lint warning: {:#?}",
msgs
);
}
#[test]
fn lint_flags_positional_capture() {
let source = r#"
name: positional
steps:
- name: list
request:
method: GET
url: http://example.com/items
capture:
first_id: "$[0].id"
assert:
status: 200
"#;
let msgs = validate_document(Path::new(TEST_PATH), source);
assert!(
msgs.iter().any(|m| m.code == ValidationCode::BrittlePattern
&& m.message.contains("first_id")),
"expected positional-capture warning, got {:#?}",
msgs
);
}
#[test]
fn lint_flags_static_uuid_in_mutating_url() {
let source = r#"
name: static id
steps:
- name: update
request:
method: PATCH
url: http://example.com/users/550e8400-e29b-41d4-a716-446655440000
body: { name: updated }
assert:
status: 200
"#;
let msgs = validate_document(Path::new(TEST_PATH), source);
assert!(
msgs.iter().any(|m| m.code == ValidationCode::BrittlePattern
&& m.message.contains("550e8400")),
"expected static-UUID warning, got {:#?}",
msgs
);
}
#[test]
fn lint_ignores_interpolated_ids() {
let source = r#"
name: dynamic
steps:
- name: update
request:
method: PATCH
url: "http://example.com/users/{{ capture.user_id }}"
body: { name: updated }
assert:
status: 200
"#;
let msgs = validate_document(Path::new(TEST_PATH), source);
assert!(
msgs.iter()
.all(|m| m.code != ValidationCode::BrittlePattern),
"interpolated URL must not be flagged: {:#?}",
msgs
);
}
}