#![forbid(unsafe_code)]
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, SecondsFormat, Utc};
use clap::{Parser, ValueEnum};
use pi::conformance::report::{
ConformanceRegression, ConformanceReport, ExtensionConformanceResult, compute_regression,
generate_report,
};
use serde::Serialize;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "kebab-case")]
enum RegressionMode {
Ignore,
Warn,
Fail,
}
#[derive(Debug, Parser)]
#[command(name = "ext_conformance_report")]
#[command(about = "Generate extension conformance report JSON + Markdown")]
struct Args {
#[arg(long)]
input: PathBuf,
#[arg(long, default_value = "tests/ext_conformance/reports")]
out_dir: PathBuf,
#[arg(long)]
run_id: Option<String>,
#[arg(long)]
timestamp: Option<String>,
#[arg(long, value_enum, default_value = "warn")]
regression_mode: RegressionMode,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ConformanceTrendPoint {
run_id: String,
timestamp: String,
total: u64,
passed: u64,
failed: u64,
skipped: u64,
errors: u64,
pass_rate: f64,
archive: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ConformanceTrend {
schema: &'static str,
generated_at: String,
runs: Vec<ConformanceTrendPoint>,
#[serde(skip_serializing_if = "Option::is_none")]
latest_regression: Option<ConformanceRegression>,
}
fn parse_report_timestamp(report: &ConformanceReport) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(&report.timestamp)
.ok()
.map(|ts| ts.with_timezone(&Utc))
}
fn report_sort_key(report: &ConformanceReport) -> (i64, &str) {
let timestamp = parse_report_timestamp(report).map_or(0, |ts| ts.timestamp());
(timestamp, report.run_id.as_str())
}
fn archive_path_for_report(out_dir: &Path, report: &ConformanceReport) -> PathBuf {
let ts = parse_report_timestamp(report);
let date = ts.map_or_else(
|| "unknown-date".to_string(),
|ts| ts.format("%Y-%m-%d").to_string(),
);
let time = ts.map_or_else(
|| "unknown-time".to_string(),
|ts| ts.format("%H%M%SZ").to_string(),
);
let date_only = out_dir.join(format!("conformance_{date}.json"));
if !date_only.exists() {
return date_only;
}
out_dir.join(format!("conformance_{date}_{time}_{}.json", report.run_id))
}
fn is_archive_filename(name: &str) -> bool {
if !name.starts_with("conformance_")
|| !Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
{
return false;
}
if name == "conformance_report.json" || name == "conformance_trend.json" {
return false;
}
true
}
fn read_archives(out_dir: &Path) -> Result<Vec<(PathBuf, ConformanceReport)>> {
let mut reports = Vec::new();
if !out_dir.exists() {
return Ok(reports);
}
for entry in std::fs::read_dir(out_dir)
.with_context(|| format!("list conformance report directory: {}", out_dir.display()))?
{
let entry = entry.context("read_dir entry")?;
let path = entry.path();
if path.extension() != Some(OsStr::new("json")) {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !is_archive_filename(name) {
continue;
}
let bytes = std::fs::read(&path)
.with_context(|| format!("read archived report: {}", path.display()))?;
let report: ConformanceReport =
serde_json::from_slice(&bytes).context("parse archived report JSON")?;
reports.push((path, report));
}
reports.sort_by(|(_, left), (_, right)| report_sort_key(left).cmp(&report_sort_key(right)));
Ok(reports)
}
fn build_trend(
archives: &[(PathBuf, ConformanceReport)],
latest_regression: Option<ConformanceRegression>,
) -> ConformanceTrend {
let runs = archives
.iter()
.map(|(path, report)| ConformanceTrendPoint {
run_id: report.run_id.clone(),
timestamp: report.timestamp.clone(),
total: report.summary.total,
passed: report.summary.passed,
failed: report.summary.failed,
skipped: report.summary.skipped,
errors: report.summary.errors,
pass_rate: report.summary.pass_rate,
archive: path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string(),
})
.collect::<Vec<_>>();
ConformanceTrend {
schema: "pi.ext.conformance_trend.v1",
generated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
runs,
latest_regression,
}
}
fn print_regression(regression: &ConformanceRegression) {
eprintln!(
"Conformance regression: pass rate {:.2}% → {:.2}% (Δ {:.2}%) on {} prior extensions",
regression.previous_pass_rate * 100.0,
regression.current_pass_rate * 100.0,
regression.pass_rate_delta * 100.0,
regression.compared_total
);
for entry in ®ression.regressed_extensions {
let current = entry
.current
.map_or_else(|| "missing".to_string(), |s| s.as_upper_str().to_string());
eprintln!(" - {}: PASS → {}", entry.id, current);
eprintln!(
"::error title=Conformance regression::{} regressed (PASS → {})",
entry.id, current
);
}
}
fn main() -> Result<()> {
let args = Args::parse();
let bytes = std::fs::read(&args.input)
.with_context(|| format!("read input JSON: {}", args.input.display()))?;
let results: Vec<ExtensionConformanceResult> =
serde_json::from_slice(&bytes).context("parse input JSON")?;
let run_id = args
.run_id
.unwrap_or_else(|| format!("run-{}", Uuid::new_v4()));
let report = generate_report(run_id, args.timestamp, results);
std::fs::create_dir_all(&args.out_dir)
.with_context(|| format!("create output dir: {}", args.out_dir.display()))?;
let json_path = args.out_dir.join("conformance_report.json");
let md_path = args.out_dir.join("conformance_report.md");
let json = serde_json::to_string_pretty(&report).context("serialize report JSON")?;
std::fs::write(&json_path, json.as_bytes())
.with_context(|| format!("write {}", json_path.display()))?;
let md = report.render_markdown();
std::fs::write(&md_path, md.as_bytes())
.with_context(|| format!("write {}", md_path.display()))?;
let archive_path = archive_path_for_report(&args.out_dir, &report);
std::fs::write(&archive_path, json.as_bytes())
.with_context(|| format!("write archive {}", archive_path.display()))?;
let mut archives = read_archives(&args.out_dir)?;
if !archives.iter().any(|(path, _)| path == &archive_path) {
archives.push((archive_path.clone(), report.clone()));
}
archives.sort_by(|(_, left), (_, right)| report_sort_key(left).cmp(&report_sort_key(right)));
let latest_regression = if archives.len() >= 2 {
let (_, previous) = &archives[archives.len() - 2];
let regression = compute_regression(previous, &report);
if regression.has_regression() {
Some(regression)
} else {
None
}
} else {
None
};
let trend = build_trend(&archives, latest_regression.clone());
let trend_path = args.out_dir.join("conformance_trend.json");
let trend_json = serde_json::to_string_pretty(&trend).context("serialize trend JSON")?;
std::fs::write(&trend_path, trend_json.as_bytes())
.with_context(|| format!("write {}", trend_path.display()))?;
println!("Wrote: {}", json_path.display());
println!("Wrote: {}", md_path.display());
println!("Archived: {}", archive_path.display());
println!("Wrote: {}", trend_path.display());
if let Some(regression) = latest_regression {
print_regression(®ression);
if args.regression_mode == RegressionMode::Fail {
anyhow::bail!("conformance regression detected");
}
}
Ok(())
}