use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{CheckResult, Report, Verdict};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiReport {
pub schema_version: u32,
pub subject: String,
pub subject_version: String,
pub started_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<DateTime<Utc>>,
pub reports: Vec<Report>,
}
impl MultiReport {
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(),
started_at: Utc::now(),
finished_at: None,
reports: Vec::new(),
}
}
pub fn push(&mut self, r: Report) {
self.reports.push(r);
}
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 r in &self.reports {
for c in &r.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 total_check_count(&self) -> usize {
self.reports.iter().map(|r| r.checks.len()).sum()
}
pub fn iter_checks(&self) -> impl Iterator<Item = (Option<&str>, &CheckResult)> {
self.reports.iter().flat_map(|r| {
let p = r.producer.as_deref();
r.checks.iter().map(move |c| (p, c))
})
}
pub fn checks_with_tag<'a>(
&'a self,
tag: &'a str,
) -> impl Iterator<Item = (Option<&'a str>, &'a CheckResult)> {
self.iter_checks().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)
}
pub fn passed(&self) -> bool {
self.overall_verdict() == Verdict::Pass
}
pub fn failed(&self) -> bool {
self.overall_verdict() == Verdict::Fail
}
pub fn warned(&self) -> bool {
self.overall_verdict() == Verdict::Warn
}
pub fn skipped(&self) -> bool {
self.overall_verdict() == Verdict::Skip
}
pub fn checks_with_severity(
&self,
severity: crate::Severity,
) -> impl Iterator<Item = (Option<&str>, &CheckResult)> {
self.iter_checks()
.filter(move |(_, c)| c.severity == Some(severity))
}
#[cfg(feature = "terminal")]
#[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
pub fn to_terminal(&self) -> String {
crate::terminal::multi_to_terminal(self)
}
#[cfg(feature = "terminal")]
#[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
pub fn to_terminal_color(&self) -> String {
crate::terminal::multi_to_terminal_color(self)
}
#[cfg(feature = "markdown")]
#[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
pub fn to_markdown(&self) -> String {
crate::markdown::multi_to_markdown(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Severity;
fn rep(producer: &str, checks: Vec<CheckResult>) -> Report {
let mut r = Report::new("c", "0.1.0").with_producer(producer);
for c in checks {
r.push(c);
}
r.finish();
r
}
#[test]
fn empty_multi_is_skip() {
let m = MultiReport::new("c", "0.1.0");
assert_eq!(m.overall_verdict(), Verdict::Skip);
assert_eq!(m.total_check_count(), 0);
}
#[test]
fn fail_in_any_report_dominates() {
let mut m = MultiReport::new("c", "0.1.0");
m.push(rep("a", vec![CheckResult::pass("x")]));
m.push(rep("b", vec![CheckResult::fail("y", Severity::Error)]));
m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
assert_eq!(m.overall_verdict(), Verdict::Fail);
}
#[test]
fn warn_dominates_pass_and_skip() {
let mut m = MultiReport::new("c", "0.1.0");
m.push(rep("a", vec![CheckResult::pass("x")]));
m.push(rep("b", vec![CheckResult::skip("y")]));
m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
assert_eq!(m.overall_verdict(), Verdict::Warn);
}
#[test]
fn pass_dominates_skip() {
let mut m = MultiReport::new("c", "0.1.0");
m.push(rep("a", vec![CheckResult::skip("x")]));
m.push(rep("b", vec![CheckResult::pass("y")]));
assert_eq!(m.overall_verdict(), Verdict::Pass);
}
#[test]
fn same_name_across_producers_is_kept_separate() {
let mut m = MultiReport::new("c", "0.1.0");
m.push(rep("p1", vec![CheckResult::pass("compile")]));
m.push(rep(
"p2",
vec![CheckResult::fail("compile", Severity::Error)],
));
assert_eq!(m.total_check_count(), 2);
assert_eq!(m.overall_verdict(), Verdict::Fail);
let producers: Vec<_> = m
.iter_checks()
.filter(|(_, c)| c.name == "compile")
.map(|(p, _)| p)
.collect();
assert_eq!(producers, vec![Some("p1"), Some("p2")]);
}
#[test]
fn iter_checks_pairs_with_producer() {
let mut m = MultiReport::new("c", "0.1.0");
m.push(rep(
"p1",
vec![CheckResult::pass("a"), CheckResult::pass("b")],
));
m.push(rep("p2", vec![CheckResult::pass("c")]));
let v: Vec<_> = m.iter_checks().map(|(p, c)| (p, c.name.clone())).collect();
assert_eq!(
v,
vec![
(Some("p1"), "a".to_string()),
(Some("p1"), "b".to_string()),
(Some("p2"), "c".to_string()),
]
);
}
#[test]
fn json_round_trip() {
let mut m = MultiReport::new("c", "0.1.0");
m.push(rep(
"p1",
vec![CheckResult::fail("x", Severity::Error)
.with_tag("regression")
.with_detail("regressed")],
));
m.finish();
let json = m.to_json().unwrap();
let parsed = MultiReport::from_json(&json).unwrap();
assert_eq!(parsed.subject, "c");
assert_eq!(parsed.reports.len(), 1);
assert_eq!(parsed.overall_verdict(), Verdict::Fail);
}
}