use serde_json::Value;
use std::collections::VecDeque;
use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser};
use yaml_rust2::scanner::Marker;
use crate::model::Location;
use crate::validation::{ValidationCode, ValidationMessage};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FixPlan {
pub diagnostic_code: String,
pub title: String,
pub edits: Vec<FixEdit>,
pub preferred: bool,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FixEdit {
pub range: Location,
pub length: usize,
pub new_text: String,
}
pub fn generate_fix_plan_from_report(report: &Value, max_items: usize) -> Vec<ReportFixItem> {
let Some(files) = report.get("files").and_then(Value::as_array) else {
return Vec::new();
};
let mut items: Vec<ReportFixItem> = Vec::new();
for file in files {
let file_name = file
.get("file")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
for step in file
.get("setup")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(item) = report_item(&file_name, "setup", step) {
items.push(item);
}
}
for test in file
.get("tests")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let test_name = test.get("name").and_then(Value::as_str).unwrap_or("test");
for step in test
.get("steps")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(item) = report_item(&file_name, test_name, step) {
items.push(item);
}
}
}
for step in file
.get("teardown")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(item) = report_item(&file_name, "teardown", step) {
items.push(item);
}
}
}
items.sort_by(|a, b| {
a.priority_rank
.cmp(&b.priority_rank)
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.step.cmp(&b.step))
});
items.truncate(max_items);
items
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReportFixItem {
pub file: String,
pub scope: String,
pub step: String,
pub failure_category: String,
pub error_code: String,
pub priority: &'static str,
pub priority_rank: u64,
pub summary: String,
pub actions: Vec<Value>,
pub request_url: Value,
pub response_status: Value,
pub failed_assertions: Vec<Value>,
}
impl ReportFixItem {
pub fn to_json(&self) -> Value {
serde_json::json!({
"file": self.file,
"scope": self.scope,
"step": self.step,
"failure_category": self.failure_category,
"error_code": self.error_code,
"priority": self.priority,
"priority_rank": self.priority_rank,
"summary": self.summary,
"actions": self.actions,
"evidence": {
"request_url": self.request_url,
"response_status": self.response_status,
"failed_assertions": self.failed_assertions,
}
})
}
}
fn report_item(file_name: &str, scope: &str, step: &Value) -> Option<ReportFixItem> {
if step.get("status")?.as_str()? != "FAILED" {
return None;
}
let failure_category = step
.get("failure_category")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let error_code = step
.get("error_code")
.and_then(Value::as_str)
.unwrap_or(&failure_category)
.to_string();
let failed_assertions = step
.get("assertions")
.and_then(|value| value.get("failures"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let actions = step
.get("remediation_hints")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
Some(ReportFixItem {
file: file_name.to_string(),
scope: scope.to_string(),
step: step
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
failure_category: failure_category.clone(),
error_code: error_code.clone(),
priority: priority_label(&failure_category),
priority_rank: priority_rank(&failure_category),
summary: summary_text(&failure_category, &error_code, &failed_assertions),
actions,
request_url: step
.get("request")
.and_then(|request| request.get("url"))
.cloned()
.unwrap_or(Value::Null),
response_status: step
.get("response")
.and_then(|response| response.get("status"))
.cloned()
.unwrap_or(Value::Null),
failed_assertions,
})
}
fn priority_rank(category: &str) -> u64 {
match category {
"parse_error" => 1,
"connection_error" => 2,
"timeout" => 3,
"capture_error" => 4,
"assertion_failed" => 5,
_ => 9,
}
}
fn priority_label(category: &str) -> &'static str {
match priority_rank(category) {
1 | 2 => "high",
3 | 4 => "medium",
_ => "normal",
}
}
fn summary_text(category: &str, error_code: &str, failed_assertions: &[Value]) -> String {
if let Some(message) = failed_assertions
.first()
.and_then(|failure| failure.get("message"))
.and_then(Value::as_str)
{
return message.to_string();
}
match category {
"connection_error" => format!("Connectivity issue detected ({error_code})."),
"timeout" => format!("Operation timed out ({error_code})."),
"capture_error" => format!("Capture extraction failed ({error_code})."),
"parse_error" => format!("Test definition or interpolation issue detected ({error_code})."),
_ => format!("Test step failed ({error_code})."),
}
}
pub fn generate_fix_plan(source: &str, diagnostics: &[ValidationMessage]) -> Vec<FixPlan> {
let mut out: Vec<FixPlan> = Vec::new();
let mut index: Option<KeySpanIndex> = None;
for diag in diagnostics {
if !matches!(
diag.code,
ValidationCode::TarnValidation | ValidationCode::TarnParse
) {
continue;
}
let Some(parsed) = parse_unknown_field_message(&diag.message) else {
continue;
};
let idx = index.get_or_insert_with(|| KeySpanIndex::build(source));
let Some(span) = idx.find_key(&parsed.context_path, &parsed.unknown) else {
continue;
};
out.push(FixPlan {
diagnostic_code: diag.code.as_str().to_string(),
title: format!("Change '{}' to '{}'", parsed.unknown, parsed.suggestion),
edits: vec![FixEdit {
range: Location {
file: diag
.location
.as_ref()
.map(|loc| loc.file.clone())
.unwrap_or_default(),
line: span.line,
column: span.column,
},
length: parsed.unknown.chars().count(),
new_text: parsed.suggestion.clone(),
}],
preferred: true,
description: None,
});
}
out
}
pub fn has_fix_plan(source: &str, diag: &ValidationMessage) -> bool {
!generate_fix_plan(source, std::slice::from_ref(diag)).is_empty()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct UnknownFieldSuggestion {
unknown: String,
suggestion: String,
context_path: Vec<ContextSegment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ContextSegment {
Key(String),
Index(usize),
}
fn parse_unknown_field_message(message: &str) -> Option<UnknownFieldSuggestion> {
let anchor = message.find("Unknown field '")?;
let rest = &message[anchor + "Unknown field '".len()..];
let end_unknown = rest.find('\'')?;
let unknown = rest[..end_unknown].to_string();
if unknown.is_empty() {
return None;
}
let after_unknown = &rest[end_unknown + 1..];
let at_marker = " at ";
let at_pos = after_unknown.find(at_marker)?;
let after_at = &after_unknown[at_pos + at_marker.len()..];
let did_marker = ". Did you mean '";
let did_pos = after_at.find(did_marker)?;
let context_raw = after_at[..did_pos].trim();
let after_did = &after_at[did_pos + did_marker.len()..];
let end_sugg = after_did.find('\'')?;
let suggestion = after_did[..end_sugg].to_string();
if suggestion.is_empty() {
return None;
}
let context_path = parse_context_path(context_raw)?;
Some(UnknownFieldSuggestion {
unknown,
suggestion,
context_path,
})
}
fn parse_context_path(raw: &str) -> Option<Vec<ContextSegment>> {
let mut out: Vec<ContextSegment> = Vec::new();
for part in raw.split('.') {
if part.is_empty() {
return None;
}
let mut head = part;
let key_end = head.find('[').unwrap_or(head.len());
let key = &head[..key_end];
if key.is_empty() {
return None;
}
out.push(ContextSegment::Key(key.to_string()));
head = &head[key_end..];
while let Some(stripped) = head.strip_prefix('[') {
let close = stripped.find(']')?;
let num: usize = stripped[..close].parse().ok()?;
out.push(ContextSegment::Index(num));
head = &stripped[close + 1..];
}
if !head.is_empty() {
return None;
}
}
Some(out)
}
#[derive(Debug, Clone, Copy)]
struct KeySpan {
line: usize,
column: usize,
}
struct KeySpanIndex {
entries: Vec<(Vec<ContextSegment>, KeySpan)>,
}
impl KeySpanIndex {
fn build(source: &str) -> Self {
let mut sink = EventSink { events: Vec::new() };
let mut parser = Parser::new_from_str(source);
if parser.load(&mut sink, true).is_err() {
return Self {
entries: Vec::new(),
};
}
let mut walker = Walker {
events: &sink.events,
pos: 0,
path: vec![ContextSegment::Key("root".to_string())],
entries: Vec::new(),
};
walker.walk();
Self {
entries: walker.entries,
}
}
fn find_key(&self, context_path: &[ContextSegment], key: &str) -> Option<KeySpan> {
let mut target = context_path.to_vec();
target.push(ContextSegment::Key(key.to_string()));
self.entries
.iter()
.find(|(p, _)| p == &target)
.map(|(_, span)| *span)
}
}
struct EventSink {
events: Vec<(Event, Marker)>,
}
impl MarkedEventReceiver for EventSink {
fn on_event(&mut self, ev: Event, mark: Marker) {
self.events.push((ev, mark));
}
}
struct Walker<'a> {
events: &'a [(Event, Marker)],
pos: usize,
path: Vec<ContextSegment>,
entries: Vec<(Vec<ContextSegment>, KeySpan)>,
}
impl<'a> Walker<'a> {
fn peek(&self) -> Option<&'a (Event, Marker)> {
self.events.get(self.pos)
}
fn advance(&mut self) -> Option<&'a (Event, Marker)> {
let event = self.events.get(self.pos);
if event.is_some() {
self.pos += 1;
}
event
}
fn walk(&mut self) {
let mut queue: VecDeque<()> = VecDeque::new();
queue.push_back(());
while let Some((ev, _)) = self.peek() {
if matches!(ev, Event::StreamStart | Event::DocumentStart) {
self.advance();
continue;
}
break;
}
self.walk_node();
}
fn walk_node(&mut self) {
let Some((event, _)) = self.advance() else {
return;
};
match event {
Event::MappingStart(_, _) => self.walk_mapping(),
Event::SequenceStart(_, _) => self.walk_sequence(),
Event::Scalar(_, _, _, _) | Event::Alias(_) => {
}
_ => {}
}
}
fn walk_mapping(&mut self) {
loop {
match self.peek() {
Some((Event::MappingEnd, _)) => {
self.advance();
return;
}
Some((Event::Scalar(key, _, _, _), mark)) => {
let key = key.clone();
let span = KeySpan {
line: mark.line(),
column: mark.col() + 1,
};
self.advance();
self.path.push(ContextSegment::Key(key));
self.entries.push((self.path.clone(), span));
self.walk_node();
self.path.pop();
}
Some(_) => {
self.walk_node(); self.walk_node(); }
None => return,
}
}
}
fn walk_sequence(&mut self) {
let mut index: usize = 0;
loop {
match self.peek() {
Some((Event::SequenceEnd, _)) => {
self.advance();
return;
}
Some(_) => {
self.path.push(ContextSegment::Index(index));
self.walk_node();
self.path.pop();
index += 1;
}
None => return,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::validation::{Severity, ValidationCode};
#[test]
fn report_fix_plan_returns_empty_when_no_files() {
let report = serde_json::json!({});
let items = generate_fix_plan_from_report(&report, 10);
assert!(items.is_empty());
}
#[test]
fn report_fix_plan_matches_golden_shape_for_single_failure() {
let report = serde_json::json!({
"files": [{
"file": "tests/users.tarn.yaml",
"tests": [{
"name": "smoke",
"steps": [{
"name": "Create user",
"status": "FAILED",
"failure_category": "assertion_failed",
"error_code": "assertion_mismatch",
"remediation_hints": ["hint"],
"assertions": {"failures": [{"message": "Expected HTTP 201, got 400"}]},
"request": {"url": "https://example.test/users"},
"response": {"status": 400}
}]
}]
}]
});
let items = generate_fix_plan_from_report(&report, 10);
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(item.file, "tests/users.tarn.yaml");
assert_eq!(item.scope, "smoke");
assert_eq!(item.step, "Create user");
assert_eq!(item.failure_category, "assertion_failed");
assert_eq!(item.error_code, "assertion_mismatch");
assert_eq!(item.priority, "normal");
assert_eq!(item.priority_rank, 5);
assert_eq!(item.summary, "Expected HTTP 201, got 400");
}
#[test]
fn report_fix_plan_orders_by_priority_then_file_then_step() {
let report = serde_json::json!({
"files": [{
"file": "a.tarn.yaml",
"steps": [],
"tests": [{
"name": "t",
"steps": [
{"name": "z_asserting", "status": "FAILED", "failure_category": "assertion_failed"},
{"name": "a_parse", "status": "FAILED", "failure_category": "parse_error"}
]
}]
}]
});
let items = generate_fix_plan_from_report(&report, 10);
assert_eq!(items.len(), 2);
assert_eq!(items[0].failure_category, "parse_error");
assert_eq!(items[0].priority, "high");
assert_eq!(items[1].failure_category, "assertion_failed");
}
#[test]
fn report_fix_plan_truncates_to_max_items() {
let mut steps = Vec::new();
for i in 0..5 {
steps.push(serde_json::json!({
"name": format!("step_{i}"),
"status": "FAILED",
"failure_category": "assertion_failed"
}));
}
let report = serde_json::json!({
"files": [{
"file": "f.tarn.yaml",
"tests": [{"name": "t", "steps": steps}]
}]
});
let items = generate_fix_plan_from_report(&report, 3);
assert_eq!(items.len(), 3);
}
#[test]
fn report_fix_plan_skips_passing_steps() {
let report = serde_json::json!({
"files": [{
"file": "f.tarn.yaml",
"tests": [{
"name": "t",
"steps": [
{"name": "ok", "status": "PASSED"},
{"name": "bad", "status": "FAILED", "failure_category": "timeout"}
]
}]
}]
});
let items = generate_fix_plan_from_report(&report, 10);
assert_eq!(items.len(), 1);
assert_eq!(items[0].step, "bad");
}
fn msg(message: &str, code: ValidationCode) -> ValidationMessage {
ValidationMessage {
severity: Severity::Error,
code,
message: message.to_string(),
location: Some(Location {
file: "t.tarn.yaml".into(),
line: 1,
column: 1,
}),
}
}
#[test]
fn parse_unknown_field_message_extracts_three_parts() {
let parsed =
parse_unknown_field_message("Unknown field 'step' at root. Did you mean 'steps'?")
.unwrap();
assert_eq!(parsed.unknown, "step");
assert_eq!(parsed.suggestion, "steps");
assert_eq!(
parsed.context_path,
vec![ContextSegment::Key("root".into())]
);
}
#[test]
fn parse_unknown_field_message_handles_index_and_nested_context() {
let parsed = parse_unknown_field_message(
"Unknown field 'header' at root.steps[0].request. Did you mean 'headers'?",
)
.unwrap();
assert_eq!(parsed.unknown, "header");
assert_eq!(parsed.suggestion, "headers");
assert_eq!(
parsed.context_path,
vec![
ContextSegment::Key("root".into()),
ContextSegment::Key("steps".into()),
ContextSegment::Index(0),
ContextSegment::Key("request".into()),
]
);
}
#[test]
fn parse_unknown_field_message_rejects_messages_without_suggestion() {
assert!(parse_unknown_field_message("Unknown field 'step' at root.").is_none());
assert!(parse_unknown_field_message("Step 'x' has empty URL").is_none());
assert!(parse_unknown_field_message("").is_none());
}
#[test]
fn generate_fix_plan_finds_typo_at_root() {
let source = "name: x\nstep: []\n";
let d = msg(
"Unknown field 'step' at root. Did you mean 'steps'?",
ValidationCode::TarnValidation,
);
let plans = generate_fix_plan(source, &[d]);
assert_eq!(plans.len(), 1);
let plan = &plans[0];
assert_eq!(plan.title, "Change 'step' to 'steps'");
assert!(plan.preferred);
assert_eq!(plan.edits.len(), 1);
let edit = &plan.edits[0];
assert_eq!(edit.range.line, 2);
assert_eq!(edit.range.column, 1);
assert_eq!(edit.length, 4);
assert_eq!(edit.new_text, "steps");
}
#[test]
fn generate_fix_plan_finds_typo_in_nested_context() {
let source = "name: x\nsteps:\n - name: s1\n request:\n method: GET\n url: u\n header:\n a: b\n assert:\n status: 200\n";
let d = msg(
"Unknown field 'header' at root.steps[0].request. Did you mean 'headers'?",
ValidationCode::TarnValidation,
);
let plans = generate_fix_plan(source, &[d]);
assert_eq!(plans.len(), 1);
let edit = &plans[0].edits[0];
assert_eq!(edit.range.line, 7);
assert_eq!(edit.range.column, 7);
assert_eq!(edit.length, 6);
assert_eq!(edit.new_text, "headers");
}
#[test]
fn generate_fix_plan_empty_diagnostics_returns_empty() {
let source = "name: x\nsteps: []\n";
let plans = generate_fix_plan(source, &[]);
assert!(plans.is_empty());
}
#[test]
fn generate_fix_plan_skips_yaml_syntax_diagnostics() {
let source = "name: x\nsteps: [\n";
let d = ValidationMessage {
severity: Severity::Error,
code: ValidationCode::YamlSyntax,
message: "did not find expected ',' or ']'".to_string(),
location: Some(Location {
file: "t.tarn.yaml".into(),
line: 1,
column: 1,
}),
};
let plans = generate_fix_plan(source, &[d]);
assert!(plans.is_empty());
}
#[test]
fn generate_fix_plan_skips_messages_without_did_you_mean() {
let source = "name: x\nsteps: []\n";
let d = msg("Step 'x' has empty URL", ValidationCode::TarnParse);
let plans = generate_fix_plan(source, &[d]);
assert!(plans.is_empty());
}
#[test]
fn generate_fix_plan_declines_when_key_not_found_in_source() {
let source =
"name: x\nsteps:\n - name: s1\n request:\n method: GET\n url: u\n";
let d = msg(
"Unknown field 'header' at root.steps[2].request. Did you mean 'headers'?",
ValidationCode::TarnValidation,
);
let plans = generate_fix_plan(source, &[d]);
assert!(plans.is_empty());
}
#[test]
fn generate_fix_plan_handles_multiple_diagnostics_same_file() {
let source = "name: x\nstep: []\nteardowns: []\n";
let d1 = msg(
"Unknown field 'step' at root. Did you mean 'steps'?",
ValidationCode::TarnValidation,
);
let d2 = msg(
"Unknown field 'teardowns' at root. Did you mean 'teardown'?",
ValidationCode::TarnValidation,
);
let plans = generate_fix_plan(source, &[d1, d2]);
assert_eq!(plans.len(), 2);
assert_eq!(plans[0].title, "Change 'step' to 'steps'");
assert_eq!(plans[1].title, "Change 'teardowns' to 'teardown'");
}
#[test]
fn generate_fix_plan_never_reports_advice_only_plans() {
let source = "name: x\nstep: []\n";
let d = msg(
"Unknown field 'step' at root. Did you mean 'steps'?",
ValidationCode::TarnValidation,
);
let plans = generate_fix_plan(source, &[d]);
for plan in &plans {
assert!(!plan.edits.is_empty());
assert!(plan.description.is_none());
}
}
#[test]
fn has_fix_plan_mirrors_generate_fix_plan() {
let source = "name: x\nstep: []\n";
let with_fix = msg(
"Unknown field 'step' at root. Did you mean 'steps'?",
ValidationCode::TarnValidation,
);
let without_fix = msg("Step 'x' has empty URL", ValidationCode::TarnParse);
assert!(has_fix_plan(source, &with_fix));
assert!(!has_fix_plan(source, &without_fix));
}
#[test]
fn parse_context_path_splits_dotted_and_indexed_segments() {
let path = parse_context_path("root.steps[0].request").unwrap();
assert_eq!(
path,
vec![
ContextSegment::Key("root".into()),
ContextSegment::Key("steps".into()),
ContextSegment::Index(0),
ContextSegment::Key("request".into()),
]
);
}
#[test]
fn parse_context_path_rejects_malformed_input() {
assert!(parse_context_path("").is_none());
assert!(parse_context_path("root..steps").is_none());
assert!(parse_context_path("root[abc]").is_none());
}
}