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
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::Severity;
314
315    fn rep(producer: &str, checks: Vec<CheckResult>) -> Report {
316        let mut r = Report::new("c", "0.1.0").with_producer(producer);
317        for c in checks {
318            r.push(c);
319        }
320        r.finish();
321        r
322    }
323
324    #[test]
325    fn empty_multi_is_skip() {
326        let m = MultiReport::new("c", "0.1.0");
327        assert_eq!(m.overall_verdict(), Verdict::Skip);
328        assert_eq!(m.total_check_count(), 0);
329    }
330
331    #[test]
332    fn fail_in_any_report_dominates() {
333        let mut m = MultiReport::new("c", "0.1.0");
334        m.push(rep("a", vec![CheckResult::pass("x")]));
335        m.push(rep("b", vec![CheckResult::fail("y", Severity::Error)]));
336        m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
337        assert_eq!(m.overall_verdict(), Verdict::Fail);
338    }
339
340    #[test]
341    fn warn_dominates_pass_and_skip() {
342        let mut m = MultiReport::new("c", "0.1.0");
343        m.push(rep("a", vec![CheckResult::pass("x")]));
344        m.push(rep("b", vec![CheckResult::skip("y")]));
345        m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
346        assert_eq!(m.overall_verdict(), Verdict::Warn);
347    }
348
349    #[test]
350    fn pass_dominates_skip() {
351        let mut m = MultiReport::new("c", "0.1.0");
352        m.push(rep("a", vec![CheckResult::skip("x")]));
353        m.push(rep("b", vec![CheckResult::pass("y")]));
354        assert_eq!(m.overall_verdict(), Verdict::Pass);
355    }
356
357    #[test]
358    fn same_name_across_producers_is_kept_separate() {
359        // Both producers emit a check named "compile". MultiReport must
360        // NOT collapse them into one entry.
361        let mut m = MultiReport::new("c", "0.1.0");
362        m.push(rep("p1", vec![CheckResult::pass("compile")]));
363        m.push(rep(
364            "p2",
365            vec![CheckResult::fail("compile", Severity::Error)],
366        ));
367        assert_eq!(m.total_check_count(), 2);
368        assert_eq!(m.overall_verdict(), Verdict::Fail);
369
370        let producers: Vec<_> = m
371            .iter_checks()
372            .filter(|(_, c)| c.name == "compile")
373            .map(|(p, _)| p)
374            .collect();
375        assert_eq!(producers, vec![Some("p1"), Some("p2")]);
376    }
377
378    #[test]
379    fn iter_checks_pairs_with_producer() {
380        let mut m = MultiReport::new("c", "0.1.0");
381        m.push(rep(
382            "p1",
383            vec![CheckResult::pass("a"), CheckResult::pass("b")],
384        ));
385        m.push(rep("p2", vec![CheckResult::pass("c")]));
386        let v: Vec<_> = m.iter_checks().map(|(p, c)| (p, c.name.clone())).collect();
387        assert_eq!(
388            v,
389            vec![
390                (Some("p1"), "a".to_string()),
391                (Some("p1"), "b".to_string()),
392                (Some("p2"), "c".to_string()),
393            ]
394        );
395    }
396
397    #[test]
398    fn json_round_trip() {
399        let mut m = MultiReport::new("c", "0.1.0");
400        m.push(rep(
401            "p1",
402            vec![CheckResult::fail("x", Severity::Error)
403                .with_tag("regression")
404                .with_detail("regressed")],
405        ));
406        m.finish();
407        let json = m.to_json().unwrap();
408        let parsed = MultiReport::from_json(&json).unwrap();
409        assert_eq!(parsed.subject, "c");
410        assert_eq!(parsed.reports.len(), 1);
411        assert_eq!(parsed.overall_verdict(), Verdict::Fail);
412    }
413
414    #[test]
415    fn iter_reports_yields_each_report() {
416        let mut m = MultiReport::new("c", "0.1.0");
417        m.push(rep("a", vec![CheckResult::pass("x")]));
418        m.push(rep("b", vec![CheckResult::pass("y")]));
419        let producers: Vec<&str> = m
420            .iter_reports()
421            .filter_map(|r| r.producer.as_deref())
422            .collect();
423        assert_eq!(producers, vec!["a", "b"]);
424    }
425
426    #[test]
427    fn report_from_finds_by_producer() {
428        let mut m = MultiReport::new("c", "0.1.0");
429        m.push(rep("dev-bench", vec![CheckResult::pass("hot")]));
430        m.push(rep("dev-chaos", vec![CheckResult::pass("recover")]));
431        assert!(m.report_from("dev-bench").is_some());
432        assert!(m.report_from("dev-chaos").is_some());
433        assert!(m.report_from("not-here").is_none());
434    }
435
436    #[test]
437    fn multi_verdict_counts_aggregates() {
438        let mut m = MultiReport::new("c", "0.1.0");
439        m.push(rep(
440            "a",
441            vec![
442                CheckResult::pass("x"),
443                CheckResult::fail("y", Severity::Error),
444            ],
445        ));
446        m.push(rep(
447            "b",
448            vec![
449                CheckResult::pass("z"),
450                CheckResult::warn("w", Severity::Warning),
451            ],
452        ));
453        assert_eq!(m.verdict_counts(), (2, 1, 1, 0));
454    }
455}