use std::collections::BTreeMap;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum GateName {
ErrorSeverityFindings,
Regression,
StaleBaseline,
DuplicationThreshold,
HealthMinScore,
HealthMinSeverity,
HealthFindings,
HealthCoverageGaps,
HealthRuntimeCoverage,
Security,
SecurityAdvisory,
AuditVerdict,
TypeAwareRequire,
}
impl GateName {
pub const ALL: [Self; 13] = [
Self::ErrorSeverityFindings,
Self::Regression,
Self::StaleBaseline,
Self::DuplicationThreshold,
Self::HealthMinScore,
Self::HealthMinSeverity,
Self::HealthFindings,
Self::HealthCoverageGaps,
Self::HealthRuntimeCoverage,
Self::Security,
Self::SecurityAdvisory,
Self::AuditVerdict,
Self::TypeAwareRequire,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ErrorSeverityFindings => "error-severity-findings",
Self::Regression => "regression",
Self::StaleBaseline => "stale-baseline",
Self::DuplicationThreshold => "duplication-threshold",
Self::HealthMinScore => "health-min-score",
Self::HealthMinSeverity => "health-min-severity",
Self::HealthFindings => "health-findings",
Self::HealthCoverageGaps => "health-coverage-gaps",
Self::HealthRuntimeCoverage => "health-runtime-coverage",
Self::Security => "security",
Self::SecurityAdvisory => "security-advisory",
Self::AuditVerdict => "audit-verdict",
Self::TypeAwareRequire => "type-aware-require",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum GateStatus {
Pass,
Warn,
Fail,
Skipped,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct GateOutcome {
pub status: GateStatus,
pub enforced: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_label: Option<String>,
}
impl GateOutcome {
#[must_use]
pub const fn new(status: GateStatus, enforced: bool) -> Self {
Self {
status,
enforced,
observed: None,
threshold: None,
threshold_label: None,
}
}
#[must_use]
pub const fn measured(
status: GateStatus,
enforced: bool,
observed: f64,
threshold: f64,
) -> Self {
Self {
status,
enforced,
observed: Some(observed),
threshold: Some(threshold),
threshold_label: None,
}
}
#[must_use]
pub fn counted(
status: GateStatus,
enforced: bool,
observed: f64,
threshold_label: &str,
) -> Self {
Self {
status,
enforced,
observed: Some(observed),
threshold: None,
threshold_label: Some(threshold_label.to_owned()),
}
}
#[must_use]
pub const fn fails_run(&self) -> bool {
self.enforced && matches!(self.status, GateStatus::Fail)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(transparent)]
pub struct GateOutcomes(BTreeMap<GateName, GateOutcome>);
impl GateOutcomes {
#[must_use]
pub fn new() -> Self {
Self(BTreeMap::new())
}
pub fn insert(&mut self, name: GateName, outcome: GateOutcome) {
self.0.insert(name, outcome);
}
pub fn insert_if(&mut self, name: GateName, outcome: Option<GateOutcome>) {
if let Some(outcome) = outcome {
self.0.insert(name, outcome);
}
}
#[must_use]
pub fn get(&self, name: GateName) -> Option<&GateOutcome> {
self.0.get(&name)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn any_fails_run(&self) -> bool {
self.0.values().any(GateOutcome::fails_run)
}
#[must_use]
pub fn into_option(self) -> Option<Self> {
if self.0.is_empty() { None } else { Some(self) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_set_collapses_to_absent() {
assert!(GateOutcomes::new().into_option().is_none());
}
#[test]
fn populated_set_survives_collapse() {
let mut gates = GateOutcomes::new();
gates.insert(
GateName::Regression,
GateOutcome::new(GateStatus::Fail, true),
);
assert!(gates.into_option().is_some());
}
#[test]
fn every_name_in_all_is_distinct_and_spelled_as_serde_spells_it() {
let mut names = GateName::ALL.map(GateName::as_str).to_vec();
let total = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), total, "a duplicated entry hides one variant");
for name in GateName::ALL {
assert_eq!(
serde_json::to_value(name).expect("name serializes"),
serde_json::json!(name.as_str())
);
}
}
#[test]
fn names_serialize_as_kebab_case() {
let mut gates = GateOutcomes::new();
gates.insert(
GateName::ErrorSeverityFindings,
GateOutcome::new(GateStatus::Pass, true),
);
gates.insert(
GateName::HealthMinScore,
GateOutcome::measured(GateStatus::Fail, true, 85.0, 90.0),
);
let value = serde_json::to_value(&gates).expect("gate outcomes serialize");
assert_eq!(
value,
serde_json::json!({
"error-severity-findings": { "status": "pass", "enforced": true },
"health-min-score": {
"status": "fail",
"enforced": true,
"observed": 85.0,
"threshold": 90.0
}
})
);
}
#[test]
fn an_unenforced_failure_does_not_fail_the_run() {
let outcome = GateOutcome::new(GateStatus::Fail, false);
assert!(!outcome.fails_run());
let enforced = GateOutcome::new(GateStatus::Fail, true);
assert!(enforced.fails_run());
}
#[test]
fn a_skipped_gate_never_fails_the_run() {
assert!(!GateOutcome::new(GateStatus::Skipped, true).fails_run());
assert!(!GateOutcome::new(GateStatus::Warn, true).fails_run());
}
}