1use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::{CheckResult, Report, Verdict};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct MultiReport {
40 pub schema_version: u32,
42 pub subject: String,
44 pub subject_version: String,
46 pub started_at: DateTime<Utc>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub finished_at: Option<DateTime<Utc>>,
51 pub reports: Vec<Report>,
53}
54
55impl MultiReport {
56 pub fn new(subject: impl Into<String>, subject_version: impl Into<String>) -> Self {
58 Self {
59 schema_version: 1,
60 subject: subject.into(),
61 subject_version: subject_version.into(),
62 started_at: Utc::now(),
63 finished_at: None,
64 reports: Vec::new(),
65 }
66 }
67
68 pub fn push(&mut self, r: Report) {
70 self.reports.push(r);
71 }
72
73 pub fn finish(&mut self) {
75 self.finished_at = Some(Utc::now());
76 }
77
78 pub fn overall_verdict(&self) -> Verdict {
83 let mut saw_fail = false;
84 let mut saw_warn = false;
85 let mut saw_pass = false;
86 for r in &self.reports {
87 for c in &r.checks {
88 match c.verdict {
89 Verdict::Fail => saw_fail = true,
90 Verdict::Warn => saw_warn = true,
91 Verdict::Pass => saw_pass = true,
92 Verdict::Skip => {}
93 }
94 }
95 }
96 if saw_fail {
97 Verdict::Fail
98 } else if saw_warn {
99 Verdict::Warn
100 } else if saw_pass {
101 Verdict::Pass
102 } else {
103 Verdict::Skip
104 }
105 }
106
107 pub fn total_check_count(&self) -> usize {
109 self.reports.iter().map(|r| r.checks.len()).sum()
110 }
111
112 pub fn iter_checks(&self) -> impl Iterator<Item = (Option<&str>, &CheckResult)> {
117 self.reports.iter().flat_map(|r| {
118 let p = r.producer.as_deref();
119 r.checks.iter().map(move |c| (p, c))
120 })
121 }
122
123 pub fn checks_with_tag<'a>(
142 &'a self,
143 tag: &'a str,
144 ) -> impl Iterator<Item = (Option<&'a str>, &'a CheckResult)> {
145 self.iter_checks().filter(move |(_, c)| c.has_tag(tag))
146 }
147
148 pub fn to_json(&self) -> serde_json::Result<String> {
150 serde_json::to_string_pretty(self)
151 }
152
153 pub fn from_json(s: &str) -> serde_json::Result<Self> {
155 serde_json::from_str(s)
156 }
157
158 pub fn passed(&self) -> bool {
160 self.overall_verdict() == Verdict::Pass
161 }
162
163 pub fn failed(&self) -> bool {
165 self.overall_verdict() == Verdict::Fail
166 }
167
168 pub fn warned(&self) -> bool {
170 self.overall_verdict() == Verdict::Warn
171 }
172
173 pub fn skipped(&self) -> bool {
175 self.overall_verdict() == Verdict::Skip
176 }
177
178 pub fn checks_with_severity(
195 &self,
196 severity: crate::Severity,
197 ) -> impl Iterator<Item = (Option<&str>, &CheckResult)> {
198 self.iter_checks()
199 .filter(move |(_, c)| c.severity == Some(severity))
200 }
201
202 #[cfg(feature = "terminal")]
206 #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
207 pub fn to_terminal(&self) -> String {
208 crate::terminal::multi_to_terminal(self)
209 }
210
211 #[cfg(feature = "terminal")]
215 #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
216 pub fn to_terminal_color(&self) -> String {
217 crate::terminal::multi_to_terminal_color(self)
218 }
219
220 #[cfg(feature = "markdown")]
224 #[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
225 pub fn to_markdown(&self) -> String {
226 crate::markdown::multi_to_markdown(self)
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::Severity;
234
235 fn rep(producer: &str, checks: Vec<CheckResult>) -> Report {
236 let mut r = Report::new("c", "0.1.0").with_producer(producer);
237 for c in checks {
238 r.push(c);
239 }
240 r.finish();
241 r
242 }
243
244 #[test]
245 fn empty_multi_is_skip() {
246 let m = MultiReport::new("c", "0.1.0");
247 assert_eq!(m.overall_verdict(), Verdict::Skip);
248 assert_eq!(m.total_check_count(), 0);
249 }
250
251 #[test]
252 fn fail_in_any_report_dominates() {
253 let mut m = MultiReport::new("c", "0.1.0");
254 m.push(rep("a", vec![CheckResult::pass("x")]));
255 m.push(rep("b", vec![CheckResult::fail("y", Severity::Error)]));
256 m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
257 assert_eq!(m.overall_verdict(), Verdict::Fail);
258 }
259
260 #[test]
261 fn warn_dominates_pass_and_skip() {
262 let mut m = MultiReport::new("c", "0.1.0");
263 m.push(rep("a", vec![CheckResult::pass("x")]));
264 m.push(rep("b", vec![CheckResult::skip("y")]));
265 m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
266 assert_eq!(m.overall_verdict(), Verdict::Warn);
267 }
268
269 #[test]
270 fn pass_dominates_skip() {
271 let mut m = MultiReport::new("c", "0.1.0");
272 m.push(rep("a", vec![CheckResult::skip("x")]));
273 m.push(rep("b", vec![CheckResult::pass("y")]));
274 assert_eq!(m.overall_verdict(), Verdict::Pass);
275 }
276
277 #[test]
278 fn same_name_across_producers_is_kept_separate() {
279 let mut m = MultiReport::new("c", "0.1.0");
282 m.push(rep("p1", vec![CheckResult::pass("compile")]));
283 m.push(rep(
284 "p2",
285 vec![CheckResult::fail("compile", Severity::Error)],
286 ));
287 assert_eq!(m.total_check_count(), 2);
288 assert_eq!(m.overall_verdict(), Verdict::Fail);
289
290 let producers: Vec<_> = m
291 .iter_checks()
292 .filter(|(_, c)| c.name == "compile")
293 .map(|(p, _)| p)
294 .collect();
295 assert_eq!(producers, vec![Some("p1"), Some("p2")]);
296 }
297
298 #[test]
299 fn iter_checks_pairs_with_producer() {
300 let mut m = MultiReport::new("c", "0.1.0");
301 m.push(rep(
302 "p1",
303 vec![CheckResult::pass("a"), CheckResult::pass("b")],
304 ));
305 m.push(rep("p2", vec![CheckResult::pass("c")]));
306 let v: Vec<_> = m.iter_checks().map(|(p, c)| (p, c.name.clone())).collect();
307 assert_eq!(
308 v,
309 vec![
310 (Some("p1"), "a".to_string()),
311 (Some("p1"), "b".to_string()),
312 (Some("p2"), "c".to_string()),
313 ]
314 );
315 }
316
317 #[test]
318 fn json_round_trip() {
319 let mut m = MultiReport::new("c", "0.1.0");
320 m.push(rep(
321 "p1",
322 vec![CheckResult::fail("x", Severity::Error)
323 .with_tag("regression")
324 .with_detail("regressed")],
325 ));
326 m.finish();
327 let json = m.to_json().unwrap();
328 let parsed = MultiReport::from_json(&json).unwrap();
329 assert_eq!(parsed.subject, "c");
330 assert_eq!(parsed.reports.len(), 1);
331 assert_eq!(parsed.overall_verdict(), Verdict::Fail);
332 }
333}