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 every check across every constituent report,
113    /// paired with the producer that emitted it.
114    ///
115    /// Producers without a `producer` field are emitted as `None`.
116    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    /// Iterate over checks carrying the given tag, paired with their producer.
124    ///
125    /// # Example
126    ///
127    /// ```
128    /// use dev_report::{CheckResult, MultiReport, Report};
129    ///
130    /// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
131    /// bench.push(CheckResult::pass("hot").with_tag("slow"));
132    /// bench.push(CheckResult::pass("cold"));
133    ///
134    /// let mut multi = MultiReport::new("c", "0.1.0");
135    /// multi.push(bench);
136    ///
137    /// let slow: Vec<_> = multi.checks_with_tag("slow").collect();
138    /// assert_eq!(slow.len(), 1);
139    /// assert_eq!(slow[0].0, Some("dev-bench"));
140    /// ```
141    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    /// Serialize this multi-report to JSON.
149    pub fn to_json(&self) -> serde_json::Result<String> {
150        serde_json::to_string_pretty(self)
151    }
152
153    /// Deserialize a multi-report from JSON.
154    pub fn from_json(s: &str) -> serde_json::Result<Self> {
155        serde_json::from_str(s)
156    }
157
158    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Pass`.
159    pub fn passed(&self) -> bool {
160        self.overall_verdict() == Verdict::Pass
161    }
162
163    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Fail`.
164    pub fn failed(&self) -> bool {
165        self.overall_verdict() == Verdict::Fail
166    }
167
168    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Warn`.
169    pub fn warned(&self) -> bool {
170        self.overall_verdict() == Verdict::Warn
171    }
172
173    /// `true` when [`overall_verdict`](Self::overall_verdict) is `Skip`.
174    pub fn skipped(&self) -> bool {
175        self.overall_verdict() == Verdict::Skip
176    }
177
178    /// Iterate over checks with the given severity, paired with their producer.
179    ///
180    /// # Example
181    ///
182    /// ```
183    /// use dev_report::{CheckResult, MultiReport, Report, Severity};
184    ///
185    /// let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
186    /// bench.push(CheckResult::fail("a", Severity::Error));
187    ///
188    /// let mut multi = MultiReport::new("c", "0.1.0");
189    /// multi.push(bench);
190    ///
191    /// let errors: Vec<_> = multi.checks_with_severity(Severity::Error).collect();
192    /// assert_eq!(errors.len(), 1);
193    /// ```
194    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    /// Render this multi-report to a TTY-friendly string. Monochrome.
203    ///
204    /// Available with the `terminal` feature.
205    #[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    /// Render this multi-report with ANSI color codes.
212    ///
213    /// Available with the `terminal` feature.
214    #[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    /// Render this multi-report to a Markdown string.
221    ///
222    /// Available with the `markdown` feature.
223    #[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        // Both producers emit a check named "compile". MultiReport must
280        // NOT collapse them into one entry.
281        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}