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    pub schema_version: u32,
42    /// Crate or project being reported on.
43    pub subject: String,
44    /// Version of the subject.
45    pub subject_version: String,
46    /// When aggregation started.
47    pub started_at: DateTime<Utc>,
48    /// When aggregation finished, if known.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub finished_at: Option<DateTime<Utc>>,
51    /// Constituent reports.
52    pub reports: Vec<Report>,
53}
54
55impl MultiReport {
56    /// Begin a new aggregate for the given subject and version.
57    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    /// Append a constituent report.
69    pub fn push(&mut self, r: Report) {
70        self.reports.push(r);
71    }
72
73    /// Mark aggregation finished, stamping the finish time.
74    pub fn finish(&mut self) {
75        self.finished_at = Some(Utc::now());
76    }
77
78    /// Compute the overall verdict across every check in every report.
79    ///
80    /// Follows the same precedence as [`Report::overall_verdict`]:
81    /// `Fail > Warn > Pass > Skip`.
82    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    /// Total number of checks across all constituent reports.
108    pub fn total_check_count(&self) -> usize {
109        self.reports.iter().map(|r| r.checks.len()).sum()
110    }
111
112    /// Iterate over the constituent reports.
113    ///
114    /// Equivalent to `self.reports.iter()` but reads cleaner at the
115    /// call site and makes the public iteration API explicit.
116    ///
117    /// # Example
118    ///
119    /// ```
120    /// use dev_report::{CheckResult, MultiReport, Report};
121    ///
122    /// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
123    /// bench.push(CheckResult::pass("hot"));
124    /// let mut multi = MultiReport::new("c", "0.1.0");
125    /// multi.push(bench);
126    ///
127    /// for r in multi.iter_reports() {
128    ///     assert_eq!(r.subject, "c");
129    /// }
130    /// ```
131    pub fn iter_reports(&self) -> impl Iterator<Item = &Report> {
132        self.reports.iter()
133    }
134
135    /// Find the constituent report from a specific producer, if any.
136    ///
137    /// Returns the first match (`MultiReport` doesn't enforce
138    /// uniqueness; producers can appear multiple times).
139    ///
140    /// # Example
141    ///
142    /// ```
143    /// use dev_report::{CheckResult, MultiReport, Report};
144    ///
145    /// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
146    /// bench.push(CheckResult::pass("hot"));
147    /// let mut multi = MultiReport::new("c", "0.1.0");
148    /// multi.push(bench);
149    ///
150    /// let found = multi.report_from("dev-bench").unwrap();
151    /// assert_eq!(found.checks.len(), 1);
152    /// ```
153    pub fn report_from(&self, producer: &str) -> Option<&Report> {
154        self.reports
155            .iter()
156            .find(|r| r.producer.as_deref() == Some(producer))
157    }
158
159    /// Aggregate verdict counts across all constituent reports as
160    /// `(pass, fail, warn, skip)`.
161    ///
162    /// # Example
163    ///
164    /// ```
165    /// use dev_report::{CheckResult, MultiReport, Report, Severity};
166    ///
167    /// let mut a = Report::new("c", "0.1.0").with_producer("a");
168    /// a.push(CheckResult::pass("x"));
169    /// a.push(CheckResult::fail("y", Severity::Error));
170    /// let mut b = Report::new("c", "0.1.0").with_producer("b");
171    /// b.push(CheckResult::pass("z"));
172    /// let mut multi = MultiReport::new("c", "0.1.0");
173    /// multi.push(a);
174    /// multi.push(b);
175    /// assert_eq!(multi.verdict_counts(), (2, 1, 0, 0));
176    /// ```
177    pub fn verdict_counts(&self) -> (usize, usize, usize, usize) {
178        let (mut p, mut f, mut w, mut s) = (0, 0, 0, 0);
179        for r in &self.reports {
180            for c in &r.checks {
181                match c.verdict {
182                    Verdict::Pass => p += 1,
183                    Verdict::Fail => f += 1,
184                    Verdict::Warn => w += 1,
185                    Verdict::Skip => s += 1,
186                }
187            }
188        }
189        (p, f, w, s)
190    }
191
192    /// Iterate over every check across every constituent report,
193    /// paired with the producer that emitted it.
194    ///
195    /// Producers without a `producer` field are emitted as `None`.
196    pub fn iter_checks(&self) -> impl Iterator<Item = (Option<&str>, &CheckResult)> {
197        self.reports.iter().flat_map(|r| {
198            let p = r.producer.as_deref();
199            r.checks.iter().map(move |c| (p, c))
200        })
201    }
202
203    /// Iterate over checks carrying the given tag, paired with their producer.
204    ///
205    /// # Example
206    ///
207    /// ```
208    /// use dev_report::{CheckResult, MultiReport, Report};
209    ///
210    /// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
211    /// bench.push(CheckResult::pass("hot").with_tag("slow"));
212    /// bench.push(CheckResult::pass("cold"));
213    ///
214    /// let mut multi = MultiReport::new("c", "0.1.0");
215    /// multi.push(bench);
216    ///
217    /// let slow: Vec<_> = multi.checks_with_tag("slow").collect();
218    /// assert_eq!(slow.len(), 1);
219    /// assert_eq!(slow[0].0, Some("dev-bench"));
220    /// ```
221    pub fn checks_with_tag<'a>(
222        &'a self,
223        tag: &'a str,
224    ) -> impl Iterator<Item = (Option<&'a str>, &'a CheckResult)> {
225        self.iter_checks().filter(move |(_, c)| c.has_tag(tag))
226    }
227
228    /// Serialize this multi-report to JSON.
229    pub fn to_json(&self) -> serde_json::Result<String> {
230        serde_json::to_string_pretty(self)
231    }
232
233    /// Deserialize a multi-report from JSON.
234    pub fn from_json(s: &str) -> serde_json::Result<Self> {
235        serde_json::from_str(s)
236    }
237
238    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Pass`.
239    pub fn passed(&self) -> bool {
240        self.overall_verdict() == Verdict::Pass
241    }
242
243    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Fail`.
244    pub fn failed(&self) -> bool {
245        self.overall_verdict() == Verdict::Fail
246    }
247
248    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Warn`.
249    pub fn warned(&self) -> bool {
250        self.overall_verdict() == Verdict::Warn
251    }
252
253    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Skip`.
254    pub fn skipped(&self) -> bool {
255        self.overall_verdict() == Verdict::Skip
256    }
257
258    /// Iterate over checks with the given severity, paired with their producer.
259    ///
260    /// # Example
261    ///
262    /// ```
263    /// use dev_report::{CheckResult, MultiReport, Report, Severity};
264    ///
265    /// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
266    /// bench.push(CheckResult::fail("a", Severity::Error));
267    ///
268    /// let mut multi = MultiReport::new("c", "0.1.0");
269    /// multi.push(bench);
270    ///
271    /// let errors: Vec<_> = multi.checks_with_severity(Severity::Error).collect();
272    /// assert_eq!(errors.len(), 1);
273    /// ```
274    pub fn checks_with_severity(
275        &self,
276        severity: crate::Severity,
277    ) -> impl Iterator<Item = (Option<&str>, &CheckResult)> {
278        self.iter_checks()
279            .filter(move |(_, c)| c.severity == Some(severity))
280    }
281
282    /// Render this multi-report to a TTY-friendly string. Monochrome.
283    ///
284    /// Available with the `terminal` feature.
285    #[cfg(feature = "terminal")]
286    #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
287    pub fn to_terminal(&self) -> String {
288        crate::terminal::multi_to_terminal(self)
289    }
290
291    /// Render this multi-report with ANSI color codes.
292    ///
293    /// Available with the `terminal` feature.
294    #[cfg(feature = "terminal")]
295    #[cfg_attr(docsrs, doc(cfg(feature = "terminal")))]
296    pub fn to_terminal_color(&self) -> String {
297        crate::terminal::multi_to_terminal_color(self)
298    }
299
300    /// Render this multi-report to a Markdown string.
301    ///
302    /// Available with the `markdown` feature.
303    #[cfg(feature = "markdown")]
304    #[cfg_attr(docsrs, doc(cfg(feature = "markdown")))]
305    pub fn to_markdown(&self) -> String {
306        crate::markdown::multi_to_markdown(self)
307    }
308
309    /// Render this multi-report as a SARIF 2.1.0 document.
310    ///
311    /// Each constituent [`Report`] becomes its own SARIF `run`, so
312    /// consumers can tell which producer emitted which finding. Only
313    /// `Fail` and `Warn` checks are emitted.
314    ///
315    /// Available with the `sarif` feature.
316    #[cfg(feature = "sarif")]
317    #[cfg_attr(docsrs, doc(cfg(feature = "sarif")))]
318    pub fn to_sarif(&self) -> String {
319        crate::sarif::multi_to_sarif(self)
320    }
321
322    /// Render this multi-report as a JUnit XML document with one
323    /// `<testsuite>` per constituent [`Report`].
324    ///
325    /// Available with the `junit` feature.
326    #[cfg(feature = "junit")]
327    #[cfg_attr(docsrs, doc(cfg(feature = "junit")))]
328    pub fn to_junit_xml(&self) -> String {
329        crate::junit::multi_to_junit_xml(self)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::Severity;
337
338    fn rep(producer: &str, checks: Vec<CheckResult>) -> Report {
339        let mut r = Report::new("c", "0.1.0").with_producer(producer);
340        for c in checks {
341            r.push(c);
342        }
343        r.finish();
344        r
345    }
346
347    #[test]
348    fn empty_multi_is_skip() {
349        let m = MultiReport::new("c", "0.1.0");
350        assert_eq!(m.overall_verdict(), Verdict::Skip);
351        assert_eq!(m.total_check_count(), 0);
352    }
353
354    #[test]
355    fn fail_in_any_report_dominates() {
356        let mut m = MultiReport::new("c", "0.1.0");
357        m.push(rep("a", vec![CheckResult::pass("x")]));
358        m.push(rep("b", vec![CheckResult::fail("y", Severity::Error)]));
359        m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
360        assert_eq!(m.overall_verdict(), Verdict::Fail);
361    }
362
363    #[test]
364    fn warn_dominates_pass_and_skip() {
365        let mut m = MultiReport::new("c", "0.1.0");
366        m.push(rep("a", vec![CheckResult::pass("x")]));
367        m.push(rep("b", vec![CheckResult::skip("y")]));
368        m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
369        assert_eq!(m.overall_verdict(), Verdict::Warn);
370    }
371
372    #[test]
373    fn pass_dominates_skip() {
374        let mut m = MultiReport::new("c", "0.1.0");
375        m.push(rep("a", vec![CheckResult::skip("x")]));
376        m.push(rep("b", vec![CheckResult::pass("y")]));
377        assert_eq!(m.overall_verdict(), Verdict::Pass);
378    }
379
380    #[test]
381    fn same_name_across_producers_is_kept_separate() {
382        // Both producers emit a check named "compile". MultiReport must
383        // NOT collapse them into one entry.
384        let mut m = MultiReport::new("c", "0.1.0");
385        m.push(rep("p1", vec![CheckResult::pass("compile")]));
386        m.push(rep(
387            "p2",
388            vec![CheckResult::fail("compile", Severity::Error)],
389        ));
390        assert_eq!(m.total_check_count(), 2);
391        assert_eq!(m.overall_verdict(), Verdict::Fail);
392
393        let producers: Vec<_> = m
394            .iter_checks()
395            .filter(|(_, c)| c.name == "compile")
396            .map(|(p, _)| p)
397            .collect();
398        assert_eq!(producers, vec![Some("p1"), Some("p2")]);
399    }
400
401    #[test]
402    fn iter_checks_pairs_with_producer() {
403        let mut m = MultiReport::new("c", "0.1.0");
404        m.push(rep(
405            "p1",
406            vec![CheckResult::pass("a"), CheckResult::pass("b")],
407        ));
408        m.push(rep("p2", vec![CheckResult::pass("c")]));
409        let v: Vec<_> = m.iter_checks().map(|(p, c)| (p, c.name.clone())).collect();
410        assert_eq!(
411            v,
412            vec![
413                (Some("p1"), "a".to_string()),
414                (Some("p1"), "b".to_string()),
415                (Some("p2"), "c".to_string()),
416            ]
417        );
418    }
419
420    #[test]
421    fn json_round_trip() {
422        let mut m = MultiReport::new("c", "0.1.0");
423        m.push(rep(
424            "p1",
425            vec![CheckResult::fail("x", Severity::Error)
426                .with_tag("regression")
427                .with_detail("regressed")],
428        ));
429        m.finish();
430        let json = m.to_json().unwrap();
431        let parsed = MultiReport::from_json(&json).unwrap();
432        assert_eq!(parsed.subject, "c");
433        assert_eq!(parsed.reports.len(), 1);
434        assert_eq!(parsed.overall_verdict(), Verdict::Fail);
435    }
436
437    #[test]
438    fn iter_reports_yields_each_report() {
439        let mut m = MultiReport::new("c", "0.1.0");
440        m.push(rep("a", vec![CheckResult::pass("x")]));
441        m.push(rep("b", vec![CheckResult::pass("y")]));
442        let producers: Vec<&str> = m
443            .iter_reports()
444            .filter_map(|r| r.producer.as_deref())
445            .collect();
446        assert_eq!(producers, vec!["a", "b"]);
447    }
448
449    #[test]
450    fn report_from_finds_by_producer() {
451        let mut m = MultiReport::new("c", "0.1.0");
452        m.push(rep("dev-bench", vec![CheckResult::pass("hot")]));
453        m.push(rep("dev-chaos", vec![CheckResult::pass("recover")]));
454        assert!(m.report_from("dev-bench").is_some());
455        assert!(m.report_from("dev-chaos").is_some());
456        assert!(m.report_from("not-here").is_none());
457    }
458
459    #[test]
460    fn multi_verdict_counts_aggregates() {
461        let mut m = MultiReport::new("c", "0.1.0");
462        m.push(rep(
463            "a",
464            vec![
465                CheckResult::pass("x"),
466                CheckResult::fail("y", Severity::Error),
467            ],
468        ));
469        m.push(rep(
470            "b",
471            vec![
472                CheckResult::pass("z"),
473                CheckResult::warn("w", Severity::Warning),
474            ],
475        ));
476        assert_eq!(m.verdict_counts(), (2, 1, 1, 0));
477    }
478}