#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(missing_docs)]
#![warn(rust_2018_idioms)]
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[cfg(feature = "terminal")]
#[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
pub mod terminal;
#[cfg(feature = "markdown")]
#[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
pub mod markdown;
mod diff;
pub use diff::{Diff, DiffOptions, DurationRegression, SeverityChange};
mod multi;
pub use multi::MultiReport;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
Pass,
Fail,
Warn,
Skip,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Warning,
Error,
Critical,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileRef {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_start: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_end: Option<u32>,
}
impl FileRef {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
line_start: None,
line_end: None,
}
}
pub fn with_line_range(mut self, start: u32, end: u32) -> Self {
self.line_start = Some(start);
self.line_end = Some(end);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EvidenceKind {
Numeric,
KeyValue,
Snippet,
FileRef,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceData {
Numeric(f64),
KeyValue(BTreeMap<String, String>),
Snippet(String),
FileRef(FileRef),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Evidence {
pub label: String,
pub data: EvidenceData,
}
impl Evidence {
pub fn numeric(label: impl Into<String>, value: f64) -> Self {
Self {
label: label.into(),
data: EvidenceData::Numeric(value),
}
}
pub fn kv<I, K, V>(label: impl Into<String>, pairs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let map: BTreeMap<String, String> = pairs
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect();
Self {
label: label.into(),
data: EvidenceData::KeyValue(map),
}
}
pub fn snippet(label: impl Into<String>, text: impl Into<String>) -> Self {
Self {
label: label.into(),
data: EvidenceData::Snippet(text.into()),
}
}
pub fn file_ref(label: impl Into<String>, path: impl Into<String>) -> Self {
Self {
label: label.into(),
data: EvidenceData::FileRef(FileRef::new(path)),
}
}
pub fn file_ref_lines(
label: impl Into<String>,
path: impl Into<String>,
start: u32,
end: u32,
) -> Self {
Self {
label: label.into(),
data: EvidenceData::FileRef(FileRef::new(path).with_line_range(start, end)),
}
}
pub fn kind(&self) -> EvidenceKind {
match &self.data {
EvidenceData::Numeric(_) => EvidenceKind::Numeric,
EvidenceData::KeyValue(_) => EvidenceKind::KeyValue,
EvidenceData::Snippet(_) => EvidenceKind::Snippet,
EvidenceData::FileRef(_) => EvidenceKind::FileRef,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckResult {
pub name: String,
pub verdict: Verdict,
pub severity: Option<Severity>,
pub detail: Option<String>,
pub at: DateTime<Utc>,
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<Evidence>,
}
impl CheckResult {
pub fn pass(name: impl Into<String>) -> Self {
Self {
name: name.into(),
verdict: Verdict::Pass,
severity: None,
detail: None,
at: Utc::now(),
duration_ms: None,
tags: Vec::new(),
evidence: Vec::new(),
}
}
pub fn fail(name: impl Into<String>, severity: Severity) -> Self {
Self {
name: name.into(),
verdict: Verdict::Fail,
severity: Some(severity),
detail: None,
at: Utc::now(),
duration_ms: None,
tags: Vec::new(),
evidence: Vec::new(),
}
}
pub fn warn(name: impl Into<String>, severity: Severity) -> Self {
Self {
name: name.into(),
verdict: Verdict::Warn,
severity: Some(severity),
detail: None,
at: Utc::now(),
duration_ms: None,
tags: Vec::new(),
evidence: Vec::new(),
}
}
pub fn skip(name: impl Into<String>) -> Self {
Self {
name: name.into(),
verdict: Verdict::Skip,
severity: None,
detail: None,
at: Utc::now(),
duration_ms: None,
tags: Vec::new(),
evidence: Vec::new(),
}
}
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn with_duration_ms(mut self, ms: u64) -> Self {
self.duration_ms = Some(ms);
self
}
pub fn with_severity(mut self, severity: Severity) -> Self {
self.severity = Some(severity);
self
}
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
pub fn with_tags<I, S>(mut self, tags: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.tags.extend(tags.into_iter().map(Into::into));
self
}
pub fn has_tag(&self, tag: &str) -> bool {
self.tags.iter().any(|t| t == tag)
}
pub fn with_evidence(mut self, e: Evidence) -> Self {
self.evidence.push(e);
self
}
pub fn with_evidences<I>(mut self, items: I) -> Self
where
I: IntoIterator<Item = Evidence>,
{
self.evidence.extend(items);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Report {
pub schema_version: u32,
pub subject: String,
pub subject_version: String,
pub producer: Option<String>,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub checks: Vec<CheckResult>,
}
impl Report {
pub fn new(subject: impl Into<String>, subject_version: impl Into<String>) -> Self {
Self {
schema_version: 1,
subject: subject.into(),
subject_version: subject_version.into(),
producer: None,
started_at: Utc::now(),
finished_at: None,
checks: Vec::new(),
}
}
pub fn with_producer(mut self, producer: impl Into<String>) -> Self {
self.producer = Some(producer.into());
self
}
pub fn push(&mut self, result: CheckResult) {
self.checks.push(result);
}
pub fn finish(&mut self) {
self.finished_at = Some(Utc::now());
}
pub fn overall_verdict(&self) -> Verdict {
let mut saw_fail = false;
let mut saw_warn = false;
let mut saw_pass = false;
for c in &self.checks {
match c.verdict {
Verdict::Fail => saw_fail = true,
Verdict::Warn => saw_warn = true,
Verdict::Pass => saw_pass = true,
Verdict::Skip => {}
}
}
if saw_fail {
Verdict::Fail
} else if saw_warn {
Verdict::Warn
} else if saw_pass {
Verdict::Pass
} else {
Verdict::Skip
}
}
pub fn checks_with_tag<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a CheckResult> {
self.checks.iter().filter(move |c| c.has_tag(tag))
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
pub fn from_json(s: &str) -> serde_json::Result<Self> {
serde_json::from_str(s)
}
#[cfg(feature = "terminal")]
#[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
pub fn to_terminal(&self) -> String {
terminal::to_terminal(self)
}
#[cfg(feature = "terminal")]
#[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
pub fn to_terminal_color(&self) -> String {
terminal::to_terminal_color(self)
}
#[cfg(feature = "markdown")]
#[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
pub fn to_markdown(&self) -> String {
markdown::to_markdown(self)
}
pub fn diff(&self, baseline: &Self) -> Diff {
diff::diff_reports(self, baseline, &DiffOptions::default())
}
pub fn diff_with(&self, baseline: &Self, opts: &DiffOptions) -> Diff {
diff::diff_reports(self, baseline, opts)
}
}
pub trait Producer {
fn produce(&self) -> Report;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_and_roundtrip_a_report() {
let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-self-test");
r.push(CheckResult::pass("compile"));
r.push(CheckResult::fail("unit::math", Severity::Error).with_detail("off by one"));
r.finish();
let json = r.to_json().unwrap();
let parsed = Report::from_json(&json).unwrap();
assert_eq!(parsed.subject, "widget");
assert_eq!(parsed.checks.len(), 2);
assert_eq!(parsed.overall_verdict(), Verdict::Fail);
}
#[test]
fn empty_report_is_skip() {
let r = Report::new("nothing", "0.0.0");
assert_eq!(r.overall_verdict(), Verdict::Skip);
}
#[test]
fn tags_attach_and_query() {
let c = CheckResult::pass("compile")
.with_tag("slow")
.with_tags(["flaky", "bench"]);
assert!(c.has_tag("slow"));
assert!(c.has_tag("flaky"));
assert!(c.has_tag("bench"));
assert!(!c.has_tag("missing"));
assert_eq!(c.tags.len(), 3);
}
#[test]
fn evidence_constructors_set_kind() {
assert_eq!(Evidence::numeric("x", 1.0).kind(), EvidenceKind::Numeric);
assert_eq!(
Evidence::kv("env", [("K", "V")]).kind(),
EvidenceKind::KeyValue
);
assert_eq!(
Evidence::snippet("log", "boom").kind(),
EvidenceKind::Snippet
);
assert_eq!(
Evidence::file_ref("src", "lib.rs").kind(),
EvidenceKind::FileRef
);
assert_eq!(
Evidence::file_ref_lines("src", "lib.rs", 1, 2).kind(),
EvidenceKind::FileRef
);
}
#[test]
fn evidence_round_trips_through_json() {
let mut r = Report::new("subject", "0.2.0");
r.push(
CheckResult::pass("bench::parse")
.with_tag("bench")
.with_evidence(Evidence::numeric("mean_ns", 1234.5))
.with_evidence(Evidence::kv("env", [("RUST_LOG", "debug"), ("CI", "true")]))
.with_evidence(Evidence::snippet("note", "fast path taken"))
.with_evidence(Evidence::file_ref_lines("site", "src/parse.rs", 10, 20)),
);
r.finish();
let json = r.to_json().unwrap();
let parsed = Report::from_json(&json).unwrap();
assert_eq!(parsed.checks.len(), 1);
let c = &parsed.checks[0];
assert_eq!(c.tags, vec!["bench".to_string()]);
assert_eq!(c.evidence.len(), 4);
assert_eq!(c.evidence[0].kind(), EvidenceKind::Numeric);
assert_eq!(c.evidence[1].kind(), EvidenceKind::KeyValue);
assert_eq!(c.evidence[2].kind(), EvidenceKind::Snippet);
assert_eq!(c.evidence[3].kind(), EvidenceKind::FileRef);
}
#[test]
fn v0_1_0_json_deserializes_with_empty_tags_and_evidence() {
let v0_1_0_json = r#"{
"schema_version": 1,
"subject": "legacy",
"subject_version": "0.1.0",
"producer": "dev-report-self-test",
"started_at": "2026-01-01T00:00:00Z",
"finished_at": "2026-01-01T00:00:01Z",
"checks": [
{
"name": "compile",
"verdict": "pass",
"severity": null,
"detail": null,
"at": "2026-01-01T00:00:00Z",
"duration_ms": null
}
]
}"#;
let parsed = Report::from_json(v0_1_0_json).unwrap();
assert_eq!(parsed.checks.len(), 1);
let c = &parsed.checks[0];
assert!(c.tags.is_empty());
assert!(c.evidence.is_empty());
assert_eq!(parsed.overall_verdict(), Verdict::Pass);
}
#[test]
fn checks_with_tag_filters() {
let mut r = Report::new("subject", "0.2.0");
r.push(CheckResult::pass("a").with_tag("slow"));
r.push(CheckResult::pass("b"));
r.push(CheckResult::pass("c").with_tags(["slow", "flaky"]));
let slow: Vec<&CheckResult> = r.checks_with_tag("slow").collect();
assert_eq!(slow.len(), 2);
assert_eq!(slow[0].name, "a");
assert_eq!(slow[1].name, "c");
}
#[test]
fn with_severity_overrides_severity() {
let c = CheckResult::warn("x", Severity::Warning).with_severity(Severity::Error);
assert_eq!(c.severity, Some(Severity::Error));
}
#[test]
fn empty_tags_and_evidence_are_omitted_in_json() {
let mut r = Report::new("s", "0.2.0");
r.push(CheckResult::pass("a"));
let json = r.to_json().unwrap();
assert!(!json.contains("\"tags\""));
assert!(!json.contains("\"evidence\""));
}
fn r_with(checks: &[Verdict]) -> Report {
let mut r = Report::new("vp", "0.0.0");
for v in checks {
r.push(match v {
Verdict::Pass => CheckResult::pass("c"),
Verdict::Fail => CheckResult::fail("c", Severity::Error),
Verdict::Warn => CheckResult::warn("c", Severity::Warning),
Verdict::Skip => CheckResult::skip("c"),
});
}
r
}
#[test]
fn vp_empty_is_skip() {
assert_eq!(r_with(&[]).overall_verdict(), Verdict::Skip);
}
#[test]
fn vp_only_skip_is_skip() {
assert_eq!(
r_with(&[Verdict::Skip, Verdict::Skip]).overall_verdict(),
Verdict::Skip
);
}
#[test]
fn vp_only_pass_is_pass() {
assert_eq!(r_with(&[Verdict::Pass]).overall_verdict(), Verdict::Pass);
}
#[test]
fn vp_pass_with_skip_is_pass() {
assert_eq!(
r_with(&[Verdict::Skip, Verdict::Pass, Verdict::Skip]).overall_verdict(),
Verdict::Pass
);
}
#[test]
fn vp_only_warn_is_warn() {
assert_eq!(r_with(&[Verdict::Warn]).overall_verdict(), Verdict::Warn);
}
#[test]
fn vp_warn_with_pass_is_warn() {
assert_eq!(
r_with(&[Verdict::Pass, Verdict::Warn]).overall_verdict(),
Verdict::Warn
);
}
#[test]
fn vp_warn_with_skip_is_warn() {
assert_eq!(
r_with(&[Verdict::Skip, Verdict::Warn]).overall_verdict(),
Verdict::Warn
);
}
#[test]
fn vp_warn_with_pass_and_skip_is_warn() {
assert_eq!(
r_with(&[Verdict::Pass, Verdict::Skip, Verdict::Warn]).overall_verdict(),
Verdict::Warn
);
}
#[test]
fn vp_only_fail_is_fail() {
assert_eq!(r_with(&[Verdict::Fail]).overall_verdict(), Verdict::Fail);
}
#[test]
fn vp_fail_with_pass_is_fail() {
assert_eq!(
r_with(&[Verdict::Pass, Verdict::Fail]).overall_verdict(),
Verdict::Fail
);
}
#[test]
fn vp_fail_with_warn_is_fail() {
assert_eq!(
r_with(&[Verdict::Warn, Verdict::Fail]).overall_verdict(),
Verdict::Fail
);
}
#[test]
fn vp_fail_with_skip_is_fail() {
assert_eq!(
r_with(&[Verdict::Skip, Verdict::Fail]).overall_verdict(),
Verdict::Fail
);
}
#[test]
fn vp_fail_dominates_all_others() {
assert_eq!(
r_with(&[Verdict::Skip, Verdict::Pass, Verdict::Warn, Verdict::Fail,])
.overall_verdict(),
Verdict::Fail
);
}
#[test]
fn vp_order_independence() {
let a = r_with(&[Verdict::Fail, Verdict::Warn, Verdict::Pass]).overall_verdict();
let b = r_with(&[Verdict::Pass, Verdict::Warn, Verdict::Fail]).overall_verdict();
let c = r_with(&[Verdict::Warn, Verdict::Pass, Verdict::Fail]).overall_verdict();
assert_eq!(a, Verdict::Fail);
assert_eq!(a, b);
assert_eq!(b, c);
}
}