#![forbid(unsafe_code)]
#![deny(missing_docs)]
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Severity {
Blocking,
Advisory,
}
impl Severity {
pub fn is_blocking(self) -> bool {
matches!(self, Severity::Blocking)
}
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Severity::Blocking => f.write_str("blocking"),
Severity::Advisory => f.write_str("advisory"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Pass,
Fail(String),
Unevaluable(String),
}
impl Outcome {
pub fn was_evaluated(&self) -> bool {
!matches!(self, Outcome::Unevaluable(_))
}
pub fn passed(&self) -> bool {
matches!(self, Outcome::Pass)
}
pub fn reason(&self) -> Option<&str> {
match self {
Outcome::Pass => None,
Outcome::Fail(r) | Outcome::Unevaluable(r) => Some(r.as_str()),
}
}
}
impl fmt::Display for Outcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Outcome::Pass => f.write_str("PASS"),
Outcome::Fail(r) => write!(f, "FAIL: {r}"),
Outcome::Unevaluable(r) => write!(f, "UNEVALUABLE: {r}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HaltCause {
BlockingFailure,
BlockingUnevaluable,
}
impl fmt::Display for HaltCause {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HaltCause::BlockingFailure => f.write_str("blocking gate failed"),
HaltCause::BlockingUnevaluable => f.write_str("blocking gate could not be evaluated"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Proceed,
Halt {
gate_id: String,
cause: HaltCause,
reason: String,
},
}
impl Verdict {
pub fn is_halt(&self) -> bool {
matches!(self, Verdict::Halt { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Gate {
pub id: String,
pub severity: Severity,
pub outcome: Outcome,
}
impl Gate {
pub fn new(id: impl Into<String>, outcome: Outcome) -> Self {
Gate {
id: id.into(),
severity: Severity::Blocking,
outcome,
}
}
pub fn pass(id: impl Into<String>) -> Self {
Gate::new(id, Outcome::Pass)
}
pub fn fail(id: impl Into<String>, reason: impl Into<String>) -> Self {
Gate::new(id, Outcome::Fail(reason.into()))
}
pub fn unevaluable(id: impl Into<String>, reason: impl Into<String>) -> Self {
Gate::new(id, Outcome::Unevaluable(reason.into()))
}
pub fn advisory(mut self) -> Self {
self.severity = Severity::Advisory;
self
}
pub fn check(id: impl Into<String>, held: bool, reason: impl Into<String>) -> Self {
if held {
Gate::pass(id)
} else {
Gate::fail(id, reason)
}
}
pub fn check_known<T, F>(
id: impl Into<String>,
value: &Known<T>,
predicate: F,
reason: impl Into<String>,
) -> Self
where
F: FnOnce(&T) -> bool,
{
match value {
Known::Unknown => Gate::unevaluable(id, "value is unknown"),
Known::Known(v) => Gate::check(id, predicate(v), reason),
}
}
pub fn halts(&self) -> bool {
self.severity.is_blocking() && !self.outcome.passed()
}
fn halt_cause(&self) -> Option<HaltCause> {
if !self.halts() {
return None;
}
match self.outcome {
Outcome::Fail(_) => Some(HaltCause::BlockingFailure),
Outcome::Unevaluable(_) => Some(HaltCause::BlockingUnevaluable),
Outcome::Pass => None,
}
}
}
impl fmt::Display for Gate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {} {}", self.severity, self.id, self.outcome)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Known<T> {
Known(T),
#[default]
Unknown,
}
impl<T> Known<T> {
pub fn is_known(&self) -> bool {
matches!(self, Known::Known(_))
}
pub fn get(&self) -> Option<&T> {
match self {
Known::Known(v) => Some(v),
Known::Unknown => None,
}
}
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Known<U> {
match self {
Known::Known(v) => Known::Known(f(v)),
Known::Unknown => Known::Unknown,
}
}
pub fn zip_with<U, R, F: FnOnce(T, U) -> R>(self, other: Known<U>, f: F) -> Known<R> {
match (self, other) {
(Known::Known(a), Known::Known(b)) => Known::Known(f(a, b)),
_ => Known::Unknown,
}
}
}
impl<T> From<Option<T>> for Known<T> {
fn from(opt: Option<T>) -> Self {
match opt {
Some(v) => Known::Known(v),
None => Known::Unknown,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Stage {
pub name: String,
pub gates: Vec<Gate>,
}
impl Stage {
pub fn new(name: impl Into<String>) -> Self {
Stage {
name: name.into(),
gates: Vec::new(),
}
}
pub fn with_gate(mut self, gate: Gate) -> Self {
self.gates.push(gate);
self
}
pub fn push(&mut self, gate: Gate) {
self.gates.push(gate);
}
pub fn evaluate(&self) -> StageReport {
let mut counts = GateCounts::default();
let mut verdict = Verdict::Proceed;
for gate in &self.gates {
counts.total += 1;
if gate.severity.is_blocking() {
counts.blocking += 1;
}
match gate.outcome {
Outcome::Pass => counts.passed += 1,
Outcome::Fail(_) => counts.failed += 1,
Outcome::Unevaluable(_) => counts.unevaluable += 1,
}
if verdict == Verdict::Proceed {
if let Some(cause) = gate.halt_cause() {
verdict = Verdict::Halt {
gate_id: gate.id.clone(),
cause,
reason: gate.outcome.reason().unwrap_or_default().to_string(),
};
}
}
}
StageReport {
stage: self.name.clone(),
counts,
verdict,
gates: self.gates.clone(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct GateCounts {
pub total: usize,
pub blocking: usize,
pub passed: usize,
pub failed: usize,
pub unevaluable: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StageReport {
pub stage: String,
pub counts: GateCounts,
pub verdict: Verdict,
pub gates: Vec<Gate>,
}
impl StageReport {
pub fn halted(&self) -> bool {
self.verdict.is_halt()
}
pub fn warnings(&self) -> Vec<&Gate> {
self.gates
.iter()
.filter(|g| !g.severity.is_blocking() && !g.outcome.passed())
.collect()
}
pub fn not_passing(&self) -> Vec<&Gate> {
self.gates.iter().filter(|g| !g.outcome.passed()).collect()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Pipeline {
pub stages: Vec<Stage>,
}
impl Pipeline {
pub fn new() -> Self {
Pipeline { stages: Vec::new() }
}
pub fn with_stage(mut self, stage: Stage) -> Self {
self.stages.push(stage);
self
}
pub fn run(&self) -> RunReport {
let mut reports = Vec::with_capacity(self.stages.len());
for stage in &self.stages {
let report = stage.evaluate();
let halted = report.halted();
reports.push(report);
if halted {
break;
}
}
RunReport {
reports,
stages_declared: self.stages.len(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunReport {
pub reports: Vec<StageReport>,
pub stages_declared: usize,
}
impl RunReport {
pub fn halted(&self) -> bool {
self.reports.last().map(|r| r.halted()).unwrap_or(false)
}
pub fn halted_stage(&self) -> Option<&str> {
self.reports
.last()
.filter(|r| r.halted())
.map(|r| r.stage.as_str())
}
pub fn stages_skipped(&self) -> usize {
self.stages_declared - self.reports.len()
}
pub fn counts(&self) -> GateCounts {
let mut total = GateCounts::default();
for r in &self.reports {
total.total += r.counts.total;
total.blocking += r.counts.blocking;
total.passed += r.counts.passed;
total.failed += r.counts.failed;
total.unevaluable += r.counts.unevaluable;
}
total
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_passing_blocking_gate_proceeds() {
let report = Stage::new("intake").with_gate(Gate::pass("scope")).evaluate();
assert_eq!(report.verdict, Verdict::Proceed);
assert!(!report.halted());
assert_eq!(report.counts.passed, 1);
}
#[test]
fn a_failing_blocking_gate_halts() {
let report = Stage::new("build")
.with_gate(Gate::fail("pages-nonzero", "zero pages"))
.evaluate();
match report.verdict {
Verdict::Halt {
gate_id,
cause,
reason,
} => {
assert_eq!(gate_id, "pages-nonzero");
assert_eq!(cause, HaltCause::BlockingFailure);
assert_eq!(reason, "zero pages");
}
Verdict::Proceed => panic!("should have halted"),
}
}
#[test]
fn an_unevaluable_blocking_gate_also_halts() {
let report = Stage::new("harvest")
.with_gate(Gate::unevaluable("rdap", "no credential"))
.evaluate();
assert!(report.halted());
assert!(matches!(
report.verdict,
Verdict::Halt {
cause: HaltCause::BlockingUnevaluable,
..
}
));
assert_eq!(report.counts.unevaluable, 1);
}
#[test]
fn advisory_gates_never_halt_but_are_recorded() {
let report = Stage::new("design")
.with_gate(Gate::fail("tone", "flagged").advisory())
.with_gate(Gate::unevaluable("readability", "no corpus").advisory())
.with_gate(Gate::pass("prompt-vendored"))
.evaluate();
assert!(!report.halted());
assert_eq!(report.warnings().len(), 2);
assert_eq!(report.not_passing().len(), 2);
assert_eq!(report.counts.blocking, 1);
assert_eq!(report.counts.total, 3);
}
#[test]
fn the_first_halting_gate_is_the_one_reported() {
let report = Stage::new("qualify")
.with_gate(Gate::pass("a"))
.with_gate(Gate::fail("b", "first"))
.with_gate(Gate::unevaluable("c", "second"))
.evaluate();
match report.verdict {
Verdict::Halt { gate_id, .. } => assert_eq!(gate_id, "b"),
Verdict::Proceed => panic!("should have halted"),
}
assert_eq!(report.counts.failed, 1);
assert_eq!(report.counts.unevaluable, 1);
}
#[test]
fn check_builds_pass_or_fail_from_a_boolean() {
assert!(Gate::check("g", true, "r").outcome.passed());
assert_eq!(
Gate::check("g", false, "r").outcome,
Outcome::Fail("r".into())
);
}
#[test]
fn unknown_input_makes_a_gate_unevaluable_not_passing() {
let unknown: Known<u32> = Known::Unknown;
let gate = Gate::check_known("coverage", &unknown, |c| *c >= 50, "below floor");
assert!(!gate.outcome.was_evaluated());
assert!(gate.halts());
let known = Known::Known(60u32);
let gate = Gate::check_known("coverage", &known, |c| *c >= 50, "below floor");
assert!(gate.outcome.passed());
assert!(!gate.halts());
}
#[test]
fn unknown_propagates_through_combination() {
let a = Known::Known(10);
let b: Known<i32> = Known::Unknown;
assert_eq!(a.zip_with(b, |x, y| x + y), Known::Unknown);
assert_eq!(
Known::Known(10).zip_with(Known::Known(5), |x, y| x + y),
Known::Known(15)
);
assert_eq!(b.map(|v| v * 2), Known::Unknown);
assert_eq!(Known::from(Some(3)), Known::Known(3));
assert_eq!(Known::<i32>::from(None), Known::Unknown);
assert_eq!(Known::<i32>::default(), Known::Unknown);
assert_eq!(Known::Known(7).get(), Some(&7));
assert!(!b.is_known());
}
#[test]
fn pipeline_stops_at_the_first_halting_stage() {
let run = Pipeline::new()
.with_stage(Stage::new("intake").with_gate(Gate::pass("scope")))
.with_stage(Stage::new("harvest").with_gate(Gate::unevaluable("api", "no key")))
.with_stage(Stage::new("build").with_gate(Gate::pass("unreached")))
.run();
assert!(run.halted());
assert_eq!(run.halted_stage(), Some("harvest"));
assert_eq!(run.reports.len(), 2);
assert_eq!(run.stages_skipped(), 1);
assert_eq!(run.counts().total, 2);
}
#[test]
fn a_clean_pipeline_proceeds_through_every_stage() {
let run = Pipeline::new()
.with_stage(Stage::new("one").with_gate(Gate::pass("a")))
.with_stage(Stage::new("two").with_gate(Gate::pass("b")))
.run();
assert!(!run.halted());
assert_eq!(run.halted_stage(), None);
assert_eq!(run.stages_skipped(), 0);
assert_eq!(run.counts().passed, 2);
}
#[test]
fn an_empty_pipeline_does_not_claim_to_have_halted() {
let run = Pipeline::new().run();
assert!(!run.halted());
assert_eq!(run.counts(), GateCounts::default());
}
#[test]
fn a_stage_can_be_built_imperatively() {
let mut stage = Stage::new("measure");
stage.push(Gate::pass("gsc-rows"));
stage.push(Gate::fail("indexed", "0 of 14").advisory());
let report = stage.evaluate();
assert_eq!(report.counts.total, 2);
assert!(!report.halted());
}
#[test]
fn display_is_readable() {
assert_eq!(Outcome::Pass.to_string(), "PASS");
assert_eq!(
Gate::fail("x", "because").to_string(),
"[blocking] x FAIL: because"
);
assert_eq!(
HaltCause::BlockingUnevaluable.to_string(),
"blocking gate could not be evaluated"
);
}
#[test]
fn outcome_reasons_are_retrievable() {
assert_eq!(Outcome::Pass.reason(), None);
assert_eq!(Outcome::Fail("r".into()).reason(), Some("r"));
assert_eq!(Outcome::Unevaluable("u".into()).reason(), Some("u"));
assert!(Severity::Blocking.is_blocking());
assert!(!Severity::Advisory.is_blocking());
}
}