1use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::{CheckResult, Report, Verdict};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct MultiReport {
40 pub schema_version: u32,
42 pub subject: String,
44 pub subject_version: String,
46 pub started_at: DateTime<Utc>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub finished_at: Option<DateTime<Utc>>,
51 pub reports: Vec<Report>,
53}
54
55impl MultiReport {
56 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 pub fn push(&mut self, r: Report) {
70 self.reports.push(r);
71 }
72
73 pub fn finish(&mut self) {
75 self.finished_at = Some(Utc::now());
76 }
77
78 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 pub fn total_check_count(&self) -> usize {
109 self.reports.iter().map(|r| r.checks.len()).sum()
110 }
111
112 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 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 pub fn to_json(&self) -> serde_json::Result<String> {
150 serde_json::to_string_pretty(self)
151 }
152
153 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 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}