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}