use crate::assert::types::{FailureCategory, FileResult, RunResult, StepResult};
use crate::fixtures::{SETUP_TEST_SLUG, TEARDOWN_TEST_SLUG};
use crate::model::RedactionConfig;
use crate::report::redaction::{sanitize_json, sanitize_string};
use crate::report::rerun::RerunSource;
use crate::report::shape_diagnosis::ShapeMismatchDiagnosis;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const SUMMARY_SCHEMA_VERSION: u32 = 1;
pub const BODY_EXCERPT_MAX_CHARS: usize = 500;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryDoc {
pub schema_version: u32,
pub run_id: Option<String>,
pub started_at: String,
pub ended_at: String,
pub duration_ms: u64,
pub exit_code: i32,
pub totals: Counts,
pub failed: Counts,
pub failed_files: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rerun_source: Option<RerunSource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Counts {
pub files: usize,
pub tests: usize,
pub steps: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailuresDoc {
pub schema_version: u32,
pub run_id: Option<String>,
pub failures: Vec<FailureEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailureEntry {
pub file: String,
pub test: String,
pub step: String,
#[serde(default)]
pub failure_category: Option<FailureCategory>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request: Option<FailureRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response: Option<FailureResponse>,
#[serde(default)]
pub root_cause: Option<RootCauseRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_shape_mismatch: Option<ShapeMismatchDiagnosis>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailureRequest {
pub method: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailureResponse {
#[serde(default)]
pub status: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_excerpt: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RootCauseRef {
pub file: String,
pub test: String,
pub step: String,
}
pub fn build_summary_and_failures(
result: &RunResult,
started_at: DateTime<Utc>,
ended_at: DateTime<Utc>,
exit_code: i32,
run_id: Option<String>,
rerun_source: Option<RerunSource>,
) -> (SummaryDoc, FailuresDoc) {
let mut totals = Counts {
files: result.file_results.len(),
tests: 0,
steps: 0,
};
let mut failed = Counts {
files: 0,
tests: 0,
steps: 0,
};
let mut failed_files: Vec<String> = Vec::new();
let mut failures: Vec<FailureEntry> = Vec::new();
for file in &result.file_results {
for step in &file.setup_results {
totals.steps += 1;
if !step.passed {
failed.steps += 1;
failures.push(build_failure_entry(file, SETUP_TEST_SLUG, step, None));
}
}
for test in &file.test_results {
totals.tests += 1;
if !test.passed {
failed.tests += 1;
}
for step in &test.step_results {
totals.steps += 1;
if !step.passed {
failed.steps += 1;
let root_cause = resolve_root_cause(file, &test.name, step);
failures.push(build_failure_entry(file, &test.name, step, root_cause));
}
}
}
for step in &file.teardown_results {
totals.steps += 1;
if !step.passed {
failed.steps += 1;
failures.push(build_failure_entry(file, TEARDOWN_TEST_SLUG, step, None));
}
}
if !file.passed {
failed.files += 1;
failed_files.push(file.file.clone());
}
}
let duration_ms = ended_at
.signed_duration_since(started_at)
.num_milliseconds()
.max(0) as u64;
let summary = SummaryDoc {
schema_version: SUMMARY_SCHEMA_VERSION,
run_id: run_id.clone(),
started_at: started_at.to_rfc3339(),
ended_at: ended_at.to_rfc3339(),
duration_ms,
exit_code,
totals,
failed,
failed_files,
rerun_source,
};
let failures_doc = FailuresDoc {
schema_version: SUMMARY_SCHEMA_VERSION,
run_id,
failures,
};
(summary, failures_doc)
}
fn build_failure_entry(
file: &FileResult,
test_label: &str,
step: &StepResult,
root_cause: Option<RootCauseRef>,
) -> FailureEntry {
let secrets = file.redacted_values.clone();
let redaction = &file.redaction;
let message = sanitize_string(
&primary_failure_message(step),
&redaction.replacement,
&secrets,
);
let request = step.request_info.as_ref().map(|req| FailureRequest {
method: req.method.clone(),
url: sanitize_string(&req.url, &redaction.replacement, &secrets),
});
let response = step
.response_info
.as_ref()
.map(|resp| FailureResponse {
status: Some(resp.status),
body_excerpt: resp
.body
.as_ref()
.and_then(|body| body_excerpt(body, redaction, &secrets)),
})
.or_else(|| {
step.response_status.map(|status| FailureResponse {
status: Some(status),
body_excerpt: None,
})
});
FailureEntry {
file: file.file.clone(),
test: test_label.to_string(),
step: step.name.clone(),
failure_category: step.error_category,
message,
request,
response,
root_cause,
response_shape_mismatch: step.response_shape_mismatch.clone(),
}
}
fn primary_failure_message(step: &StepResult) -> String {
step.assertion_results
.iter()
.find(|a| !a.passed)
.map(|a| a.message.clone())
.unwrap_or_else(|| "step failed".to_string())
}
fn body_excerpt(
body: &serde_json::Value,
redaction: &RedactionConfig,
secrets: &[String],
) -> Option<String> {
let sanitized = sanitize_json(body, &redaction.replacement, secrets);
let serialized = serde_json::to_string(&sanitized).ok()?;
if serialized.is_empty() {
return None;
}
if serialized.chars().count() <= BODY_EXCERPT_MAX_CHARS {
return Some(serialized);
}
let end = serialized
.char_indices()
.take(BODY_EXCERPT_MAX_CHARS)
.last()
.map(|(idx, ch)| idx + ch.len_utf8())
.unwrap_or(0);
Some(format!(
"{}…[truncated, {} chars]",
&serialized[..end],
serialized.chars().count()
))
}
fn resolve_root_cause(
file: &FileResult,
test_name: &str,
step: &StepResult,
) -> Option<RootCauseRef> {
let category = step.error_category?;
let test = file.test_results.iter().find(|t| t.name == test_name)?;
let step_index = test
.step_results
.iter()
.position(|s| std::ptr::eq(s, step))?;
let upstream = match category {
FailureCategory::SkippedDueToFailedCapture => {
let missing = extract_missing_capture_names(step);
test.step_results[..step_index].iter().rfind(|prior| {
!prior.passed
&& missing
.iter()
.any(|name| !prior.captures_set.iter().any(|c| c == name))
})
}
FailureCategory::SkippedDueToFailFast => test.step_results[..step_index]
.iter()
.rfind(|prior| !prior.passed && !is_cascade_category(prior.error_category)),
_ => None,
}?;
Some(RootCauseRef {
file: file.file.clone(),
test: test_name.to_string(),
step: upstream.name.clone(),
})
}
fn is_cascade_category(category: Option<FailureCategory>) -> bool {
matches!(
category,
Some(FailureCategory::SkippedDueToFailedCapture)
| Some(FailureCategory::SkippedDueToFailFast)
)
}
fn extract_missing_capture_names(step: &StepResult) -> Vec<String> {
step.assertion_results
.iter()
.find(|a| a.assertion == "cascade")
.and_then(|a| a.actual.strip_prefix("missing: "))
.map(|rest| {
rest.split(',')
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect()
})
.unwrap_or_default()
}
pub fn write_summary_to_dir(dir: &Path, summary: &SummaryDoc) -> std::io::Result<PathBuf> {
write_json_atomic(dir, "summary.json", summary)
}
pub fn write_failures_to_dir(dir: &Path, failures: &FailuresDoc) -> std::io::Result<PathBuf> {
write_json_atomic(dir, "failures.json", failures)
}
fn write_json_atomic<T: Serialize>(
dir: &Path,
file_name: &str,
payload: &T,
) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let path = dir.join(file_name);
let tmp = dir.join(format!("{}.tmp", file_name));
let encoded = serde_json::to_vec_pretty(payload)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&tmp, encoded)?;
std::fs::rename(&tmp, &path)?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assert::types::{AssertionResult, RequestInfo, ResponseInfo, TestResult};
use std::collections::HashMap;
use tempfile::TempDir;
fn passing_step(name: &str) -> StepResult {
StepResult {
name: name.into(),
description: None,
debug: false,
passed: true,
duration_ms: 1,
assertion_results: vec![AssertionResult::pass("status", "200", "200")],
request_info: None,
response_info: None,
error_category: None,
response_status: Some(200),
response_summary: None,
captures_set: vec![],
location: None,
response_shape_mismatch: None,
}
}
fn failing_step_with_http(name: &str) -> StepResult {
let mut headers = HashMap::new();
headers.insert("Content-Type".into(), "application/json".into());
StepResult {
name: name.into(),
description: None,
debug: false,
passed: false,
duration_ms: 1,
assertion_results: vec![AssertionResult::fail(
"status",
"200",
"500",
"status mismatch: expected 200, got 500",
)],
request_info: Some(RequestInfo {
method: "GET".into(),
url: "https://api.test/users".into(),
headers: headers.clone(),
body: None,
multipart: None,
}),
response_info: Some(ResponseInfo {
status: 500,
headers,
body: Some(serde_json::json!({"error": "boom"})),
}),
error_category: Some(FailureCategory::AssertionFailed),
response_status: Some(500),
response_summary: None,
captures_set: vec![],
location: None,
response_shape_mismatch: None,
}
}
fn wrap_file(name: &str, steps: Vec<StepResult>, test_name: &str) -> FileResult {
let passed = steps.iter().all(|s| s.passed);
FileResult {
file: name.into(),
name: name.into(),
passed,
duration_ms: 1,
redaction: RedactionConfig::default(),
redacted_values: vec![],
setup_results: vec![],
test_results: vec![TestResult {
name: test_name.into(),
description: None,
passed,
duration_ms: 1,
step_results: steps,
captures: HashMap::new(),
}],
teardown_results: vec![],
}
}
#[test]
fn passing_run_yields_zero_failure_counts() {
let run = RunResult {
file_results: vec![wrap_file("a.tarn.yaml", vec![passing_step("s1")], "t1")],
duration_ms: 1,
};
let (summary, failures) =
build_summary_and_failures(&run, Utc::now(), Utc::now(), 0, Some("rid".into()), None);
assert_eq!(summary.totals.files, 1);
assert_eq!(summary.totals.tests, 1);
assert_eq!(summary.totals.steps, 1);
assert_eq!(summary.failed.files, 0);
assert_eq!(summary.failed.tests, 0);
assert_eq!(summary.failed.steps, 0);
assert!(summary.failed_files.is_empty());
assert_eq!(summary.exit_code, 0);
assert!(failures.failures.is_empty());
}
#[test]
fn failing_run_populates_failures_with_request_response_and_status() {
let run = RunResult {
file_results: vec![wrap_file(
"tests/users.tarn.yaml",
vec![passing_step("health"), failing_step_with_http("list")],
"happy",
)],
duration_ms: 1,
};
let (summary, failures) =
build_summary_and_failures(&run, Utc::now(), Utc::now(), 1, Some("rid".into()), None);
assert_eq!(summary.totals.steps, 2);
assert_eq!(summary.failed.steps, 1);
assert_eq!(summary.failed.tests, 1);
assert_eq!(summary.failed.files, 1);
assert_eq!(summary.failed_files, vec!["tests/users.tarn.yaml"]);
assert_eq!(failures.failures.len(), 1);
let f = &failures.failures[0];
assert_eq!(f.file, "tests/users.tarn.yaml");
assert_eq!(f.test, "happy");
assert_eq!(f.step, "list");
assert_eq!(f.failure_category, Some(FailureCategory::AssertionFailed));
assert!(f.message.contains("status mismatch"));
let req = f.request.as_ref().expect("request captured");
assert_eq!(req.method, "GET");
assert_eq!(req.url, "https://api.test/users");
let resp = f.response.as_ref().expect("response captured");
assert_eq!(resp.status, Some(500));
assert_eq!(resp.body_excerpt.as_deref(), Some(r#"{"error":"boom"}"#));
}
#[test]
fn body_excerpt_is_truncated_past_limit() {
let huge = "x".repeat(BODY_EXCERPT_MAX_CHARS + 200);
let value = serde_json::json!({ "data": huge });
let excerpt = body_excerpt(&value, &RedactionConfig::default(), &[]).unwrap();
assert!(
excerpt.contains("…[truncated,"),
"expected truncation marker, got: {}",
excerpt
);
assert!(
excerpt.chars().count() > BODY_EXCERPT_MAX_CHARS,
"marker is appended past the cap"
);
let prefix: String = excerpt.chars().take(BODY_EXCERPT_MAX_CHARS).collect();
assert!(!prefix.contains("…"));
}
#[test]
fn body_excerpt_respects_redaction() {
let value = serde_json::json!({ "token": "super-secret" });
let redaction = RedactionConfig {
replacement: "***".into(),
..RedactionConfig::default()
};
let excerpt = body_excerpt(&value, &redaction, &["super-secret".into()]).unwrap();
assert!(
!excerpt.contains("super-secret"),
"redacted secret leaked: {}",
excerpt
);
assert!(excerpt.contains("***"));
}
#[test]
fn write_summary_and_failures_atomic_and_roundtrip() {
let tmp = TempDir::new().unwrap();
let run = RunResult {
file_results: vec![wrap_file("a.tarn.yaml", vec![passing_step("s")], "t")],
duration_ms: 1,
};
let (summary, failures_doc) =
build_summary_and_failures(&run, Utc::now(), Utc::now(), 0, Some("rid".into()), None);
let s_path = write_summary_to_dir(tmp.path(), &summary).unwrap();
let f_path = write_failures_to_dir(tmp.path(), &failures_doc).unwrap();
assert!(s_path.is_file());
assert!(f_path.is_file());
assert!(!tmp.path().join("summary.json.tmp").exists());
assert!(!tmp.path().join("failures.json.tmp").exists());
let s_round: serde_json::Value =
serde_json::from_slice(&std::fs::read(&s_path).unwrap()).unwrap();
assert_eq!(s_round["schema_version"], SUMMARY_SCHEMA_VERSION);
assert_eq!(s_round["run_id"], "rid");
let f_round: serde_json::Value =
serde_json::from_slice(&std::fs::read(&f_path).unwrap()).unwrap();
assert!(f_round["failures"].as_array().unwrap().is_empty());
}
#[test]
fn cascade_skip_root_cause_points_at_upstream_step() {
let mut upstream = failing_step_with_http("create_user");
upstream.error_category = Some(FailureCategory::AssertionFailed);
let cascade_msg = "Skipped: step references capture(s) that failed earlier in this test: \
user_id. Fix the root-cause step first — this cascade failure is a direct \
consequence.";
let cascade = StepResult {
name: "delete_user".into(),
description: None,
debug: false,
passed: false,
duration_ms: 0,
assertion_results: vec![AssertionResult::fail(
"cascade",
"prior captures available".to_string(),
"missing: user_id".to_string(),
cascade_msg,
)],
request_info: None,
response_info: None,
error_category: Some(FailureCategory::SkippedDueToFailedCapture),
response_status: None,
response_summary: None,
captures_set: vec![],
location: None,
response_shape_mismatch: None,
};
let mut file = wrap_file("a.tarn.yaml", vec![upstream, cascade], "happy");
file.passed = false;
file.test_results[0].passed = false;
let run = RunResult {
file_results: vec![file],
duration_ms: 0,
};
let (_, failures_doc) =
build_summary_and_failures(&run, Utc::now(), Utc::now(), 1, Some("rid".into()), None);
let delete_failure = failures_doc
.failures
.iter()
.find(|f| f.step == "delete_user")
.expect("delete step failure emitted");
let root = delete_failure
.root_cause
.as_ref()
.expect("root_cause populated for cascade");
assert_eq!(root.step, "create_user");
assert_eq!(root.test, "happy");
}
}