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 #[serde(deserialize_with = "crate::deserialize_schema_version")]
43 pub schema_version: u32,
44 pub subject: String,
46 pub subject_version: String,
48 pub started_at: DateTime<Utc>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub finished_at: Option<DateTime<Utc>>,
53 pub reports: Vec<Report>,
55}
56
57impl MultiReport {
58 pub fn new(subject: impl Into<String>, subject_version: impl Into<String>) -> Self {
60 Self {
61 schema_version: crate::SCHEMA_VERSION,
62 subject: subject.into(),
63 subject_version: subject_version.into(),
64 started_at: Utc::now(),
65 finished_at: None,
66 reports: Vec::new(),
67 }
68 }
69
70 pub fn push(&mut self, r: Report) {
72 self.reports.push(r);
73 }
74
75 pub fn finish(&mut self) {
77 self.finished_at = Some(Utc::now());
78 }
79
80 pub fn overall_verdict(&self) -> Verdict {
85 let mut saw_fail = false;
86 let mut saw_warn = false;
87 let mut saw_pass = false;
88 for r in &self.reports {
89 for c in &r.checks {
90 match c.verdict {
91 Verdict::Fail => saw_fail = true,
92 Verdict::Warn => saw_warn = true,
93 Verdict::Pass => saw_pass = true,
94 Verdict::Skip => {}
95 }
96 }
97 }
98 if saw_fail {
99 Verdict::Fail
100 } else if saw_warn {
101 Verdict::Warn
102 } else if saw_pass {
103 Verdict::Pass
104 } else {
105 Verdict::Skip
106 }
107 }
108
109 pub fn total_check_count(&self) -> usize {
111 self.reports.iter().map(|r| r.checks.len()).sum()
112 }
113
114 pub fn iter_reports(&self) -> impl Iterator<Item = &Report> {
134 self.reports.iter()
135 }
136
137 pub fn report_from(&self, producer: &str) -> Option<&Report> {
156 self.reports
157 .iter()
158 .find(|r| r.producer.as_deref() == Some(producer))
159 }
160
161 pub fn verdict_counts(&self) -> (usize, usize, usize, usize) {
180 let (mut p, mut f, mut w, mut s) = (0, 0, 0, 0);
181 for r in &self.reports {
182 for c in &r.checks {
183 match c.verdict {
184 Verdict::Pass => p += 1,
185 Verdict::Fail => f += 1,
186 Verdict::Warn => w += 1,
187 Verdict::Skip => s += 1,
188 }
189 }
190 }
191 (p, f, w, s)
192 }
193
194 pub fn iter_checks(&self) -> impl Iterator<Item = (Option<&str>, &CheckResult)> {
199 self.reports.iter().flat_map(|r| {
200 let p = r.producer.as_deref();
201 r.checks.iter().map(move |c| (p, c))
202 })
203 }
204
205 pub fn checks_with_tag<'a>(
224 &'a self,
225 tag: &'a str,
226 ) -> impl Iterator<Item = (Option<&'a str>, &'a CheckResult)> {
227 self.iter_checks().filter(move |(_, c)| c.has_tag(tag))
228 }
229
230 pub fn to_json(&self) -> serde_json::Result<String> {
232 serde_json::to_string_pretty(self)
233 }
234
235 pub fn from_json(s: &str) -> serde_json::Result<Self> {
241 serde_json::from_str(s)
242 }
243
244 pub fn passed(&self) -> bool {
246 self.overall_verdict() == Verdict::Pass
247 }
248
249 pub fn failed(&self) -> bool {
251 self.overall_verdict() == Verdict::Fail
252 }
253
254 pub fn warned(&self) -> bool {
256 self.overall_verdict() == Verdict::Warn
257 }
258
259 pub fn skipped(&self) -> bool {
261 self.overall_verdict() == Verdict::Skip
262 }
263
264 pub fn checks_with_severity(
281 &self,
282 severity: crate::Severity,
283 ) -> impl Iterator<Item = (Option<&str>, &CheckResult)> {
284 self.iter_checks()
285 .filter(move |(_, c)| c.severity == Some(severity))
286 }
287
288 #[cfg(feature = "terminal")]
292 #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
293 pub fn to_terminal(&self) -> String {
294 crate::terminal::multi_to_terminal(self)
295 }
296
297 #[cfg(feature = "terminal")]
301 #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
302 pub fn to_terminal_color(&self) -> String {
303 crate::terminal::multi_to_terminal_color(self)
304 }
305
306 #[cfg(feature = "markdown")]
310 #[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
311 pub fn to_markdown(&self) -> String {
312 crate::markdown::multi_to_markdown(self)
313 }
314
315 #[cfg(feature = "sarif")]
323 #[cfg_attr(docsrs, doc(cfg(feature = "sarif")))]
324 pub fn to_sarif(&self) -> String {
325 crate::sarif::multi_to_sarif(self)
326 }
327
328 #[cfg(feature = "junit")]
333 #[cfg_attr(docsrs, doc(cfg(feature = "junit")))]
334 pub fn to_junit_xml(&self) -> String {
335 crate::junit::multi_to_junit_xml(self)
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::Severity;
343
344 fn rep(producer: &str, checks: Vec<CheckResult>) -> Report {
345 let mut r = Report::new("c", "0.1.0").with_producer(producer);
346 for c in checks {
347 r.push(c);
348 }
349 r.finish();
350 r
351 }
352
353 #[test]
354 fn empty_multi_is_skip() {
355 let m = MultiReport::new("c", "0.1.0");
356 assert_eq!(m.overall_verdict(), Verdict::Skip);
357 assert_eq!(m.total_check_count(), 0);
358 }
359
360 #[test]
361 fn fail_in_any_report_dominates() {
362 let mut m = MultiReport::new("c", "0.1.0");
363 m.push(rep("a", vec![CheckResult::pass("x")]));
364 m.push(rep("b", vec![CheckResult::fail("y", Severity::Error)]));
365 m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
366 assert_eq!(m.overall_verdict(), Verdict::Fail);
367 }
368
369 #[test]
370 fn warn_dominates_pass_and_skip() {
371 let mut m = MultiReport::new("c", "0.1.0");
372 m.push(rep("a", vec![CheckResult::pass("x")]));
373 m.push(rep("b", vec![CheckResult::skip("y")]));
374 m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
375 assert_eq!(m.overall_verdict(), Verdict::Warn);
376 }
377
378 #[test]
379 fn pass_dominates_skip() {
380 let mut m = MultiReport::new("c", "0.1.0");
381 m.push(rep("a", vec![CheckResult::skip("x")]));
382 m.push(rep("b", vec![CheckResult::pass("y")]));
383 assert_eq!(m.overall_verdict(), Verdict::Pass);
384 }
385
386 #[test]
387 fn same_name_across_producers_is_kept_separate() {
388 let mut m = MultiReport::new("c", "0.1.0");
391 m.push(rep("p1", vec![CheckResult::pass("compile")]));
392 m.push(rep(
393 "p2",
394 vec![CheckResult::fail("compile", Severity::Error)],
395 ));
396 assert_eq!(m.total_check_count(), 2);
397 assert_eq!(m.overall_verdict(), Verdict::Fail);
398
399 let producers: Vec<_> = m
400 .iter_checks()
401 .filter(|(_, c)| c.name == "compile")
402 .map(|(p, _)| p)
403 .collect();
404 assert_eq!(producers, vec![Some("p1"), Some("p2")]);
405 }
406
407 #[test]
408 fn iter_checks_pairs_with_producer() {
409 let mut m = MultiReport::new("c", "0.1.0");
410 m.push(rep(
411 "p1",
412 vec![CheckResult::pass("a"), CheckResult::pass("b")],
413 ));
414 m.push(rep("p2", vec![CheckResult::pass("c")]));
415 let v: Vec<_> = m.iter_checks().map(|(p, c)| (p, c.name.clone())).collect();
416 assert_eq!(
417 v,
418 vec![
419 (Some("p1"), "a".to_string()),
420 (Some("p1"), "b".to_string()),
421 (Some("p2"), "c".to_string()),
422 ]
423 );
424 }
425
426 #[test]
427 fn json_round_trip() {
428 let mut m = MultiReport::new("c", "0.1.0");
429 m.push(rep(
430 "p1",
431 vec![CheckResult::fail("x", Severity::Error)
432 .with_tag("regression")
433 .with_detail("regressed")],
434 ));
435 m.finish();
436 let json = m.to_json().unwrap();
437 let parsed = MultiReport::from_json(&json).unwrap();
438 assert_eq!(parsed.subject, "c");
439 assert_eq!(parsed.reports.len(), 1);
440 assert_eq!(parsed.overall_verdict(), Verdict::Fail);
441 }
442
443 #[test]
444 fn from_json_rejects_unknown_schema_versions() {
445 let mut m = MultiReport::new("c", "0.1.0");
446 m.push(rep("p1", vec![CheckResult::pass("x")]));
447 let json = m.to_json().unwrap();
448
449 let top = json.replacen("\"schema_version\": 1", "\"schema_version\": 2", 1);
451 let err = MultiReport::from_json(&top).unwrap_err().to_string();
452 assert!(err.contains("unsupported schema_version 2"), "{err}");
453
454 let pos = json.rfind("\"schema_version\": 1").unwrap();
456 let mut inner = json.clone();
457 inner.replace_range(pos.., &json[pos..].replacen("1", "7", 1));
458 let err = MultiReport::from_json(&inner).unwrap_err().to_string();
459 assert!(err.contains("unsupported schema_version 7"), "{err}");
460
461 let zero = json.replacen("\"schema_version\": 1", "\"schema_version\": 0", 1);
463 assert!(MultiReport::from_json(&zero).is_err());
464
465 assert!(MultiReport::from_json(&json).is_ok());
467 }
468
469 #[test]
470 fn iter_reports_yields_each_report() {
471 let mut m = MultiReport::new("c", "0.1.0");
472 m.push(rep("a", vec![CheckResult::pass("x")]));
473 m.push(rep("b", vec![CheckResult::pass("y")]));
474 let producers: Vec<&str> = m
475 .iter_reports()
476 .filter_map(|r| r.producer.as_deref())
477 .collect();
478 assert_eq!(producers, vec!["a", "b"]);
479 }
480
481 #[test]
482 fn report_from_finds_by_producer() {
483 let mut m = MultiReport::new("c", "0.1.0");
484 m.push(rep("dev-bench", vec![CheckResult::pass("hot")]));
485 m.push(rep("dev-chaos", vec![CheckResult::pass("recover")]));
486 assert!(m.report_from("dev-bench").is_some());
487 assert!(m.report_from("dev-chaos").is_some());
488 assert!(m.report_from("not-here").is_none());
489 }
490
491 #[test]
492 fn multi_verdict_counts_aggregates() {
493 let mut m = MultiReport::new("c", "0.1.0");
494 m.push(rep(
495 "a",
496 vec![
497 CheckResult::pass("x"),
498 CheckResult::fail("y", Severity::Error),
499 ],
500 ));
501 m.push(rep(
502 "b",
503 vec![
504 CheckResult::pass("z"),
505 CheckResult::warn("w", Severity::Warning),
506 ],
507 ));
508 assert_eq!(m.verdict_counts(), (2, 1, 1, 0));
509 }
510}