Skip to main content

dev_report/
multi.rs

1//! Aggregation of multiple [`Report`]s into a [`MultiReport`].
2//!
3//! A CI run typically invokes several producers (`dev-bench`,
4//! `dev-fixtures`, `dev-async`, ...) and wants to publish a single
5//! aggregate document. `MultiReport` carries those reports without
6//! merging checks across producers; check identity is
7//! `(producer, name)`.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::{CheckResult, Report, Verdict};
13
14/// Aggregate of multiple [`Report`]s emitted in a single run.
15///
16/// Identity of a check is `(producer, name)`. Two checks with the same
17/// `name` from different producers are kept separate.
18///
19/// # Example
20///
21/// ```
22/// use dev_report::{CheckResult, MultiReport, Report, Severity, Verdict};
23///
24/// let mut bench = Report::new("crate", "0.1.0").with_producer("dev-bench");
25/// bench.push(CheckResult::pass("hot_path"));
26///
27/// let mut chaos = Report::new("crate", "0.1.0").with_producer("dev-chaos");
28/// chaos.push(CheckResult::fail("recover", Severity::Error));
29///
30/// let mut multi = MultiReport::new("crate", "0.1.0");
31/// multi.push(bench);
32/// multi.push(chaos);
33/// multi.finish();
34///
35/// assert_eq!(multi.overall_verdict(), Verdict::Fail);
36/// assert_eq!(multi.total_check_count(), 2);
37/// ```
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct MultiReport {
40    /// Schema version. Tracks the same number as [`Report::schema_version`];
41    /// deserialization rejects versions this build does not understand.
42    #[serde(deserialize_with = "crate::deserialize_schema_version")]
43    pub schema_version: u32,
44    /// Crate or project being reported on.
45    pub subject: String,
46    /// Version of the subject.
47    pub subject_version: String,
48    /// When aggregation started.
49    pub started_at: DateTime<Utc>,
50    /// When aggregation finished, if known.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub finished_at: Option<DateTime<Utc>>,
53    /// Constituent reports.
54    pub reports: Vec<Report>,
55}
56
57impl MultiReport {
58    /// Begin a new aggregate for the given subject and version.
59    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    /// Append a constituent report.
71    pub fn push(&mut self, r: Report) {
72        self.reports.push(r);
73    }
74
75    /// Mark aggregation finished, stamping the finish time.
76    pub fn finish(&mut self) {
77        self.finished_at = Some(Utc::now());
78    }
79
80    /// Compute the overall verdict across every check in every report.
81    ///
82    /// Follows the same precedence as [`Report::overall_verdict`]:
83    /// `Fail > Warn > Pass > Skip`.
84    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    /// Total number of checks across all constituent reports.
110    pub fn total_check_count(&self) -> usize {
111        self.reports.iter().map(|r| r.checks.len()).sum()
112    }
113
114    /// Iterate over the constituent reports.
115    ///
116    /// Equivalent to `self.reports.iter()` but reads cleaner at the
117    /// call site and makes the public iteration API explicit.
118    ///
119    /// # Example
120    ///
121    /// ```
122    /// use dev_report::{CheckResult, MultiReport, Report};
123    ///
124    /// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
125    /// bench.push(CheckResult::pass("hot"));
126    /// let mut multi = MultiReport::new("c", "0.1.0");
127    /// multi.push(bench);
128    ///
129    /// for r in multi.iter_reports() {
130    ///     assert_eq!(r.subject, "c");
131    /// }
132    /// ```
133    pub fn iter_reports(&self) -> impl Iterator<Item = &Report> {
134        self.reports.iter()
135    }
136
137    /// Find the constituent report from a specific producer, if any.
138    ///
139    /// Returns the first match (`MultiReport` doesn't enforce
140    /// uniqueness; producers can appear multiple times).
141    ///
142    /// # Example
143    ///
144    /// ```
145    /// use dev_report::{CheckResult, MultiReport, Report};
146    ///
147    /// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
148    /// bench.push(CheckResult::pass("hot"));
149    /// let mut multi = MultiReport::new("c", "0.1.0");
150    /// multi.push(bench);
151    ///
152    /// let found = multi.report_from("dev-bench").unwrap();
153    /// assert_eq!(found.checks.len(), 1);
154    /// ```
155    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    /// Aggregate verdict counts across all constituent reports as
162    /// `(pass, fail, warn, skip)`.
163    ///
164    /// # Example
165    ///
166    /// ```
167    /// use dev_report::{CheckResult, MultiReport, Report, Severity};
168    ///
169    /// let mut a = Report::new("c", "0.1.0").with_producer("a");
170    /// a.push(CheckResult::pass("x"));
171    /// a.push(CheckResult::fail("y", Severity::Error));
172    /// let mut b = Report::new("c", "0.1.0").with_producer("b");
173    /// b.push(CheckResult::pass("z"));
174    /// let mut multi = MultiReport::new("c", "0.1.0");
175    /// multi.push(a);
176    /// multi.push(b);
177    /// assert_eq!(multi.verdict_counts(), (2, 1, 0, 0));
178    /// ```
179    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    /// Iterate over every check across every constituent report,
195    /// paired with the producer that emitted it.
196    ///
197    /// Producers without a `producer` field are emitted as `None`.
198    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    /// Iterate over checks carrying the given tag, paired with their producer.
206    ///
207    /// # Example
208    ///
209    /// ```
210    /// use dev_report::{CheckResult, MultiReport, Report};
211    ///
212    /// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
213    /// bench.push(CheckResult::pass("hot").with_tag("slow"));
214    /// bench.push(CheckResult::pass("cold"));
215    ///
216    /// let mut multi = MultiReport::new("c", "0.1.0");
217    /// multi.push(bench);
218    ///
219    /// let slow: Vec<_> = multi.checks_with_tag("slow").collect();
220    /// assert_eq!(slow.len(), 1);
221    /// assert_eq!(slow[0].0, Some("dev-bench"));
222    /// ```
223    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    /// Serialize this multi-report to JSON.
231    pub fn to_json(&self) -> serde_json::Result<String> {
232        serde_json::to_string_pretty(self)
233    }
234
235    /// Deserialize a multi-report from JSON.
236    ///
237    /// Fails if the document is malformed or any `schema_version` in it
238    /// (top level or constituent report) is not one this build
239    /// understands (see [`SCHEMA_VERSION`](crate::SCHEMA_VERSION)).
240    pub fn from_json(s: &str) -> serde_json::Result<Self> {
241        serde_json::from_str(s)
242    }
243
244    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Pass`.
245    pub fn passed(&self) -> bool {
246        self.overall_verdict() == Verdict::Pass
247    }
248
249    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Fail`.
250    pub fn failed(&self) -> bool {
251        self.overall_verdict() == Verdict::Fail
252    }
253
254    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Warn`.
255    pub fn warned(&self) -> bool {
256        self.overall_verdict() == Verdict::Warn
257    }
258
259    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Skip`.
260    pub fn skipped(&self) -> bool {
261        self.overall_verdict() == Verdict::Skip
262    }
263
264    /// Iterate over checks with the given severity, paired with their producer.
265    ///
266    /// # Example
267    ///
268    /// ```
269    /// use dev_report::{CheckResult, MultiReport, Report, Severity};
270    ///
271    /// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
272    /// bench.push(CheckResult::fail("a", Severity::Error));
273    ///
274    /// let mut multi = MultiReport::new("c", "0.1.0");
275    /// multi.push(bench);
276    ///
277    /// let errors: Vec<_> = multi.checks_with_severity(Severity::Error).collect();
278    /// assert_eq!(errors.len(), 1);
279    /// ```
280    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    /// Render this multi-report to a TTY-friendly string. Monochrome.
289    ///
290    /// Available with the `terminal` feature.
291    #[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    /// Render this multi-report with ANSI color codes.
298    ///
299    /// Available with the `terminal` feature.
300    #[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    /// Render this multi-report to a Markdown string.
307    ///
308    /// Available with the `markdown` feature.
309    #[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    /// Render this multi-report as a SARIF 2.1.0 document.
316    ///
317    /// Each constituent [`Report`] becomes its own SARIF `run`, so
318    /// consumers can tell which producer emitted which finding. Only
319    /// `Fail` and `Warn` checks are emitted.
320    ///
321    /// Available with the `sarif` feature.
322    #[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    /// Render this multi-report as a JUnit XML document with one
329    /// `<testsuite>` per constituent [`Report`].
330    ///
331    /// Available with the `junit` feature.
332    #[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        // Both producers emit a check named "compile". MultiReport must
389        // NOT collapse them into one entry.
390        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        // Top-level version from the future.
450        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        // Constituent report carrying a version from the future.
455        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        // Version 0 never existed.
462        let zero = json.replacen("\"schema_version\": 1", "\"schema_version\": 0", 1);
463        assert!(MultiReport::from_json(&zero).is_err());
464
465        // The current version still parses.
466        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}