Skip to main content

gate_contract/
lib.rs

1//! Fail-closed gate contracts for staged build pipelines.
2//!
3//! A pipeline that only knows PASS and FAIL has a third state hiding inside it: the gate
4//! that could not be evaluated at all, because a credential was missing, an API timed out,
5//! or the value it needed was never produced. Treating that as a pass is how a run reaches
6//! deploy having checked nothing. This crate makes the third state explicit and closes it.
7//!
8//! The rule enforced throughout:
9//!
10//! > A **blocking** gate halts the run when it fails **and** when it cannot be evaluated.
11//! > "Could not check" is not "fine".
12//!
13//! # The pieces
14//!
15//! - [`Outcome`] — `Pass`, `Fail`, or `Unevaluable`, each carrying a reason string.
16//! - [`Known`] — a value that may be `Unknown`; unknown propagates through combination
17//!   instead of decaying into a default.
18//! - [`Gate`] — an id, a [`Severity`] (blocking or advisory) and an outcome.
19//! - [`Stage`] — a named set of gates, evaluated together so one run reports every failure.
20//! - [`Pipeline`] — ordered stages, halting at the first stage that halts.
21//!
22//! # Example
23//!
24//! ```
25//! use gate_contract::{Gate, Severity, Stage, Verdict};
26//!
27//! let stage = Stage::new("harvest")
28//!     .with_gate(Gate::pass("serp-rows-present"))
29//!     .with_gate(Gate::unevaluable("rdap-reachable", "no credential on disk"));
30//!
31//! let report = stage.evaluate();
32//! assert!(report.halted());
33//! match report.verdict {
34//!     Verdict::Halt { ref gate_id, .. } => assert_eq!(gate_id, "rdap-reachable"),
35//!     Verdict::Proceed => unreachable!(),
36//! }
37//! ```
38//!
39//! An advisory gate records the same information without stopping anything:
40//!
41//! ```
42//! use gate_contract::{Gate, Stage};
43//!
44//! let report = Stage::new("design")
45//!     .with_gate(Gate::fail("tone-check", "two sentences flagged").advisory())
46//!     .evaluate();
47//! assert!(!report.halted());
48//! assert_eq!(report.warnings().len(), 1);
49//! ```
50//!
51//! No dependencies, no I/O, no unsafe. Evaluating what a gate *means* is this crate's job;
52//! deciding what to measure is yours.
53//!
54//! Extracted from a real staged build pipeline, whose first blocking gate is worked through in
55//! public at <https://aiwebsitepipeline.com/niche-score.html>: six subscores, the weights that
56//! combine them, and the thresholds that decide the run — 55 and above proceeds, 40 to 55
57//! proceeds under a page cap, below 40 the run halts. That page is a concrete example of the
58//! shape [`Severity::Blocking`] exists to express, and is worth reading before deciding what
59//! your own gates should refuse.
60
61#![forbid(unsafe_code)]
62#![deny(missing_docs)]
63
64use std::fmt;
65
66/// Whether a gate can stop the run.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum Severity {
69    /// Failure or non-evaluation halts the run.
70    Blocking,
71    /// Failure is recorded as a warning and the run continues.
72    Advisory,
73}
74
75impl Severity {
76    /// Whether this severity can halt a run.
77    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/// The result of evaluating one gate.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum Outcome {
94    /// The condition was checked and held.
95    Pass,
96    /// The condition was checked and did not hold.
97    Fail(String),
98    /// The condition could not be checked at all.
99    Unevaluable(String),
100}
101
102impl Outcome {
103    /// Whether the check ran to a verdict, pass or fail.
104    pub fn was_evaluated(&self) -> bool {
105        !matches!(self, Outcome::Unevaluable(_))
106    }
107
108    /// Whether the condition held.
109    pub fn passed(&self) -> bool {
110        matches!(self, Outcome::Pass)
111    }
112
113    /// The reason attached to a fail or a non-evaluation, if any.
114    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/// Why a run halted.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum HaltCause {
135    /// A blocking gate was checked and did not hold.
136    BlockingFailure,
137    /// A blocking gate could not be checked.
138    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/// What the caller should do next.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum Verdict {
153    /// Nothing blocking objected; continue.
154    Proceed,
155    /// Stop, and here is the first gate that said so.
156    Halt {
157        /// Id of the gate that halted the run.
158        gate_id: String,
159        /// Whether it failed or could not be evaluated.
160        cause: HaltCause,
161        /// The gate's reason string.
162        reason: String,
163    },
164}
165
166impl Verdict {
167    /// Whether this verdict stops the run.
168    pub fn is_halt(&self) -> bool {
169        matches!(self, Verdict::Halt { .. })
170    }
171}
172
173/// One contract: an identifier, a severity and an outcome.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Gate {
176    /// Stable identifier, e.g. `"rdap-reachable"`.
177    pub id: String,
178    /// Whether this gate can halt the run.
179    pub severity: Severity,
180    /// The evaluated outcome.
181    pub outcome: Outcome,
182}
183
184impl Gate {
185    /// A blocking gate with an explicit outcome.
186    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    /// A blocking gate that passed.
195    pub fn pass(id: impl Into<String>) -> Self {
196        Gate::new(id, Outcome::Pass)
197    }
198
199    /// A blocking gate that failed, with the reason.
200    pub fn fail(id: impl Into<String>, reason: impl Into<String>) -> Self {
201        Gate::new(id, Outcome::Fail(reason.into()))
202    }
203
204    /// A blocking gate that could not be checked, with the reason.
205    pub fn unevaluable(id: impl Into<String>, reason: impl Into<String>) -> Self {
206        Gate::new(id, Outcome::Unevaluable(reason.into()))
207    }
208
209    /// Downgrade this gate to advisory.
210    pub fn advisory(mut self) -> Self {
211        self.severity = Severity::Advisory;
212        self
213    }
214
215    /// Build a gate from a boolean check, with the reason used when it is false.
216    ///
217    /// ```
218    /// use gate_contract::Gate;
219    /// let g = Gate::check("page-count-nonzero", 12 > 0, "no pages were produced");
220    /// assert!(g.outcome.passed());
221    /// ```
222    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    /// Build a gate from a value that may be unknown.
231    ///
232    /// `Known(v)` is handed to `predicate`; `Unknown` short-circuits to
233    /// [`Outcome::Unevaluable`], which is what makes the missing input halt a blocking
234    /// gate instead of silently passing it.
235    ///
236    /// ```
237    /// use gate_contract::{Gate, Known, Outcome};
238    ///
239    /// let measured: Known<u32> = Known::Unknown;
240    /// let g = Gate::check_known("coverage-above-floor", &measured, |c| *c >= 50, "below floor");
241    /// assert!(matches!(g.outcome, Outcome::Unevaluable(_)));
242    /// ```
243    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    /// Whether this gate, on its own, halts the run.
259    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/// A value that may not exist yet.
282///
283/// The point of a dedicated type is that `Unknown` cannot be mistaken for a zero or an
284/// empty string once it reaches a comparison. Combining anything with `Unknown` yields
285/// `Unknown`.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
287pub enum Known<T> {
288    /// The value was produced.
289    Known(T),
290    /// The value was not produced.
291    #[default]
292    Unknown,
293}
294
295impl<T> Known<T> {
296    /// Whether a value is present.
297    pub fn is_known(&self) -> bool {
298        matches!(self, Known::Known(_))
299    }
300
301    /// Borrow the value if present.
302    pub fn get(&self) -> Option<&T> {
303        match self {
304            Known::Known(v) => Some(v),
305            Known::Unknown => None,
306        }
307    }
308
309    /// Map the value, keeping `Unknown` unknown.
310    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    /// Combine two values; if either is unknown the result is unknown.
318    ///
319    /// ```
320    /// use gate_contract::Known;
321    /// let a = Known::Known(2);
322    /// let b: Known<i32> = Known::Unknown;
323    /// assert_eq!(a.zip_with(b, |x, y| x + y), Known::Unknown);
324    /// ```
325    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/// A named set of gates evaluated together.
343#[derive(Debug, Clone, Default, PartialEq, Eq)]
344pub struct Stage {
345    /// Stage name, e.g. `"harvest"`.
346    pub name: String,
347    /// The gates belonging to this stage.
348    pub gates: Vec<Gate>,
349}
350
351impl Stage {
352    /// An empty stage.
353    pub fn new(name: impl Into<String>) -> Self {
354        Stage {
355            name: name.into(),
356            gates: Vec::new(),
357        }
358    }
359
360    /// Append a gate, builder style.
361    pub fn with_gate(mut self, gate: Gate) -> Self {
362        self.gates.push(gate);
363        self
364    }
365
366    /// Append a gate in place.
367    pub fn push(&mut self, gate: Gate) {
368        self.gates.push(gate);
369    }
370
371    /// Evaluate every gate and summarise.
372    ///
373    /// All gates are inspected, so one run reports every problem rather than only the
374    /// first. The verdict names the first halting gate in declaration order.
375    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/// Tallies from one stage evaluation.
408#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
409pub struct GateCounts {
410    /// Gates in the stage.
411    pub total: usize,
412    /// Of those, how many are blocking.
413    pub blocking: usize,
414    /// Gates that passed.
415    pub passed: usize,
416    /// Gates that were checked and failed.
417    pub failed: usize,
418    /// Gates that could not be checked.
419    pub unevaluable: usize,
420}
421
422/// The outcome of evaluating a [`Stage`].
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct StageReport {
425    /// Name of the stage.
426    pub stage: String,
427    /// Tallies.
428    pub counts: GateCounts,
429    /// Proceed, or the first gate that halted the run.
430    pub verdict: Verdict,
431    /// Every gate as evaluated, in declaration order.
432    pub gates: Vec<Gate>,
433}
434
435impl StageReport {
436    /// Whether the stage halted the run.
437    pub fn halted(&self) -> bool {
438        self.verdict.is_halt()
439    }
440
441    /// Advisory gates that did not pass. These never halt; they are for the log.
442    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    /// Every gate that did not pass, blocking or not.
450    pub fn not_passing(&self) -> Vec<&Gate> {
451        self.gates.iter().filter(|g| !g.outcome.passed()).collect()
452    }
453}
454
455/// Ordered stages evaluated until one halts.
456#[derive(Debug, Clone, Default, PartialEq, Eq)]
457pub struct Pipeline {
458    /// The stages, in execution order.
459    pub stages: Vec<Stage>,
460}
461
462impl Pipeline {
463    /// An empty pipeline.
464    pub fn new() -> Self {
465        Pipeline { stages: Vec::new() }
466    }
467
468    /// Append a stage, builder style.
469    pub fn with_stage(mut self, stage: Stage) -> Self {
470        self.stages.push(stage);
471        self
472    }
473
474    /// Evaluate stages in order, stopping after the first that halts.
475    ///
476    /// Stages after a halt are not evaluated and do not appear in the report, which is
477    /// the honest shape: they never ran, so they have no outcome.
478    ///
479    /// ```
480    /// use gate_contract::{Gate, Pipeline, Stage};
481    ///
482    /// let run = Pipeline::new()
483    ///     .with_stage(Stage::new("intake").with_gate(Gate::pass("scope-present")))
484    ///     .with_stage(Stage::new("harvest").with_gate(Gate::unevaluable("api", "no key")))
485    ///     .with_stage(Stage::new("build").with_gate(Gate::pass("never-reached")))
486    ///     .run();
487    ///
488    /// assert!(run.halted());
489    /// assert_eq!(run.reports.len(), 2);
490    /// assert_eq!(run.halted_stage(), Some("harvest"));
491    /// ```
492    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/// The outcome of a whole pipeline run.
510#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct RunReport {
512    /// Reports for the stages that actually ran.
513    pub reports: Vec<StageReport>,
514    /// How many stages the pipeline declared, including any never reached.
515    pub stages_declared: usize,
516}
517
518impl RunReport {
519    /// Whether any stage halted the run.
520    pub fn halted(&self) -> bool {
521        self.reports.last().map(|r| r.halted()).unwrap_or(false)
522    }
523
524    /// Name of the halting stage, if any.
525    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    /// Stages declared but never evaluated because of an earlier halt.
533    pub fn stages_skipped(&self) -> usize {
534        self.stages_declared - self.reports.len()
535    }
536
537    /// Tallies summed across the stages that ran.
538    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        // Evaluation still visits every gate.
624        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}