1use std::process::ExitCode;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum Severity {
18 Info,
19 Warn,
20 Error,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Finding {
27 pub rule: String,
28 pub message: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub file: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub line: Option<u32>,
37 pub severity: Severity,
38}
39
40#[derive(Debug, Clone)]
43pub enum Verdict {
44 Pass,
45 Warn {
46 findings: Vec<Finding>,
47 message: Option<String>,
48 },
49 Fail {
50 findings: Vec<Finding>,
51 message: String,
52 },
53}
54
55impl Verdict {
56 pub fn is_blocking(&self) -> bool {
59 matches!(self, Verdict::Fail { .. })
60 }
61
62 pub fn exit_code(&self) -> ExitCode {
65 if self.is_blocking() {
66 ExitCode::from(2)
67 } else {
68 ExitCode::SUCCESS
69 }
70 }
71
72 pub fn merge(verdicts: Vec<Verdict>, policy: VerdictPolicy) -> Verdict {
75 match policy {
76 VerdictPolicy::AnyFail => merge_any_fail(verdicts),
77 }
78 }
79}
80
81fn merge_any_fail(verdicts: Vec<Verdict>) -> Verdict {
82 let mut fail_findings: Vec<Finding> = Vec::new();
83 let mut fail_messages: Vec<String> = Vec::new();
84 let mut warn_findings: Vec<Finding> = Vec::new();
85 let mut warn_messages: Vec<String> = Vec::new();
86
87 for verdict in verdicts {
88 match verdict {
89 Verdict::Pass => {}
90 Verdict::Warn { findings, message } => {
91 warn_findings.extend(findings);
92 if let Some(m) = message {
93 warn_messages.push(m);
94 }
95 }
96 Verdict::Fail { findings, message } => {
97 fail_findings.extend(findings);
98 fail_messages.push(message);
99 }
100 }
101 }
102
103 if !fail_messages.is_empty() {
104 Verdict::Fail {
105 findings: fail_findings,
106 message: fail_messages.join("\n"),
107 }
108 } else if !warn_findings.is_empty() || !warn_messages.is_empty() {
109 Verdict::Warn {
110 findings: warn_findings,
111 message: if warn_messages.is_empty() {
112 None
113 } else {
114 Some(warn_messages.join("\n"))
115 },
116 }
117 } else {
118 Verdict::Pass
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
127#[serde(rename_all = "snake_case")]
128pub enum VerdictPolicy {
129 #[default]
130 AnyFail,
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 fn finding(rule: &str, severity: Severity) -> Finding {
138 Finding {
139 rule: rule.into(),
140 message: "msg".into(),
141 file: None,
142 line: None,
143 severity,
144 }
145 }
146
147 #[test]
148 fn pass_is_not_blocking() {
149 assert!(!Verdict::Pass.is_blocking());
150 }
151
152 #[test]
153 fn warn_is_not_blocking() {
154 let v = Verdict::Warn {
155 findings: vec![finding("r", Severity::Warn)],
156 message: None,
157 };
158 assert!(!v.is_blocking());
159 }
160
161 #[test]
162 fn fail_is_blocking() {
163 let v = Verdict::Fail {
164 findings: vec![],
165 message: "boom".into(),
166 };
167 assert!(v.is_blocking());
168 }
169
170 #[test]
171 fn merge_empty_is_pass() {
172 let v = Verdict::merge(vec![], VerdictPolicy::AnyFail);
173 assert!(matches!(v, Verdict::Pass));
174 }
175
176 #[test]
177 fn merge_all_pass_is_pass() {
178 let v = Verdict::merge(
179 vec![Verdict::Pass, Verdict::Pass, Verdict::Pass],
180 VerdictPolicy::AnyFail,
181 );
182 assert!(matches!(v, Verdict::Pass));
183 }
184
185 #[test]
186 fn merge_warn_among_pass_is_warn() {
187 let v = Verdict::merge(
188 vec![
189 Verdict::Pass,
190 Verdict::Warn {
191 findings: vec![finding("a", Severity::Warn)],
192 message: Some("notice".into()),
193 },
194 Verdict::Pass,
195 ],
196 VerdictPolicy::AnyFail,
197 );
198 match v {
199 Verdict::Warn { findings, message } => {
200 assert_eq!(findings.len(), 1);
201 assert_eq!(message.as_deref(), Some("notice"));
202 }
203 other => panic!("expected Warn, got {other:?}"),
204 }
205 }
206
207 #[test]
208 fn merge_any_fail_is_fail() {
209 let v = Verdict::merge(
210 vec![
211 Verdict::Pass,
212 Verdict::Warn {
213 findings: vec![finding("w", Severity::Warn)],
214 message: None,
215 },
216 Verdict::Fail {
217 findings: vec![finding("f", Severity::Error)],
218 message: "broken".into(),
219 },
220 ],
221 VerdictPolicy::AnyFail,
222 );
223 match v {
224 Verdict::Fail { findings, message } => {
225 assert_eq!(findings.len(), 1);
226 assert_eq!(message, "broken");
227 }
228 other => panic!("expected Fail, got {other:?}"),
229 }
230 }
231}