use std::process::ExitCode;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub rule: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
pub severity: Severity,
}
#[derive(Debug, Clone)]
pub enum Verdict {
Pass,
Warn {
findings: Vec<Finding>,
message: Option<String>,
},
Fail {
findings: Vec<Finding>,
message: String,
},
}
impl Verdict {
pub fn is_blocking(&self) -> bool {
matches!(self, Verdict::Fail { .. })
}
pub fn exit_code(&self) -> ExitCode {
if self.is_blocking() {
ExitCode::from(2)
} else {
ExitCode::SUCCESS
}
}
pub fn merge(verdicts: Vec<Verdict>, policy: VerdictPolicy) -> Verdict {
match policy {
VerdictPolicy::AnyFail => merge_any_fail(verdicts),
}
}
}
fn merge_any_fail(verdicts: Vec<Verdict>) -> Verdict {
let mut fail_findings: Vec<Finding> = Vec::new();
let mut fail_messages: Vec<String> = Vec::new();
let mut warn_findings: Vec<Finding> = Vec::new();
let mut warn_messages: Vec<String> = Vec::new();
for verdict in verdicts {
match verdict {
Verdict::Pass => {}
Verdict::Warn { findings, message } => {
warn_findings.extend(findings);
if let Some(m) = message {
warn_messages.push(m);
}
}
Verdict::Fail { findings, message } => {
fail_findings.extend(findings);
fail_messages.push(message);
}
}
}
if !fail_messages.is_empty() {
Verdict::Fail {
findings: fail_findings,
message: fail_messages.join("\n"),
}
} else if !warn_findings.is_empty() || !warn_messages.is_empty() {
Verdict::Warn {
findings: warn_findings,
message: if warn_messages.is_empty() {
None
} else {
Some(warn_messages.join("\n"))
},
}
} else {
Verdict::Pass
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum VerdictPolicy {
#[default]
AnyFail,
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(rule: &str, severity: Severity) -> Finding {
Finding {
rule: rule.into(),
message: "msg".into(),
file: None,
line: None,
severity,
}
}
#[test]
fn pass_is_not_blocking() {
assert!(!Verdict::Pass.is_blocking());
}
#[test]
fn warn_is_not_blocking() {
let v = Verdict::Warn {
findings: vec![finding("r", Severity::Warn)],
message: None,
};
assert!(!v.is_blocking());
}
#[test]
fn fail_is_blocking() {
let v = Verdict::Fail {
findings: vec![],
message: "boom".into(),
};
assert!(v.is_blocking());
}
#[test]
fn merge_empty_is_pass() {
let v = Verdict::merge(vec![], VerdictPolicy::AnyFail);
assert!(matches!(v, Verdict::Pass));
}
#[test]
fn merge_all_pass_is_pass() {
let v = Verdict::merge(
vec![Verdict::Pass, Verdict::Pass, Verdict::Pass],
VerdictPolicy::AnyFail,
);
assert!(matches!(v, Verdict::Pass));
}
#[test]
fn merge_warn_among_pass_is_warn() {
let v = Verdict::merge(
vec![
Verdict::Pass,
Verdict::Warn {
findings: vec![finding("a", Severity::Warn)],
message: Some("notice".into()),
},
Verdict::Pass,
],
VerdictPolicy::AnyFail,
);
match v {
Verdict::Warn { findings, message } => {
assert_eq!(findings.len(), 1);
assert_eq!(message.as_deref(), Some("notice"));
}
other => panic!("expected Warn, got {other:?}"),
}
}
#[test]
fn merge_any_fail_is_fail() {
let v = Verdict::merge(
vec![
Verdict::Pass,
Verdict::Warn {
findings: vec![finding("w", Severity::Warn)],
message: None,
},
Verdict::Fail {
findings: vec![finding("f", Severity::Error)],
message: "broken".into(),
},
],
VerdictPolicy::AnyFail,
);
match v {
Verdict::Fail { findings, message } => {
assert_eq!(findings.len(), 1);
assert_eq!(message, "broken");
}
other => panic!("expected Fail, got {other:?}"),
}
}
}