use std::collections::HashMap;
use std::collections::HashSet;
use std::time::Duration;
use chrono::DateTime;
use chrono::Local;
use serde::Serialize;
use tabled::builder::Builder;
use tabled::settings::Alignment;
use tabled::settings::Modify;
use tabled::settings::Style;
use tabled::settings::Width;
use tabled::settings::object::Columns;
use tabled::settings::span::Span;
use relux_runtime::report::result::format_duration;
use super::analysis::DurationRecord;
use super::analysis::DurationStats;
use super::analysis::FailureModeEntry;
use super::analysis::FailureRecord;
use super::analysis::FirstFailRecord;
use super::analysis::FlakyRecord;
use super::analysis::LoadedRunsCollection;
use super::analysis::TestKey;
use super::loader::LoadedRun;
#[derive(Debug, Serialize)]
struct ReportMeta {
runs: usize,
}
fn build_file_index(test_ids: &[&str]) -> (HashMap<String, usize>, String) {
let mut seen: Vec<String> = Vec::new();
let mut seen_set: HashSet<String> = HashSet::new();
for id in test_ids {
if let Some(pos) = id.rfind('/') {
let path = &id[..pos];
if seen_set.insert(path.to_string()) {
seen.push(path.to_string());
}
}
}
let map: HashMap<String, usize> = seen
.iter()
.enumerate()
.map(|(i, p)| (p.clone(), i + 1))
.collect();
let mut legend = String::from("\n\nFiles:\n");
for (i, path) in seen.iter().enumerate() {
legend.push_str(&format!(" {}: {path}\n", i + 1));
}
(map, legend)
}
fn format_test_col(file_idx: &HashMap<String, usize>, test_id: &str) -> String {
if let Some(pos) = test_id.rfind('/') {
let path = &test_id[..pos];
let slug = &test_id[pos + 1..];
let n = file_idx.get(path).copied().unwrap_or(0);
format!("[{n}] {slug}")
} else {
test_id.to_string()
}
}
fn term_width() -> usize {
terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(80)
}
fn make_table(
mut builder: Builder,
truncated: Option<(usize, usize)>,
footer: Option<&[Vec<String>]>,
) -> String {
let cols = builder.count_columns();
let trunc_row = if let Some((shown, total)) = truncated {
let mut row: Vec<String> = vec![String::new(); cols];
row[0] = format!("~~~ showing {shown} of {total} results ~~~");
let row_idx = shown + 1;
builder.push_record(row);
Some(row_idx)
} else {
None
};
if let Some(rows) = footer {
for row in rows {
builder.push_record(row.clone());
}
}
let mut table = builder.build();
table
.with(Style::blank())
.with(Alignment::right())
.with(Modify::new(Columns::first()).with(Alignment::left()));
if let Some(row_idx) = trunc_row {
table
.modify((row_idx, 0), Span::column(cols as isize))
.modify((row_idx, 0), Alignment::center());
}
table.with(Width::increase(term_width()));
table.to_string()
}
fn fmt_dur(ms: f64) -> String {
format_duration(Duration::from_secs_f64(ms / 1000.0))
}
fn fmt_dur_u64(ms: u64) -> String {
format_duration(Duration::from_millis(ms))
}
pub fn format_flaky_human(
coll: &LoadedRunsCollection,
entries: &[(TestKey, FlakyRecord)],
) -> String {
let mut out = format!("Flakiness Report ({} runs)\n\n", coll.run_count());
if entries.is_empty() {
out.push_str("No flaky tests detected.\n");
return out;
}
let display_ids: Vec<String> = entries.iter().map(|(k, _)| k.to_string()).collect();
let display_refs: Vec<&str> = display_ids.iter().map(|s| s.as_str()).collect();
let (idx, legend) = build_file_index(&display_refs);
let mut builder = Builder::default();
builder.push_record(["Test", "Flips", "Pass", "Fail", "Rate", "Avg Retries"]);
for (key, rec) in entries {
let display_id = key.to_string();
builder.push_record([
format_test_col(&idx, &display_id),
rec.flips.to_string(),
rec.pass.to_string(),
rec.fail.to_string(),
format!("{:.1}%", rec.rate),
format!("{:.1}", rec.avg_retries),
]);
}
out.push_str(&make_table(builder, coll.truncation(), None));
out.push_str(&legend);
out
}
pub fn format_flaky_toml(
coll: &LoadedRunsCollection,
entries: &[(TestKey, FlakyRecord)],
) -> String {
#[derive(Serialize)]
struct FlakyReport {
meta: ReportMeta,
tests: Vec<FlakyEntryOut>,
}
#[derive(Serialize)]
struct FlakyEntryOut {
path: String,
flips: usize,
pass: usize,
fail: usize,
rate: f64,
avg_retries: f64,
}
let report = FlakyReport {
meta: ReportMeta {
runs: coll.run_count(),
},
tests: entries
.iter()
.map(|(k, r)| FlakyEntryOut {
path: k.to_string(),
flips: r.flips,
pass: r.pass,
fail: r.fail,
rate: r.rate,
avg_retries: r.avg_retries,
})
.collect(),
};
toml::to_string_pretty(&report).expect("failed to serialize flaky report")
}
pub fn format_failures_human(
coll: &LoadedRunsCollection,
entries: &[(TestKey, FailureRecord)],
modes: &[FailureModeEntry],
) -> String {
let mut out = format!("Failure Report ({} runs)\n\n", coll.run_count());
if entries.is_empty() {
out.push_str("No failures detected.\n");
return out;
}
let display_ids: Vec<String> = entries.iter().map(|(k, _)| k.to_string()).collect();
let display_refs: Vec<&str> = display_ids.iter().map(|s| s.as_str()).collect();
let (idx, legend) = build_file_index(&display_refs);
let mut builder = Builder::default();
builder.push_record(["Test", "Fails", "Runs", "Rate"]);
for (key, rec) in entries {
let display_id = key.to_string();
builder.push_record([
format_test_col(&idx, &display_id),
rec.fails.to_string(),
rec.runs.to_string(),
format!("{:.1}%", rec.rate),
]);
}
out.push_str(&make_table(builder, coll.truncation(), None));
if !modes.is_empty() {
out.push_str("\n\n");
let mut builder = Builder::default();
builder.push_record(["Type", "Count", "%"]);
for m in modes {
builder.push_record([
m.failure_type.clone(),
m.count.to_string(),
format!("{:.1}%", m.percentage),
]);
}
out.push_str(&make_table(builder, None, None));
}
out.push_str(&legend);
out.push_str(&format_latest_fail_block(coll, entries, &idx));
out
}
fn format_latest_fail_block(
coll: &LoadedRunsCollection,
entries: &[(TestKey, FailureRecord)],
idx: &HashMap<String, usize>,
) -> String {
let links: Vec<(usize, String)> = entries
.iter()
.filter_map(|(key, rec)| {
let run_id = rec.latest_fail_run_id.as_deref()?;
let uri = coll.event_html_uri(key, run_id)?;
let n = key
.to_string()
.rfind('/')
.and_then(|pos| idx.get(&key.to_string()[..pos]).copied())
.unwrap_or(0);
Some((n, uri))
})
.collect();
if links.is_empty() {
return String::new();
}
let mut out = String::from("\nLatest fail:\n");
for (n, uri) in links {
out.push_str(&format!(" [{n}] {uri}\n"));
}
out
}
pub fn format_failures_toml(
coll: &LoadedRunsCollection,
entries: &[(TestKey, FailureRecord)],
modes: &[FailureModeEntry],
) -> String {
#[derive(Serialize)]
struct FailureReport {
meta: ReportMeta,
tests: Vec<FailureEntryOut>,
modes: Vec<FailureModeOut>,
}
#[derive(Serialize)]
struct FailureEntryOut {
path: String,
fails: usize,
runs: usize,
rate: f64,
#[serde(skip_serializing_if = "Option::is_none")]
latest_fail: Option<String>,
}
#[derive(Serialize)]
struct FailureModeOut {
failure_type: String,
count: usize,
percentage: f64,
}
let report = FailureReport {
meta: ReportMeta {
runs: coll.run_count(),
},
tests: entries
.iter()
.map(|(k, r)| FailureEntryOut {
path: k.to_string(),
fails: r.fails,
runs: r.runs,
rate: r.rate,
latest_fail: r
.latest_fail_run_id
.as_deref()
.and_then(|rid| coll.event_html_uri(k, rid)),
})
.collect(),
modes: modes
.iter()
.map(|m| FailureModeOut {
failure_type: m.failure_type.clone(),
count: m.count,
percentage: m.percentage,
})
.collect(),
};
toml::to_string_pretty(&report).expect("failed to serialize failure report")
}
pub fn format_first_fail_human(
coll: &LoadedRunsCollection,
entries: &[(TestKey, FirstFailRecord)],
) -> String {
let mut out = format!("Latest Regressions ({} runs)\n\n", coll.run_count());
if entries.is_empty() {
out.push_str("No pass-to-fail transitions detected.\n");
return out;
}
for (i, (key, rec)) in entries.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&format!(" Test: {}\n", key));
out.push_str(&format!(" Time: {}\n", rec.timestamp));
out.push_str(&format!(" Type: {}\n", rec.failure_type));
out.push_str(&format!(" Summary: {}\n", rec.summary));
out.push_str(&format!(" Report: {}\n", rec.report));
}
out
}
pub fn format_first_fail_toml(
coll: &LoadedRunsCollection,
entries: &[(TestKey, FirstFailRecord)],
) -> String {
#[derive(Serialize)]
struct FirstFailReport {
meta: ReportMeta,
tests: Vec<FirstFailEntryOut>,
}
#[derive(Serialize)]
struct FirstFailEntryOut {
path: String,
report: String,
timestamp: String,
failure_type: String,
summary: String,
}
let report = FirstFailReport {
meta: ReportMeta {
runs: coll.run_count(),
},
tests: entries
.iter()
.map(|(k, r)| FirstFailEntryOut {
path: k.to_string(),
report: r.report.clone(),
timestamp: r.timestamp.clone(),
failure_type: r.failure_type.clone(),
summary: r.summary.clone(),
})
.collect(),
};
toml::to_string_pretty(&report).expect("failed to serialize first-fail report")
}
pub fn format_durations_human(
coll: &LoadedRunsCollection,
entries: &[(TestKey, DurationRecord)],
aggregate: &DurationStats,
) -> String {
let mut out = format!("Duration Analysis ({} runs)\n\n", coll.run_count());
if entries.is_empty() {
out.push_str("No duration data available.\n");
return out;
}
let display_ids: Vec<String> = entries.iter().map(|(k, _)| k.to_string()).collect();
let display_refs: Vec<&str> = display_ids.iter().map(|s| s.as_str()).collect();
let (idx, legend) = build_file_index(&display_refs);
let mut builder = Builder::default();
builder.push_record(["Test", "Mean", "StdDev", "Min", "Max", "Trend"]);
for (key, rec) in entries {
let display_id = key.to_string();
builder.push_record([
format_test_col(&idx, &display_id),
fmt_dur(rec.mean_ms),
fmt_dur(rec.stddev_ms),
fmt_dur_u64(rec.min_ms),
fmt_dur_u64(rec.max_ms),
rec.trend.clone(),
]);
}
let footer = vec![
vec![String::new(); 6],
vec![
"Aggregate".to_string(),
fmt_dur(aggregate.mean_ms),
fmt_dur(aggregate.stddev_ms),
fmt_dur_u64(aggregate.min_ms),
fmt_dur_u64(aggregate.max_ms),
aggregate.trend.clone(),
],
];
out.push_str(&make_table(builder, coll.truncation(), Some(&footer)));
out.push_str(&legend);
out
}
pub fn format_durations_toml(
coll: &LoadedRunsCollection,
entries: &[(TestKey, DurationRecord)],
aggregate: &DurationStats,
) -> String {
#[derive(Serialize)]
struct DurationReport {
meta: ReportMeta,
tests: Vec<DurationEntryOut>,
aggregate: DurationStatsOut,
}
#[derive(Serialize)]
struct DurationEntryOut {
path: String,
mean_ms: f64,
stddev_ms: f64,
min_ms: u64,
max_ms: u64,
trend: String,
}
#[derive(Serialize)]
struct DurationStatsOut {
mean_ms: f64,
stddev_ms: f64,
min_ms: u64,
max_ms: u64,
trend: String,
}
let report = DurationReport {
meta: ReportMeta {
runs: coll.run_count(),
},
tests: entries
.iter()
.map(|(k, r)| DurationEntryOut {
path: k.to_string(),
mean_ms: r.mean_ms,
stddev_ms: r.stddev_ms,
min_ms: r.min_ms,
max_ms: r.max_ms,
trend: r.trend.clone(),
})
.collect(),
aggregate: DurationStatsOut {
mean_ms: aggregate.mean_ms,
stddev_ms: aggregate.stddev_ms,
min_ms: aggregate.min_ms,
max_ms: aggregate.max_ms,
trend: aggregate.trend.clone(),
},
};
toml::to_string_pretty(&report).expect("failed to serialize duration report")
}
fn outcome_counts(run: &LoadedRun) -> [(usize, &'static str); 4] {
let mut pass = 0;
let mut fail = 0;
let mut skip = 0;
let mut cancel = 0;
for t in &run.summary.tests {
match t.outcome.as_str() {
"pass" => pass += 1,
"fail" => fail += 1,
"skipped" => skip += 1,
"cancelled" => cancel += 1,
_ => {}
}
}
[
(pass, "pass"),
(fail, "fail"),
(skip, "skip"),
(cancel, "cancel"),
]
}
fn render_outcome_summary(run: &LoadedRun) -> String {
outcome_counts(run)
.iter()
.filter(|(n, _)| *n > 0)
.map(|(n, label)| format!("{n} {label}"))
.collect::<Vec<_>>()
.join(", ")
}
fn render_local_time(rfc3339: &str) -> String {
DateTime::parse_from_rfc3339(rfc3339)
.map(|dt| {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_else(|_| rfc3339.to_string())
}
fn run_index_uri(run: &LoadedRun) -> String {
format!("file://{}", run.dir.join("index.html").display())
}
pub fn format_run_index_human(runs: &[LoadedRun]) -> String {
let mut out = format!("Recent Runs ({} runs)\n\n", runs.len());
if runs.is_empty() {
out.push_str("No runs found.\n");
return out;
}
let mut iter = runs.iter().rev().peekable();
while let Some(run) = iter.next() {
let when = render_local_time(&run.summary.run.timestamp);
let summary = render_outcome_summary(run);
let link = run_index_uri(run);
out.push_str(&format!(" {when} {summary}\n {link}\n"));
if iter.peek().is_some() {
out.push('\n');
}
}
out
}
pub fn format_run_index_toml(runs: &[LoadedRun]) -> String {
#[derive(Serialize)]
struct RunIndexReport {
meta: ReportMeta,
runs: Vec<RunEntryOut>,
}
#[derive(Serialize)]
struct RunEntryOut {
run_id: String,
started: String,
duration_ms: u64,
report: String,
#[serde(skip_serializing_if = "Option::is_none")]
pass: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
fail: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
skip: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
cancel: Option<usize>,
}
let report = RunIndexReport {
meta: ReportMeta { runs: runs.len() },
runs: runs
.iter()
.rev()
.map(|run| {
let counts = outcome_counts(run);
let pass = (counts[0].0 > 0).then_some(counts[0].0);
let fail = (counts[1].0 > 0).then_some(counts[1].0);
let skip = (counts[2].0 > 0).then_some(counts[2].0);
let cancel = (counts[3].0 > 0).then_some(counts[3].0);
RunEntryOut {
run_id: run.summary.run.run_id.clone(),
started: render_local_time(&run.summary.run.timestamp),
duration_ms: run.summary.run.duration_ms,
report: run_index_uri(run),
pass,
fail,
skip,
cancel,
}
})
.collect(),
};
toml::to_string_pretty(&report).expect("failed to serialize run index report")
}
#[cfg(test)]
mod tests {
use super::super::analysis::DurationAggregate;
use super::super::analysis::DurationPreaggregate;
use super::super::analysis::FailurePreaggregate;
use super::super::analysis::FlakyPreaggregate;
use super::super::analysis::LoadedRunsCollection;
use super::super::analysis::compute_failure_modes;
use super::super::analysis::tests::sample_runs;
use super::*;
#[test]
fn format_flaky_human_has_legend() {
let runs = sample_runs();
let mut coll = LoadedRunsCollection::new(runs);
let entries = coll.truncate::<FlakyPreaggregate>(None);
let output = format_flaky_human(&coll, &entries);
assert!(output.contains("Flakiness Report (4 runs)"));
assert!(output.contains("[1]") || output.contains("[2]") || output.contains("[3]"));
assert!(output.contains("Files:"));
assert!(output.contains("a.relux"));
}
#[test]
fn format_durations_human_has_aggregate() {
let runs = sample_runs();
let mut coll = LoadedRunsCollection::new(runs);
let entries = coll.truncate::<DurationPreaggregate>(None);
let aggregate = coll.aggregate::<DurationAggregate>();
let output = format_durations_human(&coll, &entries, &aggregate);
assert!(output.contains("Aggregate"));
assert!(output.contains("Duration Analysis (4 runs)"));
assert!(output.contains("Files:"));
}
#[test]
fn build_file_index_assigns_sequential_numbers() {
let ids = vec![
"foo/bar.relux/test-one",
"foo/bar.relux/test-two",
"baz.relux/test-three",
];
let (idx, legend) = build_file_index(&ids);
assert_eq!(idx["foo/bar.relux"], 1);
assert_eq!(idx["baz.relux"], 2);
assert!(legend.contains("1: foo/bar.relux"));
assert!(legend.contains("2: baz.relux"));
}
#[test]
fn format_test_col_produces_bracket_format() {
let mut idx = HashMap::new();
idx.insert("foo.relux".to_string(), 3);
assert_eq!(format_test_col(&idx, "foo.relux/my-test"), "[3] my-test");
}
#[test]
fn format_failures_human_emits_latest_fail_footer() {
let runs = sample_runs();
let mut coll = LoadedRunsCollection::new(runs);
let modes = compute_failure_modes(&coll);
let entries = coll.truncate::<FailurePreaggregate>(None);
let output = format_failures_human(&coll, &entries, &modes);
assert!(output.contains("Latest fail:"));
assert!(output.contains("file:///tmp/out/run"));
assert!(output.contains("/event.html"));
assert!(output.contains("[1] file://") || output.contains("[2] file://"));
}
#[test]
fn format_failures_toml_includes_latest_fail_field() {
let runs = sample_runs();
let mut coll = LoadedRunsCollection::new(runs);
let modes = compute_failure_modes(&coll);
let entries = coll.truncate::<FailurePreaggregate>(None);
let output = format_failures_toml(&coll, &entries, &modes);
assert!(output.contains("latest_fail = \"file:///tmp/out/run"));
}
#[test]
fn format_run_index_human_shows_runs_newest_first_omitting_zero_counts() {
let runs = sample_runs();
let output = format_run_index_human(&runs);
assert!(output.contains("Recent Runs (4 runs)"));
assert!(output.contains("pass"));
assert!(output.contains("fail"));
assert!(!output.contains("skip"));
assert!(!output.contains("cancel"));
let pos4 = output.find("run4").expect("run4 in output");
let pos1 = output.find("run1").expect("run1 in output");
assert!(pos4 < pos1);
assert!(output.contains("file:///tmp/out/run4/index.html"));
assert!(output.contains("file:///tmp/out/run1/index.html"));
}
#[test]
fn format_run_index_toml_omits_empty_categories() {
let runs = sample_runs();
let output = format_run_index_toml(&runs);
assert!(output.contains("pass = "));
assert!(output.contains("fail = "));
assert!(!output.contains("skip = "));
assert!(!output.contains("cancel = "));
assert!(output.contains("report = \"file:///tmp/out/run"));
}
}