Skip to main content

scute_core/
report.rs

1use crate::{Evaluation, ExecutionError, Outcome, Status};
2
3/// Aggregated result of running a check.
4///
5/// Wraps the raw check output (`Result<Vec<Evaluation>, ExecutionError>`)
6/// with summary counts and the check name.
7///
8/// ```
9/// use scute_core::report::CheckReport;
10/// use scute_core::{Evaluation, Thresholds};
11///
12/// let evals = vec![Evaluation::completed(
13///     "feat: add login",
14///     0,
15///     Thresholds { warn: None, fail: Some(0) },
16///     vec![],
17/// )];
18/// let report = CheckReport::new("commit-message", Ok(evals));
19/// assert!(!report.has_failures());
20/// ```
21pub struct CheckReport {
22    pub check: String,
23    pub result: Result<CheckRun, ExecutionError>,
24}
25
26/// Successful check execution with summary and all evaluations.
27#[derive(Debug)]
28pub struct CheckRun {
29    pub summary: Summary,
30    pub evaluations: Vec<Evaluation>,
31}
32
33impl CheckRun {
34    /// Evaluations that did not pass (warnings, failures, errors).
35    #[must_use]
36    pub fn non_passing_evaluations(&self) -> Vec<&Evaluation> {
37        self.evaluations.iter().filter(|e| !e.is_pass()).collect()
38    }
39}
40
41/// Counts of evaluation outcomes.
42#[derive(Debug, Default)]
43pub struct Summary {
44    pub evaluated: u64,
45    pub passed: u64,
46    pub warned: u64,
47    pub failed: u64,
48    pub errored: u64,
49}
50
51impl Summary {
52    fn tally(mut self, eval: &Evaluation) -> Self {
53        self.evaluated += 1;
54        match &eval.outcome {
55            Outcome::Completed { status, .. } => match status {
56                Status::Pass => self.passed += 1,
57                Status::Warn => self.warned += 1,
58                Status::Fail => self.failed += 1,
59            },
60            Outcome::Errored(_) => self.errored += 1,
61        }
62        self
63    }
64}
65
66impl CheckReport {
67    /// Create a report from raw check output, computing the [`Summary`].
68    #[must_use]
69    pub fn new(check_name: &str, result: Result<Vec<Evaluation>, ExecutionError>) -> Self {
70        Self {
71            check: check_name.into(),
72            result: result.map(|evals| {
73                let summary = summarize(&evals);
74                CheckRun {
75                    summary,
76                    evaluations: evals,
77                }
78            }),
79        }
80    }
81
82    /// True when any evaluation resolved to [`Status::Fail`].
83    #[must_use]
84    pub fn has_failures(&self) -> bool {
85        self.result.as_ref().is_ok_and(|run| run.summary.failed > 0)
86    }
87
88    /// True when the check itself failed to run, or any evaluation errored.
89    #[must_use]
90    pub fn has_errors(&self) -> bool {
91        self.result.is_err()
92            || self
93                .result
94                .as_ref()
95                .is_ok_and(|run| run.summary.errored > 0)
96    }
97}
98
99fn summarize(evaluations: &[Evaluation]) -> Summary {
100    evaluations.iter().fold(Summary::default(), Summary::tally)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::{Evidence, Thresholds};
107
108    #[test]
109    fn empty_evaluations_produces_zero_summary() {
110        let report = CheckReport::new("test-check", Ok(vec![]));
111
112        let run = report.result.as_ref().unwrap();
113        assert_eq!(run.summary.evaluated, 0);
114        assert_eq!(run.summary.passed, 0);
115        assert!(!report.has_failures());
116        assert!(!report.has_errors());
117    }
118
119    #[test]
120    fn summary_counts_match_evaluations() {
121        let evals = vec![
122            passing_eval("a"),
123            failing_eval("b"),
124            warned_eval("c"),
125            errored_eval("d"),
126        ];
127
128        let report = CheckReport::new("test-check", Ok(evals));
129
130        let run = report.result.as_ref().unwrap();
131        assert_eq!(run.summary.evaluated, 4);
132        assert_eq!(run.summary.passed, 1);
133        assert_eq!(run.summary.failed, 1);
134        assert_eq!(run.summary.warned, 1);
135        assert_eq!(run.summary.errored, 1);
136    }
137
138    #[test]
139    fn check_level_error_has_no_summary() {
140        let err = ExecutionError {
141            code: "missing_tool".into(),
142            message: "not installed".into(),
143            recovery: "install it".into(),
144        };
145
146        let report = CheckReport::new("test-check", Err(err));
147
148        assert!(report.result.is_err());
149        assert_eq!(report.result.unwrap_err().code, "missing_tool");
150    }
151
152    #[test]
153    fn has_failures_true_when_any_fail() {
154        let evals = vec![passing_eval("a"), failing_eval("b")];
155
156        let report = CheckReport::new("test-check", Ok(evals));
157
158        assert!(report.has_failures());
159    }
160
161    #[test]
162    fn has_failures_false_when_all_pass() {
163        let report = CheckReport::new("test-check", Ok(vec![passing_eval("a")]));
164
165        assert!(!report.has_failures());
166    }
167
168    #[test]
169    fn has_errors_true_for_check_level_error() {
170        let err = ExecutionError {
171            code: "boom".into(),
172            message: "it broke".into(),
173            recovery: "fix it".into(),
174        };
175
176        let report = CheckReport::new("test-check", Err(err));
177
178        assert!(report.has_errors());
179    }
180
181    #[test]
182    fn has_errors_true_for_errored_evaluation() {
183        let evals = vec![Evaluation::errored(
184            "x",
185            ExecutionError {
186                code: "eval_err".into(),
187                message: "bad".into(),
188                recovery: "retry".into(),
189            },
190        )];
191
192        let report = CheckReport::new("test-check", Ok(evals));
193
194        assert!(report.has_errors());
195    }
196
197    #[test]
198    fn preserves_all_evaluations_in_run() {
199        let evals = vec![passing_eval("a"), failing_eval("b"), passing_eval("c")];
200
201        let report = CheckReport::new("test-check", Ok(evals));
202
203        let run = report.result.unwrap();
204        assert_eq!(run.evaluations.len(), 3);
205        assert_eq!(run.evaluations[0].target, "a");
206        assert_eq!(run.evaluations[1].target, "b");
207        assert_eq!(run.evaluations[2].target, "c");
208    }
209
210    #[test]
211    fn non_passing_evaluations_excludes_passing() {
212        let run = run_with(vec![passing_eval("a")]);
213
214        assert!(run.non_passing_evaluations().is_empty());
215    }
216
217    #[test]
218    fn non_passing_evaluations_includes_warned() {
219        let run = run_with(vec![warned_eval("a")]);
220
221        assert_eq!(run.non_passing_evaluations().len(), 1);
222    }
223
224    #[test]
225    fn non_passing_evaluations_includes_failed() {
226        let run = run_with(vec![failing_eval("a")]);
227
228        assert_eq!(run.non_passing_evaluations().len(), 1);
229    }
230
231    #[test]
232    fn non_passing_evaluations_includes_errored() {
233        let run = run_with(vec![errored_eval("a")]);
234
235        assert_eq!(run.non_passing_evaluations().len(), 1);
236    }
237
238    fn run_with(evals: Vec<Evaluation>) -> CheckRun {
239        CheckReport::new("test-check", Ok(evals)).result.unwrap()
240    }
241
242    fn passing_eval(target: &str) -> Evaluation {
243        Evaluation::completed(
244            target,
245            0,
246            Thresholds {
247                warn: None,
248                fail: Some(0),
249            },
250            vec![],
251        )
252    }
253
254    fn warned_eval(target: &str) -> Evaluation {
255        Evaluation::completed(
256            target,
257            3,
258            Thresholds {
259                warn: Some(2),
260                fail: Some(5),
261            },
262            vec![],
263        )
264    }
265
266    fn failing_eval(target: &str) -> Evaluation {
267        Evaluation::completed(
268            target,
269            1,
270            Thresholds {
271                warn: None,
272                fail: Some(0),
273            },
274            vec![Evidence::new("violation", "found")],
275        )
276    }
277
278    fn errored_eval(target: &str) -> Evaluation {
279        Evaluation::errored(
280            target,
281            ExecutionError {
282                code: "boom".into(),
283                message: "broke".into(),
284                recovery: "fix".into(),
285            },
286        )
287    }
288}