1#![forbid(unsafe_code)]
57#![deny(missing_docs)]
58
59use std::fmt;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum Severity {
64 Blocking,
66 Advisory,
68}
69
70impl Severity {
71 pub fn is_blocking(self) -> bool {
73 matches!(self, Severity::Blocking)
74 }
75}
76
77impl fmt::Display for Severity {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 match self {
80 Severity::Blocking => f.write_str("blocking"),
81 Severity::Advisory => f.write_str("advisory"),
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum Outcome {
89 Pass,
91 Fail(String),
93 Unevaluable(String),
95}
96
97impl Outcome {
98 pub fn was_evaluated(&self) -> bool {
100 !matches!(self, Outcome::Unevaluable(_))
101 }
102
103 pub fn passed(&self) -> bool {
105 matches!(self, Outcome::Pass)
106 }
107
108 pub fn reason(&self) -> Option<&str> {
110 match self {
111 Outcome::Pass => None,
112 Outcome::Fail(r) | Outcome::Unevaluable(r) => Some(r.as_str()),
113 }
114 }
115}
116
117impl fmt::Display for Outcome {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 match self {
120 Outcome::Pass => f.write_str("PASS"),
121 Outcome::Fail(r) => write!(f, "FAIL: {r}"),
122 Outcome::Unevaluable(r) => write!(f, "UNEVALUABLE: {r}"),
123 }
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum HaltCause {
130 BlockingFailure,
132 BlockingUnevaluable,
134}
135
136impl fmt::Display for HaltCause {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 match self {
139 HaltCause::BlockingFailure => f.write_str("blocking gate failed"),
140 HaltCause::BlockingUnevaluable => f.write_str("blocking gate could not be evaluated"),
141 }
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum Verdict {
148 Proceed,
150 Halt {
152 gate_id: String,
154 cause: HaltCause,
156 reason: String,
158 },
159}
160
161impl Verdict {
162 pub fn is_halt(&self) -> bool {
164 matches!(self, Verdict::Halt { .. })
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Gate {
171 pub id: String,
173 pub severity: Severity,
175 pub outcome: Outcome,
177}
178
179impl Gate {
180 pub fn new(id: impl Into<String>, outcome: Outcome) -> Self {
182 Gate {
183 id: id.into(),
184 severity: Severity::Blocking,
185 outcome,
186 }
187 }
188
189 pub fn pass(id: impl Into<String>) -> Self {
191 Gate::new(id, Outcome::Pass)
192 }
193
194 pub fn fail(id: impl Into<String>, reason: impl Into<String>) -> Self {
196 Gate::new(id, Outcome::Fail(reason.into()))
197 }
198
199 pub fn unevaluable(id: impl Into<String>, reason: impl Into<String>) -> Self {
201 Gate::new(id, Outcome::Unevaluable(reason.into()))
202 }
203
204 pub fn advisory(mut self) -> Self {
206 self.severity = Severity::Advisory;
207 self
208 }
209
210 pub fn check(id: impl Into<String>, held: bool, reason: impl Into<String>) -> Self {
218 if held {
219 Gate::pass(id)
220 } else {
221 Gate::fail(id, reason)
222 }
223 }
224
225 pub fn check_known<T, F>(
239 id: impl Into<String>,
240 value: &Known<T>,
241 predicate: F,
242 reason: impl Into<String>,
243 ) -> Self
244 where
245 F: FnOnce(&T) -> bool,
246 {
247 match value {
248 Known::Unknown => Gate::unevaluable(id, "value is unknown"),
249 Known::Known(v) => Gate::check(id, predicate(v), reason),
250 }
251 }
252
253 pub fn halts(&self) -> bool {
255 self.severity.is_blocking() && !self.outcome.passed()
256 }
257
258 fn halt_cause(&self) -> Option<HaltCause> {
259 if !self.halts() {
260 return None;
261 }
262 match self.outcome {
263 Outcome::Fail(_) => Some(HaltCause::BlockingFailure),
264 Outcome::Unevaluable(_) => Some(HaltCause::BlockingUnevaluable),
265 Outcome::Pass => None,
266 }
267 }
268}
269
270impl fmt::Display for Gate {
271 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272 write!(f, "[{}] {} {}", self.severity, self.id, self.outcome)
273 }
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
282pub enum Known<T> {
283 Known(T),
285 #[default]
287 Unknown,
288}
289
290impl<T> Known<T> {
291 pub fn is_known(&self) -> bool {
293 matches!(self, Known::Known(_))
294 }
295
296 pub fn get(&self) -> Option<&T> {
298 match self {
299 Known::Known(v) => Some(v),
300 Known::Unknown => None,
301 }
302 }
303
304 pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Known<U> {
306 match self {
307 Known::Known(v) => Known::Known(f(v)),
308 Known::Unknown => Known::Unknown,
309 }
310 }
311
312 pub fn zip_with<U, R, F: FnOnce(T, U) -> R>(self, other: Known<U>, f: F) -> Known<R> {
321 match (self, other) {
322 (Known::Known(a), Known::Known(b)) => Known::Known(f(a, b)),
323 _ => Known::Unknown,
324 }
325 }
326}
327
328impl<T> From<Option<T>> for Known<T> {
329 fn from(opt: Option<T>) -> Self {
330 match opt {
331 Some(v) => Known::Known(v),
332 None => Known::Unknown,
333 }
334 }
335}
336
337#[derive(Debug, Clone, Default, PartialEq, Eq)]
339pub struct Stage {
340 pub name: String,
342 pub gates: Vec<Gate>,
344}
345
346impl Stage {
347 pub fn new(name: impl Into<String>) -> Self {
349 Stage {
350 name: name.into(),
351 gates: Vec::new(),
352 }
353 }
354
355 pub fn with_gate(mut self, gate: Gate) -> Self {
357 self.gates.push(gate);
358 self
359 }
360
361 pub fn push(&mut self, gate: Gate) {
363 self.gates.push(gate);
364 }
365
366 pub fn evaluate(&self) -> StageReport {
371 let mut counts = GateCounts::default();
372 let mut verdict = Verdict::Proceed;
373 for gate in &self.gates {
374 counts.total += 1;
375 if gate.severity.is_blocking() {
376 counts.blocking += 1;
377 }
378 match gate.outcome {
379 Outcome::Pass => counts.passed += 1,
380 Outcome::Fail(_) => counts.failed += 1,
381 Outcome::Unevaluable(_) => counts.unevaluable += 1,
382 }
383 if verdict == Verdict::Proceed {
384 if let Some(cause) = gate.halt_cause() {
385 verdict = Verdict::Halt {
386 gate_id: gate.id.clone(),
387 cause,
388 reason: gate.outcome.reason().unwrap_or_default().to_string(),
389 };
390 }
391 }
392 }
393 StageReport {
394 stage: self.name.clone(),
395 counts,
396 verdict,
397 gates: self.gates.clone(),
398 }
399 }
400}
401
402#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
404pub struct GateCounts {
405 pub total: usize,
407 pub blocking: usize,
409 pub passed: usize,
411 pub failed: usize,
413 pub unevaluable: usize,
415}
416
417#[derive(Debug, Clone, PartialEq, Eq)]
419pub struct StageReport {
420 pub stage: String,
422 pub counts: GateCounts,
424 pub verdict: Verdict,
426 pub gates: Vec<Gate>,
428}
429
430impl StageReport {
431 pub fn halted(&self) -> bool {
433 self.verdict.is_halt()
434 }
435
436 pub fn warnings(&self) -> Vec<&Gate> {
438 self.gates
439 .iter()
440 .filter(|g| !g.severity.is_blocking() && !g.outcome.passed())
441 .collect()
442 }
443
444 pub fn not_passing(&self) -> Vec<&Gate> {
446 self.gates.iter().filter(|g| !g.outcome.passed()).collect()
447 }
448}
449
450#[derive(Debug, Clone, Default, PartialEq, Eq)]
452pub struct Pipeline {
453 pub stages: Vec<Stage>,
455}
456
457impl Pipeline {
458 pub fn new() -> Self {
460 Pipeline { stages: Vec::new() }
461 }
462
463 pub fn with_stage(mut self, stage: Stage) -> Self {
465 self.stages.push(stage);
466 self
467 }
468
469 pub fn run(&self) -> RunReport {
488 let mut reports = Vec::with_capacity(self.stages.len());
489 for stage in &self.stages {
490 let report = stage.evaluate();
491 let halted = report.halted();
492 reports.push(report);
493 if halted {
494 break;
495 }
496 }
497 RunReport {
498 reports,
499 stages_declared: self.stages.len(),
500 }
501 }
502}
503
504#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct RunReport {
507 pub reports: Vec<StageReport>,
509 pub stages_declared: usize,
511}
512
513impl RunReport {
514 pub fn halted(&self) -> bool {
516 self.reports.last().map(|r| r.halted()).unwrap_or(false)
517 }
518
519 pub fn halted_stage(&self) -> Option<&str> {
521 self.reports
522 .last()
523 .filter(|r| r.halted())
524 .map(|r| r.stage.as_str())
525 }
526
527 pub fn stages_skipped(&self) -> usize {
529 self.stages_declared - self.reports.len()
530 }
531
532 pub fn counts(&self) -> GateCounts {
534 let mut total = GateCounts::default();
535 for r in &self.reports {
536 total.total += r.counts.total;
537 total.blocking += r.counts.blocking;
538 total.passed += r.counts.passed;
539 total.failed += r.counts.failed;
540 total.unevaluable += r.counts.unevaluable;
541 }
542 total
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn a_passing_blocking_gate_proceeds() {
552 let report = Stage::new("intake").with_gate(Gate::pass("scope")).evaluate();
553 assert_eq!(report.verdict, Verdict::Proceed);
554 assert!(!report.halted());
555 assert_eq!(report.counts.passed, 1);
556 }
557
558 #[test]
559 fn a_failing_blocking_gate_halts() {
560 let report = Stage::new("build")
561 .with_gate(Gate::fail("pages-nonzero", "zero pages"))
562 .evaluate();
563 match report.verdict {
564 Verdict::Halt {
565 gate_id,
566 cause,
567 reason,
568 } => {
569 assert_eq!(gate_id, "pages-nonzero");
570 assert_eq!(cause, HaltCause::BlockingFailure);
571 assert_eq!(reason, "zero pages");
572 }
573 Verdict::Proceed => panic!("should have halted"),
574 }
575 }
576
577 #[test]
578 fn an_unevaluable_blocking_gate_also_halts() {
579 let report = Stage::new("harvest")
580 .with_gate(Gate::unevaluable("rdap", "no credential"))
581 .evaluate();
582 assert!(report.halted());
583 assert!(matches!(
584 report.verdict,
585 Verdict::Halt {
586 cause: HaltCause::BlockingUnevaluable,
587 ..
588 }
589 ));
590 assert_eq!(report.counts.unevaluable, 1);
591 }
592
593 #[test]
594 fn advisory_gates_never_halt_but_are_recorded() {
595 let report = Stage::new("design")
596 .with_gate(Gate::fail("tone", "flagged").advisory())
597 .with_gate(Gate::unevaluable("readability", "no corpus").advisory())
598 .with_gate(Gate::pass("prompt-vendored"))
599 .evaluate();
600 assert!(!report.halted());
601 assert_eq!(report.warnings().len(), 2);
602 assert_eq!(report.not_passing().len(), 2);
603 assert_eq!(report.counts.blocking, 1);
604 assert_eq!(report.counts.total, 3);
605 }
606
607 #[test]
608 fn the_first_halting_gate_is_the_one_reported() {
609 let report = Stage::new("qualify")
610 .with_gate(Gate::pass("a"))
611 .with_gate(Gate::fail("b", "first"))
612 .with_gate(Gate::unevaluable("c", "second"))
613 .evaluate();
614 match report.verdict {
615 Verdict::Halt { gate_id, .. } => assert_eq!(gate_id, "b"),
616 Verdict::Proceed => panic!("should have halted"),
617 }
618 assert_eq!(report.counts.failed, 1);
620 assert_eq!(report.counts.unevaluable, 1);
621 }
622
623 #[test]
624 fn check_builds_pass_or_fail_from_a_boolean() {
625 assert!(Gate::check("g", true, "r").outcome.passed());
626 assert_eq!(
627 Gate::check("g", false, "r").outcome,
628 Outcome::Fail("r".into())
629 );
630 }
631
632 #[test]
633 fn unknown_input_makes_a_gate_unevaluable_not_passing() {
634 let unknown: Known<u32> = Known::Unknown;
635 let gate = Gate::check_known("coverage", &unknown, |c| *c >= 50, "below floor");
636 assert!(!gate.outcome.was_evaluated());
637 assert!(gate.halts());
638
639 let known = Known::Known(60u32);
640 let gate = Gate::check_known("coverage", &known, |c| *c >= 50, "below floor");
641 assert!(gate.outcome.passed());
642 assert!(!gate.halts());
643 }
644
645 #[test]
646 fn unknown_propagates_through_combination() {
647 let a = Known::Known(10);
648 let b: Known<i32> = Known::Unknown;
649 assert_eq!(a.zip_with(b, |x, y| x + y), Known::Unknown);
650 assert_eq!(
651 Known::Known(10).zip_with(Known::Known(5), |x, y| x + y),
652 Known::Known(15)
653 );
654 assert_eq!(b.map(|v| v * 2), Known::Unknown);
655 assert_eq!(Known::from(Some(3)), Known::Known(3));
656 assert_eq!(Known::<i32>::from(None), Known::Unknown);
657 assert_eq!(Known::<i32>::default(), Known::Unknown);
658 assert_eq!(Known::Known(7).get(), Some(&7));
659 assert!(!b.is_known());
660 }
661
662 #[test]
663 fn pipeline_stops_at_the_first_halting_stage() {
664 let run = Pipeline::new()
665 .with_stage(Stage::new("intake").with_gate(Gate::pass("scope")))
666 .with_stage(Stage::new("harvest").with_gate(Gate::unevaluable("api", "no key")))
667 .with_stage(Stage::new("build").with_gate(Gate::pass("unreached")))
668 .run();
669 assert!(run.halted());
670 assert_eq!(run.halted_stage(), Some("harvest"));
671 assert_eq!(run.reports.len(), 2);
672 assert_eq!(run.stages_skipped(), 1);
673 assert_eq!(run.counts().total, 2);
674 }
675
676 #[test]
677 fn a_clean_pipeline_proceeds_through_every_stage() {
678 let run = Pipeline::new()
679 .with_stage(Stage::new("one").with_gate(Gate::pass("a")))
680 .with_stage(Stage::new("two").with_gate(Gate::pass("b")))
681 .run();
682 assert!(!run.halted());
683 assert_eq!(run.halted_stage(), None);
684 assert_eq!(run.stages_skipped(), 0);
685 assert_eq!(run.counts().passed, 2);
686 }
687
688 #[test]
689 fn an_empty_pipeline_does_not_claim_to_have_halted() {
690 let run = Pipeline::new().run();
691 assert!(!run.halted());
692 assert_eq!(run.counts(), GateCounts::default());
693 }
694
695 #[test]
696 fn a_stage_can_be_built_imperatively() {
697 let mut stage = Stage::new("measure");
698 stage.push(Gate::pass("gsc-rows"));
699 stage.push(Gate::fail("indexed", "0 of 14").advisory());
700 let report = stage.evaluate();
701 assert_eq!(report.counts.total, 2);
702 assert!(!report.halted());
703 }
704
705 #[test]
706 fn display_is_readable() {
707 assert_eq!(Outcome::Pass.to_string(), "PASS");
708 assert_eq!(
709 Gate::fail("x", "because").to_string(),
710 "[blocking] x FAIL: because"
711 );
712 assert_eq!(
713 HaltCause::BlockingUnevaluable.to_string(),
714 "blocking gate could not be evaluated"
715 );
716 }
717
718 #[test]
719 fn outcome_reasons_are_retrievable() {
720 assert_eq!(Outcome::Pass.reason(), None);
721 assert_eq!(Outcome::Fail("r".into()).reason(), Some("r"));
722 assert_eq!(Outcome::Unevaluable("u".into()).reason(), Some("u"));
723 assert!(Severity::Blocking.is_blocking());
724 assert!(!Severity::Advisory.is_blocking());
725 }
726}