use crate::assert::types::FailureCategory;
use crate::report::failures_command::{build_report, FailureGroup};
use crate::report::summary::{FailuresDoc, SummaryDoc};
use serde_json::{json, Value};
pub const CONCISE_SCHEMA_VERSION: u32 = 1;
pub const MAX_HUMAN_GROUPS: usize = 10;
pub fn render_concise(
summary: &SummaryDoc,
failures: &FailuresDoc,
run_id: &str,
no_color: bool,
) -> String {
let report = build_report(failures, ".tarn/failures.json");
let mut out = String::new();
out.push_str(&header_line(summary, run_id, no_color));
out.push('\n');
if report.total_failures == 0 {
return out;
}
out.push('\n');
out.push_str("failures:\n");
let shown = report.groups.iter().take(MAX_HUMAN_GROUPS);
for group in shown {
out.push_str(&render_group_block(group, no_color));
}
if report.groups.len() > MAX_HUMAN_GROUPS {
let extra = report.groups.len() - MAX_HUMAN_GROUPS;
out.push_str(&format!(
"…and {} more group{} (run `tarn failures` for full list)\n",
extra,
if extra == 1 { "" } else { "s" }
));
}
out
}
pub fn render_json(summary: &SummaryDoc, failures: &FailuresDoc, run_id: &str) -> Value {
let report = build_report(failures, ".tarn/failures.json");
let groups_total = report.groups.len();
let truncated = groups_total > MAX_HUMAN_GROUPS;
let groups: Vec<Value> = report
.groups
.iter()
.take(MAX_HUMAN_GROUPS)
.map(group_to_json)
.collect();
json!({
"schema_version": CONCISE_SCHEMA_VERSION,
"run_id": run_id,
"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,
},
"groups": groups,
"groups_truncated": truncated,
"groups_total": groups_total,
})
}
fn header_line(summary: &SummaryDoc, run_id: &str, no_color: bool) -> String {
let total = summary.totals.steps;
let failed = summary.failed.steps;
let passed = total.saturating_sub(failed);
let duration = format_duration(summary.duration_ms);
let dim_start = if no_color { "" } else { "\x1b[2m" };
let dim_end = if no_color { "" } else { "\x1b[0m" };
let verdict = if failed == 0 { "PASS" } else { "FAIL" };
format!(
"{dim_start}Run {} {dim_end} {} exit {} passed {}/{} steps failed {}/{} steps {}",
run_id,
verdict,
summary.exit_code,
passed,
total,
failed,
total,
duration,
dim_start = dim_start,
dim_end = dim_end,
)
}
fn render_group_block(group: &FailureGroup, no_color: bool) -> String {
let bullet = if no_color {
"●"
} else {
"\x1b[31m●\x1b[0m"
};
let dim_start = if no_color { "" } else { "\x1b[2m" };
let dim_end = if no_color { "" } else { "\x1b[0m" };
let mut out = String::new();
let exemplar = &group.root_cause;
out.push_str(&format!(
"{} {} {}::{}::{}\n",
bullet, group.fingerprint, exemplar.file, exemplar.test, exemplar.step
));
if let Some(req) = &exemplar.request {
let status_piece = exemplar
.response
.as_ref()
.and_then(|r| r.status)
.map(|s| format!(" → {}", s))
.unwrap_or_default();
let body_piece = exemplar
.response
.as_ref()
.and_then(|r| r.body_excerpt.as_ref())
.map(|b| format!(" {}", truncate_excerpt(b, 40)))
.unwrap_or_default();
out.push_str(&format!(
" {}{} {}{}{}{}\n",
dim_start, req.method, req.url, status_piece, body_piece, dim_end,
));
} else {
let first = exemplar
.message
.lines()
.next()
.unwrap_or(exemplar.message.as_str());
out.push_str(&format!(" {}{}{}\n", dim_start, first, dim_end));
}
if !group.blocked_steps.is_empty() {
out.push_str(&format!(
" └─ cascades: {} skipped\n",
group.blocked_steps.len()
));
}
out
}
fn group_to_json(group: &FailureGroup) -> Value {
let exemplar = &group.root_cause;
json!({
"fingerprint": group.fingerprint,
"occurrences": group.occurrences,
"cascades": group.blocked_steps.len(),
"primary": {
"file": exemplar.file,
"test": exemplar.test,
"step": exemplar.step,
"category": exemplar.category.map(category_label),
"method": exemplar.request.as_ref().map(|r| r.method.clone()),
"url": exemplar.request.as_ref().map(|r| r.url.clone()),
"status": exemplar.response.as_ref().and_then(|r| r.status),
"message": exemplar
.message
.lines()
.next()
.unwrap_or(exemplar.message.as_str())
.to_string(),
},
})
}
fn category_label(cat: FailureCategory) -> &'static str {
match cat {
FailureCategory::AssertionFailed => "assertion_failed",
FailureCategory::ResponseShapeMismatch => "response_shape_mismatch",
FailureCategory::ConnectionError => "connection_error",
FailureCategory::Timeout => "timeout",
FailureCategory::ParseError => "parse_error",
FailureCategory::CaptureError => "capture_error",
FailureCategory::UnresolvedTemplate => "unresolved_template",
FailureCategory::SkippedDueToFailedCapture => "skipped_due_to_failed_capture",
FailureCategory::SkippedDueToFailFast => "skipped_due_to_fail_fast",
FailureCategory::SkippedByCondition => "skipped_by_condition",
}
}
fn format_duration(ms: u64) -> String {
if ms < 1_000 {
format!("{}ms", ms)
} else if ms < 60_000 {
format!("{:.1}s", ms as f64 / 1_000.0)
} else {
let total_seconds = ms / 1_000;
let minutes = total_seconds / 60;
let seconds = total_seconds % 60;
format!("{}m{:02}s", minutes, seconds)
}
}
fn truncate_excerpt(input: &str, limit: usize) -> String {
let trimmed: String = input.chars().filter(|c| *c != '\n').collect();
if trimmed.chars().count() <= limit {
return format!("\"{}\"", trimmed);
}
let clipped: String = trimmed.chars().take(limit).collect();
format!("\"{}…\"", clipped)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::summary::{
Counts, FailureEntry, FailureRequest, FailureResponse, RootCauseRef, SUMMARY_SCHEMA_VERSION,
};
fn make_summary(exit_code: i32, total_steps: 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:01Z".into(),
duration_ms: 1234,
exit_code,
totals: Counts {
files: 1,
tests: 1,
steps: total_steps,
},
failed: Counts {
files: if failed_steps == 0 { 0 } else { 1 },
tests: if failed_steps == 0 { 0 } else { 1 },
steps: failed_steps,
},
failed_files: if failed_steps == 0 {
vec![]
} else {
vec!["a.tarn.yaml".into()]
},
rerun_source: None,
}
}
struct FailureFixture<'a> {
file: &'a str,
test: &'a str,
step: &'a str,
cat: FailureCategory,
msg: &'a str,
method: &'a str,
url: &'a str,
status: Option<u16>,
}
fn make_failure(fx: FailureFixture<'_>) -> FailureEntry {
FailureEntry {
file: fx.file.into(),
test: fx.test.into(),
step: fx.step.into(),
failure_category: Some(fx.cat),
message: fx.msg.into(),
request: Some(FailureRequest {
method: fx.method.into(),
url: fx.url.into(),
}),
response: fx.status.map(|s| FailureResponse {
status: Some(s),
body_excerpt: Some(r#"{"error":"internal server error"}"#.into()),
}),
root_cause: None,
response_shape_mismatch: None,
}
}
fn doc(failures: Vec<FailureEntry>) -> FailuresDoc {
FailuresDoc {
schema_version: SUMMARY_SCHEMA_VERSION,
run_id: Some("rid".into()),
failures,
}
}
#[test]
fn passing_run_renders_single_header_line_with_pass_verdict() {
let summary = make_summary(0, 5, 0);
let failures = doc(vec![]);
let out = render_concise(&summary, &failures, "rid", true);
assert!(out.contains("Run rid"));
assert!(out.contains("PASS"));
assert!(out.contains("exit 0"));
assert!(out.contains("passed 5/5 steps"));
assert!(out.contains("failed 0/5 steps"));
assert!(!out.contains("failures:"));
}
fn basic_http_failure() -> FailureFixture<'static> {
FailureFixture {
file: "a.tarn.yaml",
test: "t",
step: "s",
cat: FailureCategory::AssertionFailed,
msg: "Expected HTTP status 200, got 500",
method: "GET",
url: "https://api.test/users",
status: Some(500),
}
}
#[test]
fn failing_run_renders_fail_verdict_and_failures_section() {
let summary = make_summary(1, 3, 1);
let failures = doc(vec![make_failure(basic_http_failure())]);
let out = render_concise(&summary, &failures, "rid", true);
assert!(out.contains("FAIL"));
assert!(out.contains("failures:"));
assert!(out.contains("status:200:500:GET:/users"));
assert!(out.contains("a.tarn.yaml::t::s"));
assert!(out.contains("GET https://api.test/users → 500"));
}
#[test]
fn cascades_are_listed_as_suffix_not_as_extra_occurrences() {
let root = make_failure(FailureFixture {
file: "a.tarn.yaml",
test: "t",
step: "create",
cat: FailureCategory::AssertionFailed,
msg: "Expected HTTP status 201, got 500",
method: "POST",
url: "https://api.test/users",
status: Some(500),
});
let cascade = FailureEntry {
file: "a.tarn.yaml".into(),
test: "t".into(),
step: "delete".into(),
failure_category: Some(FailureCategory::SkippedDueToFailedCapture),
message: "Skipped".into(),
request: None,
response: None,
root_cause: Some(RootCauseRef {
file: "a.tarn.yaml".into(),
test: "t".into(),
step: "create".into(),
}),
response_shape_mismatch: None,
};
let summary = make_summary(1, 2, 2);
let out = render_concise(&summary, &doc(vec![root, cascade]), "rid", true);
assert!(out.contains("cascades: 1 skipped"));
assert_eq!(out.matches("●").count(), 1);
}
#[test]
fn group_list_truncates_past_ten_with_remainder_line() {
let urls: Vec<String> = (0..12)
.map(|i| format!("https://api.test/u{}", i))
.collect();
let steps: Vec<String> = (0..12).map(|i| format!("s{}", i)).collect();
let entries: Vec<FailureEntry> = (0..12)
.map(|i| {
make_failure(FailureFixture {
file: "a.tarn.yaml",
test: "t",
step: &steps[i],
cat: FailureCategory::AssertionFailed,
msg: "Expected HTTP status 200, got 500",
method: "GET",
url: &urls[i],
status: Some(500),
})
})
.collect();
let summary = make_summary(1, 12, 12);
let out = render_concise(&summary, &doc(entries), "rid", true);
assert_eq!(
out.matches("●").count(),
MAX_HUMAN_GROUPS,
"expected only the first {} groups rendered, got: {}",
MAX_HUMAN_GROUPS,
out
);
assert!(out.contains("…and 2 more groups"));
assert!(out.contains("tarn failures"));
}
#[test]
fn json_mode_emits_documented_schema_keys() {
let summary = make_summary(1, 2, 1);
let failures = doc(vec![make_failure(basic_http_failure())]);
let v = render_json(&summary, &failures, "rid");
assert_eq!(v["schema_version"], CONCISE_SCHEMA_VERSION);
assert_eq!(v["run_id"], "rid");
assert_eq!(v["exit_code"], 1);
assert_eq!(v["duration_ms"], 1234);
assert_eq!(v["totals"]["steps"], 2);
assert_eq!(v["failed"]["steps"], 1);
assert_eq!(v["groups_truncated"], false);
assert_eq!(v["groups_total"], 1);
let group = &v["groups"][0];
assert_eq!(group["fingerprint"], "status:200:500:GET:/users");
assert_eq!(group["occurrences"], 1);
assert_eq!(group["cascades"], 0);
assert_eq!(group["primary"]["file"], "a.tarn.yaml");
assert_eq!(group["primary"]["status"], 500);
assert_eq!(group["primary"]["method"], "GET");
assert_eq!(group["primary"]["category"], "assertion_failed");
}
#[test]
fn json_mode_reports_groups_truncated_flag_when_over_cap() {
let total = MAX_HUMAN_GROUPS + 3;
let urls: Vec<String> = (0..total)
.map(|i| format!("https://api.test/u{}", i))
.collect();
let steps: Vec<String> = (0..total).map(|i| format!("s{}", i)).collect();
let entries: Vec<FailureEntry> = (0..total)
.map(|i| {
make_failure(FailureFixture {
file: "a.tarn.yaml",
test: "t",
step: &steps[i],
cat: FailureCategory::AssertionFailed,
msg: "Expected HTTP status 200, got 500",
method: "GET",
url: &urls[i],
status: Some(500),
})
})
.collect();
let summary = make_summary(1, total, total);
let v = render_json(&summary, &doc(entries), "rid");
assert_eq!(v["groups_truncated"], true);
assert_eq!(v["groups_total"], total as u64);
assert_eq!(v["groups"].as_array().unwrap().len(), MAX_HUMAN_GROUPS);
}
#[test]
fn no_color_mode_strips_ansi_escapes() {
let summary = make_summary(1, 2, 1);
let failures = doc(vec![make_failure(basic_http_failure())]);
let plain = render_concise(&summary, &failures, "rid", true);
assert!(
!plain.contains('\x1b'),
"no-color mode must not emit ANSI escapes; got: {:?}",
plain
);
let colored = render_concise(&summary, &failures, "rid", false);
assert!(
colored.contains('\x1b'),
"color mode must emit ANSI escapes; got: {:?}",
colored
);
}
}