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
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::Severity;
163
164    fn rep(producer: &str, checks: Vec<CheckResult>) -> Report {
165        let mut r = Report::new("c", "0.1.0").with_producer(producer);
166        for c in checks {
167            r.push(c);
168        }
169        r.finish();
170        r
171    }
172
173    #[test]
174    fn empty_multi_is_skip() {
175        let m = MultiReport::new("c", "0.1.0");
176        assert_eq!(m.overall_verdict(), Verdict::Skip);
177        assert_eq!(m.total_check_count(), 0);
178    }
179
180    #[test]
181    fn fail_in_any_report_dominates() {
182        let mut m = MultiReport::new("c", "0.1.0");
183        m.push(rep("a", vec![CheckResult::pass("x")]));
184        m.push(rep("b", vec![CheckResult::fail("y", Severity::Error)]));
185        m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
186        assert_eq!(m.overall_verdict(), Verdict::Fail);
187    }
188
189    #[test]
190    fn warn_dominates_pass_and_skip() {
191        let mut m = MultiReport::new("c", "0.1.0");
192        m.push(rep("a", vec![CheckResult::pass("x")]));
193        m.push(rep("b", vec![CheckResult::skip("y")]));
194        m.push(rep("c", vec![CheckResult::warn("z", Severity::Warning)]));
195        assert_eq!(m.overall_verdict(), Verdict::Warn);
196    }
197
198    #[test]
199    fn pass_dominates_skip() {
200        let mut m = MultiReport::new("c", "0.1.0");
201        m.push(rep("a", vec![CheckResult::skip("x")]));
202        m.push(rep("b", vec![CheckResult::pass("y")]));
203        assert_eq!(m.overall_verdict(), Verdict::Pass);
204    }
205
206    #[test]
207    fn same_name_across_producers_is_kept_separate() {
208        // Both producers emit a check named "compile". MultiReport must
209        // NOT collapse them into one entry.
210        let mut m = MultiReport::new("c", "0.1.0");
211        m.push(rep("p1", vec![CheckResult::pass("compile")]));
212        m.push(rep(
213            "p2",
214            vec![CheckResult::fail("compile", Severity::Error)],
215        ));
216        assert_eq!(m.total_check_count(), 2);
217        assert_eq!(m.overall_verdict(), Verdict::Fail);
218
219        let producers: Vec<_> = m
220            .iter_checks()
221            .filter(|(_, c)| c.name == "compile")
222            .map(|(p, _)| p)
223            .collect();
224        assert_eq!(producers, vec![Some("p1"), Some("p2")]);
225    }
226
227    #[test]
228    fn iter_checks_pairs_with_producer() {
229        let mut m = MultiReport::new("c", "0.1.0");
230        m.push(rep(
231            "p1",
232            vec![CheckResult::pass("a"), CheckResult::pass("b")],
233        ));
234        m.push(rep("p2", vec![CheckResult::pass("c")]));
235        let v: Vec<_> = m.iter_checks().map(|(p, c)| (p, c.name.clone())).collect();
236        assert_eq!(
237            v,
238            vec![
239                (Some("p1"), "a".to_string()),
240                (Some("p1"), "b".to_string()),
241                (Some("p2"), "c".to_string()),
242            ]
243        );
244    }
245
246    #[test]
247    fn json_round_trip() {
248        let mut m = MultiReport::new("c", "0.1.0");
249        m.push(rep(
250            "p1",
251            vec![CheckResult::fail("x", Severity::Error)
252                .with_tag("regression")
253                .with_detail("regressed")],
254        ));
255        m.finish();
256        let json = m.to_json().unwrap();
257        let parsed = MultiReport::from_json(&json).unwrap();
258        assert_eq!(parsed.subject, "c");
259        assert_eq!(parsed.reports.len(), 1);
260        assert_eq!(parsed.overall_verdict(), Verdict::Fail);
261    }
262}