use crate::report::failures_command::fingerprint_for;
use crate::report::run_dir::{resolve_run_id, run_directory};
use crate::report::summary::{FailureEntry, FailuresDoc, SummaryDoc};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const DIFF_SCHEMA_VERSION: u32 = 1;
#[derive(Debug)]
pub enum DiffError {
NotFound(PathBuf),
Io {
path: PathBuf,
error: std::io::Error,
},
Parse {
path: PathBuf,
error: serde_json::Error,
},
UnknownRunId {
alias: String,
error: std::io::Error,
},
}
impl std::fmt::Display for DiffError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DiffError::NotFound(p) => write!(f, "missing artifact at {}", p.display()),
DiffError::Io { path, error } => {
write!(f, "failed to read {}: {}", path.display(), error)
}
DiffError::Parse { path, error } => {
write!(f, "failed to parse {}: {}", path.display(), error)
}
DiffError::UnknownRunId { alias, error } => {
write!(f, "unknown run id '{}': {}", alias, error)
}
}
}
}
impl std::error::Error for DiffError {}
#[derive(Debug, Clone)]
pub struct DiffSide {
pub run_id: String,
pub summary_path: PathBuf,
pub failures_path: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct DiffFilters<'a> {
pub file: Option<&'a str>,
pub test: Option<&'a str>,
pub category: Option<&'a str>,
}
impl<'a> DiffFilters<'a> {
fn matches(&self, entry: &FailureEntry) -> bool {
if let Some(f) = self.file {
let candidate = entry
.root_cause
.as_ref()
.map(|rc| rc.file.as_str())
.unwrap_or(entry.file.as_str());
if candidate != f {
return false;
}
}
if let Some(t) = self.test {
let candidate = entry
.root_cause
.as_ref()
.map(|rc| rc.test.as_str())
.unwrap_or(entry.test.as_str());
if candidate != t {
return false;
}
}
if let Some(cat) = self.category {
let entry_cat = entry
.failure_category
.map(failure_category_as_str)
.unwrap_or("");
if !entry_cat.eq_ignore_ascii_case(cat) {
return false;
}
}
true
}
}
pub fn resolve_side(workspace_root: &Path, alias: &str) -> Result<DiffSide, DiffError> {
let run_id =
resolve_run_id(workspace_root, alias).map_err(|error| DiffError::UnknownRunId {
alias: alias.to_string(),
error,
})?;
let dir = run_directory(workspace_root, &run_id);
let summary_path = dir.join("summary.json");
let failures_path = dir.join("failures.json");
if !summary_path.is_file() {
return Err(DiffError::NotFound(summary_path));
}
if !failures_path.is_file() {
return Err(DiffError::NotFound(failures_path));
}
Ok(DiffSide {
run_id,
summary_path,
failures_path,
})
}
pub fn load_side(side: &DiffSide) -> Result<(SummaryDoc, FailuresDoc), DiffError> {
let summary: SummaryDoc = read_json(&side.summary_path)?;
let failures: FailuresDoc = read_json(&side.failures_path)?;
Ok((summary, failures))
}
fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, DiffError> {
let bytes = std::fs::read(path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
DiffError::NotFound(path.to_path_buf())
} else {
DiffError::Io {
path: path.to_path_buf(),
error,
}
}
})?;
serde_json::from_slice(&bytes).map_err(|error| DiffError::Parse {
path: path.to_path_buf(),
error,
})
}
pub fn build_diff(
from_side: &DiffSide,
from_summary: &SummaryDoc,
from_failures: &FailuresDoc,
to_side: &DiffSide,
to_summary: &SummaryDoc,
to_failures: &FailuresDoc,
filters: &DiffFilters<'_>,
) -> Value {
let from_map = index_by_fingerprint(&from_failures.failures, filters);
let to_map = index_by_fingerprint(&to_failures.failures, filters);
let mut new_bucket: Vec<FingerprintEntry> = Vec::new();
let mut fixed_bucket: Vec<FingerprintEntry> = Vec::new();
let mut persistent_bucket: Vec<FingerprintEntry> = Vec::new();
for (fp, entries) in &to_map {
if from_map.contains_key(fp) {
persistent_bucket.push(FingerprintEntry::from(fp.clone(), entries));
} else {
new_bucket.push(FingerprintEntry::from(fp.clone(), entries));
}
}
for (fp, entries) in &from_map {
if !to_map.contains_key(fp) {
fixed_bucket.push(FingerprintEntry::from(fp.clone(), entries));
}
}
json!({
"schema_version": DIFF_SCHEMA_VERSION,
"from": side_json(from_side, from_summary),
"to": side_json(to_side, to_summary),
"totals_delta": totals_delta_json(from_summary, to_summary),
"new": new_bucket.iter().map(FingerprintEntry::to_json).collect::<Vec<_>>(),
"fixed": fixed_bucket.iter().map(FingerprintEntry::to_json).collect::<Vec<_>>(),
"persistent": persistent_bucket.iter().map(FingerprintEntry::to_json).collect::<Vec<_>>(),
"filters": json!({
"file": filters.file,
"test": filters.test,
"category": filters.category,
}),
})
}
fn index_by_fingerprint<'a>(
failures: &'a [FailureEntry],
filters: &DiffFilters<'_>,
) -> BTreeMap<String, Vec<&'a FailureEntry>> {
let mut out: BTreeMap<String, Vec<&'a FailureEntry>> = BTreeMap::new();
for entry in failures {
if !filters.matches(entry) {
continue;
}
let fp = fingerprint_for(entry);
out.entry(fp).or_default().push(entry);
}
out
}
fn side_json(side: &DiffSide, summary: &SummaryDoc) -> Value {
json!({
"run_id": side.run_id,
"summary_path": side.summary_path.display().to_string(),
"failures_path": side.failures_path.display().to_string(),
"exit_code": summary.exit_code,
"duration_ms": summary.duration_ms,
"totals": {
"files": summary.totals.files,
"tests": summary.totals.tests,
"steps": summary.totals.steps,
},
"failed": {
"files": summary.failed.files,
"tests": summary.failed.tests,
"steps": summary.failed.steps,
},
})
}
fn totals_delta_json(from: &SummaryDoc, to: &SummaryDoc) -> Value {
fn delta(a: usize, b: usize) -> i64 {
b as i64 - a as i64
}
json!({
"files": delta(from.totals.files, to.totals.files),
"tests": delta(from.totals.tests, to.totals.tests),
"steps": delta(from.totals.steps, to.totals.steps),
"failed_files": delta(from.failed.files, to.failed.files),
"failed_tests": delta(from.failed.tests, to.failed.tests),
"failed_steps": delta(from.failed.steps, to.failed.steps),
"duration_ms": to.duration_ms as i64 - from.duration_ms as i64,
})
}
struct FingerprintEntry {
fingerprint: String,
count: usize,
exemplar: FailureEntry,
}
impl FingerprintEntry {
fn from(fingerprint: String, entries: &[&FailureEntry]) -> Self {
let count = entries.len();
let exemplar = entries[0].clone();
Self {
fingerprint,
count,
exemplar,
}
}
fn to_json(&self) -> Value {
json!({
"fingerprint": self.fingerprint,
"count": self.count,
"exemplar": {
"file": self.exemplar.file,
"test": self.exemplar.test,
"step": self.exemplar.step,
"category": self
.exemplar
.failure_category
.map(failure_category_as_str),
"message": self.exemplar.message,
},
})
}
}
fn failure_category_as_str(cat: crate::assert::types::FailureCategory) -> &'static str {
use crate::assert::types::FailureCategory::*;
match cat {
AssertionFailed => "assertion_failed",
ConnectionError => "connection_error",
Timeout => "timeout",
ParseError => "parse_error",
CaptureError => "capture_error",
UnresolvedTemplate => "unresolved_template",
ResponseShapeMismatch => "response_shape_mismatch",
SkippedDueToFailedCapture => "skipped_due_to_failed_capture",
SkippedDueToFailFast => "skipped_due_to_fail_fast",
SkippedByCondition => "skipped_by_condition",
}
}
pub fn render_json(view: &Value) -> String {
serde_json::to_string_pretty(view).expect("diff view is always JSON-serializable")
}
pub fn render_human(view: &Value) -> String {
let mut out = String::new();
let from_id = view
.get("from")
.and_then(|s| s.get("run_id"))
.and_then(Value::as_str)
.unwrap_or("?");
let to_id = view
.get("to")
.and_then(|s| s.get("run_id"))
.and_then(Value::as_str)
.unwrap_or("?");
out.push_str(&format!("diff: {} -> {}\n", from_id, to_id));
if let Some(deltas) = view.get("totals_delta") {
out.push_str(&format!(
"totals_delta: failed_files={} failed_tests={} failed_steps={} steps={} duration_ms={}\n",
deltas
.get("failed_files")
.and_then(Value::as_i64)
.unwrap_or(0),
deltas
.get("failed_tests")
.and_then(Value::as_i64)
.unwrap_or(0),
deltas
.get("failed_steps")
.and_then(Value::as_i64)
.unwrap_or(0),
deltas.get("steps").and_then(Value::as_i64).unwrap_or(0),
deltas
.get("duration_ms")
.and_then(Value::as_i64)
.unwrap_or(0),
));
}
render_bucket_human("new", view.get("new"), &mut out);
render_bucket_human("fixed", view.get("fixed"), &mut out);
render_bucket_human("persistent", view.get("persistent"), &mut out);
out
}
fn render_bucket_human(label: &str, bucket: Option<&Value>, out: &mut String) {
let empty = Vec::new();
let entries = bucket.and_then(Value::as_array).unwrap_or(&empty);
out.push_str(&format!("{}: {}\n", label, entries.len()));
for entry in entries {
let fp = entry
.get("fingerprint")
.and_then(Value::as_str)
.unwrap_or("?");
let count = entry.get("count").and_then(Value::as_u64).unwrap_or(0);
out.push_str(&format!(" - [{}×] {}\n", count, fp));
if let Some(ex) = entry.get("exemplar") {
out.push_str(&format!(
" {} :: {} :: {}\n",
ex.get("file").and_then(Value::as_str).unwrap_or("?"),
ex.get("test").and_then(Value::as_str).unwrap_or("?"),
ex.get("step").and_then(Value::as_str).unwrap_or("?"),
));
if let Some(msg) = ex.get("message").and_then(Value::as_str) {
let first_line = msg.lines().next().unwrap_or(msg);
if !first_line.is_empty() {
out.push_str(&format!(" {}\n", first_line));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assert::types::FailureCategory;
use crate::report::summary::{
Counts, FailureEntry, FailuresDoc, SummaryDoc, SUMMARY_SCHEMA_VERSION,
};
fn entry(
file: &str,
test: &str,
step: &str,
category: FailureCategory,
message: &str,
) -> FailureEntry {
FailureEntry {
file: file.into(),
test: test.into(),
step: step.into(),
failure_category: Some(category),
message: message.into(),
request: None,
response: None,
root_cause: None,
response_shape_mismatch: None,
}
}
fn summary_with_counts(failed_tests: usize, failed_steps: usize) -> SummaryDoc {
SummaryDoc {
schema_version: SUMMARY_SCHEMA_VERSION,
run_id: Some("rid".into()),
started_at: "2026-01-01T00:00:00Z".into(),
ended_at: "2026-01-01T00:00:00Z".into(),
duration_ms: 100,
exit_code: if failed_steps > 0 { 1 } else { 0 },
totals: Counts {
files: 1,
tests: failed_tests + 1,
steps: failed_steps + 2,
},
failed: Counts {
files: if failed_steps > 0 { 1 } else { 0 },
tests: failed_tests,
steps: failed_steps,
},
failed_files: if failed_steps > 0 {
vec!["a.tarn.yaml".into()]
} else {
vec![]
},
rerun_source: None,
}
}
fn side(run_id: &str) -> DiffSide {
DiffSide {
run_id: run_id.into(),
summary_path: PathBuf::from(format!("/tmp/{}/summary.json", run_id)),
failures_path: PathBuf::from(format!("/tmp/{}/failures.json", run_id)),
}
}
fn failures_doc(entries: Vec<FailureEntry>) -> FailuresDoc {
FailuresDoc {
schema_version: SUMMARY_SCHEMA_VERSION,
run_id: Some("rid".into()),
failures: entries,
}
}
#[test]
fn fingerprints_only_in_to_are_new() {
let from = failures_doc(vec![]);
let to = failures_doc(vec![entry(
"a.tarn.yaml",
"t",
"s",
FailureCategory::AssertionFailed,
"JSONPath $.uuid did not match any value",
)]);
let diff = build_diff(
&side("a"),
&summary_with_counts(0, 0),
&from,
&side("b"),
&summary_with_counts(1, 1),
&to,
&DiffFilters::default(),
);
assert_eq!(diff["new"].as_array().unwrap().len(), 1);
assert_eq!(diff["fixed"].as_array().unwrap().len(), 0);
assert_eq!(diff["persistent"].as_array().unwrap().len(), 0);
assert_eq!(
diff["new"][0]["fingerprint"],
"body_jsonpath:$.uuid:missing"
);
}
#[test]
fn fingerprints_only_in_from_are_fixed() {
let from = failures_doc(vec![entry(
"a.tarn.yaml",
"t",
"s",
FailureCategory::AssertionFailed,
"JSONPath $.uuid did not match any value",
)]);
let to = failures_doc(vec![]);
let diff = build_diff(
&side("a"),
&summary_with_counts(1, 1),
&from,
&side("b"),
&summary_with_counts(0, 0),
&to,
&DiffFilters::default(),
);
assert_eq!(diff["new"].as_array().unwrap().len(), 0);
assert_eq!(diff["fixed"].as_array().unwrap().len(), 1);
assert_eq!(diff["fixed"][0]["count"], 1);
}
#[test]
fn fingerprints_present_on_both_sides_are_persistent() {
let msg = "JSONPath $.uuid did not match any value";
let from = failures_doc(vec![entry(
"a.tarn.yaml",
"t",
"s",
FailureCategory::AssertionFailed,
msg,
)]);
let to = failures_doc(vec![entry(
"a.tarn.yaml",
"t",
"s",
FailureCategory::AssertionFailed,
msg,
)]);
let diff = build_diff(
&side("a"),
&summary_with_counts(1, 1),
&from,
&side("b"),
&summary_with_counts(1, 1),
&to,
&DiffFilters::default(),
);
assert_eq!(diff["persistent"].as_array().unwrap().len(), 1);
assert_eq!(diff["new"].as_array().unwrap().len(), 0);
assert_eq!(diff["fixed"].as_array().unwrap().len(), 0);
}
#[test]
fn totals_delta_is_signed() {
let from = failures_doc(vec![]);
let to = failures_doc(vec![]);
let diff = build_diff(
&side("a"),
&summary_with_counts(3, 5),
&from,
&side("b"),
&summary_with_counts(1, 2),
&to,
&DiffFilters::default(),
);
assert_eq!(diff["totals_delta"]["failed_tests"], -2);
assert_eq!(diff["totals_delta"]["failed_steps"], -3);
}
#[test]
fn file_filter_narrows_each_bucket() {
let from = failures_doc(vec![
entry(
"a.tarn.yaml",
"t",
"s",
FailureCategory::AssertionFailed,
"JSONPath $.a did not match any value",
),
entry(
"b.tarn.yaml",
"t",
"s",
FailureCategory::AssertionFailed,
"JSONPath $.b did not match any value",
),
]);
let to = failures_doc(vec![]);
let filters = DiffFilters {
file: Some("a.tarn.yaml"),
..DiffFilters::default()
};
let diff = build_diff(
&side("a"),
&summary_with_counts(2, 2),
&from,
&side("b"),
&summary_with_counts(0, 0),
&to,
&filters,
);
let fixed = diff["fixed"].as_array().unwrap();
assert_eq!(fixed.len(), 1);
assert_eq!(fixed[0]["exemplar"]["file"], "a.tarn.yaml");
}
#[test]
fn category_filter_narrows_by_failure_category() {
let from = failures_doc(vec![
entry(
"a.tarn.yaml",
"t",
"s",
FailureCategory::AssertionFailed,
"Expected HTTP status 200, got 500",
),
entry(
"a.tarn.yaml",
"u",
"net",
FailureCategory::ConnectionError,
"connection refused",
),
]);
let to = failures_doc(vec![]);
let filters = DiffFilters {
category: Some("connection_error"),
..DiffFilters::default()
};
let diff = build_diff(
&side("a"),
&summary_with_counts(2, 2),
&from,
&side("b"),
&summary_with_counts(0, 0),
&to,
&filters,
);
let fixed = diff["fixed"].as_array().unwrap();
assert_eq!(fixed.len(), 1);
assert_eq!(fixed[0]["exemplar"]["category"], "connection_error");
}
#[test]
fn filters_compose_file_and_test() {
let from = failures_doc(vec![
entry(
"a.tarn.yaml",
"keep",
"s",
FailureCategory::AssertionFailed,
"JSONPath $.x did not match any value",
),
entry(
"a.tarn.yaml",
"drop",
"s",
FailureCategory::AssertionFailed,
"JSONPath $.y did not match any value",
),
]);
let to = failures_doc(vec![]);
let filters = DiffFilters {
file: Some("a.tarn.yaml"),
test: Some("keep"),
..DiffFilters::default()
};
let diff = build_diff(
&side("a"),
&summary_with_counts(2, 2),
&from,
&side("b"),
&summary_with_counts(0, 0),
&to,
&filters,
);
let fixed = diff["fixed"].as_array().unwrap();
assert_eq!(fixed.len(), 1);
assert_eq!(fixed[0]["exemplar"]["test"], "keep");
}
#[test]
fn render_human_lists_each_bucket() {
let from = failures_doc(vec![entry(
"a.tarn.yaml",
"t",
"s",
FailureCategory::AssertionFailed,
"JSONPath $.kept did not match any value",
)]);
let to = failures_doc(vec![
entry(
"a.tarn.yaml",
"t",
"s",
FailureCategory::AssertionFailed,
"JSONPath $.kept did not match any value",
),
entry(
"a.tarn.yaml",
"t",
"s2",
FailureCategory::AssertionFailed,
"JSONPath $.new did not match any value",
),
]);
let diff = build_diff(
&side("from-run"),
&summary_with_counts(1, 1),
&from,
&side("to-run"),
&summary_with_counts(1, 2),
&to,
&DiffFilters::default(),
);
let human = render_human(&diff);
assert!(human.contains("diff: from-run -> to-run"));
assert!(human.contains("persistent: 1"));
assert!(human.contains("new: 1"));
assert!(human.contains("fixed: 0"));
}
}