use crate::compare::Diff;
use crate::runner::ConformanceResult;
use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportSummary {
pub total: usize,
pub passed: usize,
pub failed: usize,
pub errored: usize,
pub pass_rate: f64,
pub total_duration_ms: u64,
}
impl ReportSummary {
pub fn meets_target(&self) -> bool {
self.pass_rate >= 0.95
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReportFormat {
Markdown,
Json,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConformanceReport {
pub generated_at: String,
pub summary: ReportSummary,
pub results: Vec<ConformanceResultSerde>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConformanceResultSerde {
pub fixture_name: String,
pub passed: bool,
pub compare_passed: bool,
pub expect_fail: bool,
pub diffs: Vec<Diff>,
pub duration_ms: u64,
pub error: Option<String>,
pub actual_count: usize,
pub expected_count: usize,
}
impl From<&ConformanceResult> for ConformanceResultSerde {
fn from(r: &ConformanceResult) -> Self {
Self {
fixture_name: r.fixture_name.clone(),
passed: r.is_pass_expected(),
compare_passed: r.is_pass(),
expect_fail: r.expect_fail,
diffs: r.compare.diffs.clone(),
duration_ms: r.duration_ms,
error: r.error.clone(),
actual_count: r.compare.actual_count,
expected_count: r.compare.expected_count,
}
}
}
pub struct ReportWriter;
impl ReportWriter {
pub fn build(results: &[ConformanceResult], summary: ReportSummary) -> ConformanceReport {
ConformanceReport {
generated_at: chrono::Utc::now().to_rfc3339(),
summary,
results: results.iter().map(ConformanceResultSerde::from).collect(),
}
}
pub fn write_markdown(
report: &ConformanceReport,
path: impl AsRef<Path>,
) -> Result<(), ReportError> {
let mut out = String::new();
out.push_str(&Self::render_markdown(report));
std::fs::write(path, out)?;
Ok(())
}
pub fn write_json(
report: &ConformanceReport,
path: impl AsRef<Path>,
) -> Result<(), ReportError> {
let json = serde_json::to_string_pretty(report)?;
std::fs::write(path, json)?;
Ok(())
}
pub fn render_markdown(report: &ConformanceReport) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "# Conformance Report — {}\n", report.generated_at);
let _ = writeln!(
out,
"**Pass rate**: {} / {} = {:.1}% {}\n",
report.summary.passed,
report.summary.total,
report.summary.pass_rate * 100.0,
if report.summary.meets_target() {
"✅ (target ≥ 95%)"
} else {
"❌ (target ≥ 95%)"
}
);
let _ = writeln!(
out,
"**Duration**: {} ms\n",
report.summary.total_duration_ms
);
let _ = writeln!(
out,
"- Total: {}\n- Passed: {}\n- Failed: {}\n- Errored: {}\n",
report.summary.total,
report.summary.passed,
report.summary.failed,
report.summary.errored
);
let failed: Vec<_> = report.results.iter().filter(|r| !r.passed).collect();
if !failed.is_empty() {
let _ = writeln!(out, "## Failed fixtures ({})\n", failed.len());
for r in &failed {
let _ = writeln!(out, "### {}\n", r.fixture_name);
if r.expect_fail && !r.compare_passed {
let _ = writeln!(out, "- (expected-to-fail fixture, see Passed section)\n");
continue;
}
if let Some(err) = &r.error {
let _ = writeln!(out, "- **Runner error**: {}\n", err);
}
if r.diffs.is_empty() {
let _ = writeln!(out, "- (no diffs, error in runner)\n");
} else {
let _ = writeln!(out, "**Diffs** ({}):\n", r.diffs.len());
for d in &r.diffs {
let _ = writeln!(out, "- {}\n", d.summary());
}
}
}
}
let passed: Vec<_> = report.results.iter().filter(|r| r.passed).collect();
if !passed.is_empty() {
let _ = writeln!(out, "\n## Passed fixtures ({})\n", passed.len());
for r in &passed {
let marker = if r.expect_fail {
"✅ (expected-to-fail, comparer 抓到 diff) "
} else {
"✅ "
};
let _ = writeln!(
out,
"- {}{} ({} events, {} ms)\n",
marker, r.fixture_name, r.actual_count, r.duration_ms
);
}
}
out
}
}
#[derive(Debug, Error)]
pub enum ReportError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compare::CompareResult;
fn make_result(name: &str, passed: bool, diffs: Vec<Diff>) -> ConformanceResult {
ConformanceResult {
fixture_name: name.to_string(),
compare: if passed {
CompareResult::ok(2, 2)
} else {
CompareResult::failed(diffs, 2, 2)
},
duration_ms: 10,
error: None,
actual_events: vec![],
expect_fail: false,
}
}
#[test]
fn summary_meets_target_at_95_percent() {
let s = ReportSummary {
total: 20,
passed: 19,
failed: 1,
errored: 0,
pass_rate: 0.95,
total_duration_ms: 100,
};
assert!(s.meets_target());
}
#[test]
fn summary_fails_below_95_percent() {
let s = ReportSummary {
total: 20,
passed: 18,
failed: 2,
errored: 0,
pass_rate: 0.90,
total_duration_ms: 100,
};
assert!(!s.meets_target());
}
#[test]
fn summary_handles_zero_total() {
let s = ReportSummary {
total: 0,
passed: 0,
failed: 0,
errored: 0,
pass_rate: 1.0,
total_duration_ms: 0,
};
assert!(s.meets_target()); }
#[test]
fn report_writer_builds_from_results() {
let results = vec![
make_result("a", true, vec![]),
make_result(
"b",
false,
vec![Diff::TypeMismatch {
index: 0,
expected_type: "X".to_string(),
actual_type: "Y".to_string(),
}],
),
];
let summary = ReportSummary {
total: 2,
passed: 1,
failed: 1,
errored: 0,
pass_rate: 0.5,
total_duration_ms: 20,
};
let report = ReportWriter::build(&results, summary);
assert_eq!(report.results.len(), 2);
assert!(!report.summary.meets_target());
}
#[test]
fn report_renders_markdown() {
let results = vec![
make_result("pass_one", true, vec![]),
make_result(
"fail_one",
false,
vec![Diff::MissingField {
index: 1,
key: "result".to_string(),
}],
),
];
let summary = ReportSummary {
total: 2,
passed: 1,
failed: 1,
errored: 0,
pass_rate: 0.5,
total_duration_ms: 50,
};
let report = ReportWriter::build(&results, summary);
let md = ReportWriter::render_markdown(&report);
assert!(md.contains("# Conformance Report"));
assert!(md.contains("pass_one"));
assert!(md.contains("fail_one"));
assert!(md.contains("missing field"));
assert!(md.contains("result"));
}
}