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