Skip to main content

coding_tools/
verdict.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! The shared *framed-verdict* spine: the `SUCCESS`/`ERROR` outcome every tool
5//! emits, its `0`/`1` exit-status mapping, and the [`Expect`]ation that turns a
6//! search's match count into a [`Verdict`].
7//!
8//! Both binaries reduce to the same shape — frame a question, run a probe,
9//! classify the result, emit a templated verdict — and this module carries the
10//! pieces of that shape that are not specific to *what* the probe is. `ct-test`
11//! classifies a command's streams into a [`Verdict`]; `ct-search` classifies its
12//! match count through an [`Expect`]ation into the same [`Verdict`]; both map it
13//! to an exit status the same way.
14
15use std::process::ExitCode;
16
17/// The outcome of a framed check.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Verdict {
20    /// The check passed.
21    Success,
22    /// The check failed.
23    Error,
24}
25
26impl Verdict {
27    /// The token written for `{RESULT}` and shown in human output.
28    ///
29    /// # Examples
30    ///
31    /// ```
32    /// use coding_tools::verdict::Verdict;
33    ///
34    /// assert_eq!(Verdict::Success.label(), "SUCCESS");
35    /// assert_eq!(Verdict::Error.label(), "ERROR");
36    /// ```
37    pub fn label(self) -> &'static str {
38        match self {
39            Verdict::Success => "SUCCESS",
40            Verdict::Error => "ERROR",
41        }
42    }
43
44    /// The process exit status carrying this verdict: `0` for [`Success`], `1`
45    /// for [`Error`]. A `2` (usage/runtime failure) is a separate concern owned
46    /// by each binary's `main`, never produced here.
47    ///
48    /// [`Success`]: Verdict::Success
49    /// [`Error`]: Verdict::Error
50    pub fn exit_code(self) -> ExitCode {
51        match self {
52            Verdict::Success => ExitCode::SUCCESS,
53            Verdict::Error => ExitCode::from(1),
54        }
55    }
56}
57
58/// An expectation over a match count, classifying it into a [`Verdict`].
59///
60/// The numeric forms reuse the suite's `[+|-]N` threshold grammar (the same
61/// `+` larger-than / `-` smaller-than / bare at-least convention as
62/// `ct-search --size`), extended with an exact form and two keywords so the
63/// common search-as-test assertions read plainly:
64///
65/// | Spec   | Passes when the count is | Meaning                          |
66/// | ------ | ------------------------ | -------------------------------- |
67/// | `any`  | `>= 1`                   | found something *(the default)*  |
68/// | `none` | `== 0`                   | a negative assertion             |
69/// | `N`    | `>= N`                   | at least `N`                     |
70/// | `=N`   | `== N`                   | exactly `N`                      |
71/// | `+N`   | `> N`                    | more than `N`                    |
72/// | `-N`   | `< N`                    | fewer than `N`                   |
73///
74/// `any` is the default so a plain search gains framing without changing its
75/// pass condition: `Expect::default().eval(count)` is `Success` exactly when the
76/// search matched, reproducing `ct-search`'s historic `0`/`1` exit semantics.
77///
78/// # Examples
79///
80/// ```
81/// use coding_tools::verdict::{Expect, Verdict};
82///
83/// // `none` is a negative assertion: passes only when nothing matched.
84/// assert_eq!(Expect::parse("none").unwrap().eval(0), Verdict::Success);
85/// assert_eq!(Expect::parse("none").unwrap().eval(2), Verdict::Error);
86///
87/// // The default `any` passes on one or more.
88/// assert_eq!(Expect::default().eval(0), Verdict::Error);
89/// assert_eq!(Expect::default().eval(3), Verdict::Success);
90///
91/// // Thresholds: bare N is ">= N", =N exact, +N more-than, -N fewer-than.
92/// assert_eq!(Expect::parse("3").unwrap().eval(3), Verdict::Success);
93/// assert_eq!(Expect::parse("=2").unwrap().eval(3), Verdict::Error);
94/// assert_eq!(Expect::parse("+0").unwrap().eval(1), Verdict::Success);
95/// assert_eq!(Expect::parse("-10").unwrap().eval(9), Verdict::Success);
96/// ```
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum Expect {
99    /// `>= N` — the bare-`N` and `any` (`>= 1`) forms.
100    AtLeast(u64),
101    /// `== N` — the `=N` and `none` (`== 0`) forms.
102    Eq(u64),
103    /// `> N` — the `+N` form.
104    Gt(u64),
105    /// `< N` — the `-N` form.
106    Lt(u64),
107}
108
109impl Default for Expect {
110    /// `any` — pass when at least one entry matched.
111    fn default() -> Self {
112        Expect::AtLeast(1)
113    }
114}
115
116impl Expect {
117    /// Parse an expectation spec; see the [type docs](Expect) for the grammar.
118    pub fn parse(spec: &str) -> Result<Expect, String> {
119        let spec = spec.trim();
120        match spec {
121            "any" => return Ok(Expect::AtLeast(1)),
122            "none" => return Ok(Expect::Eq(0)),
123            "" => return Err("empty --expect spec".to_string()),
124            _ => {}
125        }
126        let (ctor, body): (fn(u64) -> Expect, &str) = if let Some(r) = spec.strip_prefix('=') {
127            (Expect::Eq, r)
128        } else if let Some(r) = spec.strip_prefix('+') {
129            (Expect::Gt, r)
130        } else if let Some(r) = spec.strip_prefix('-') {
131            (Expect::Lt, r)
132        } else {
133            (Expect::AtLeast, spec)
134        };
135        let n: u64 = body
136            .trim()
137            .parse()
138            .map_err(|_| format!("invalid count in --expect '{spec}'"))?;
139        Ok(ctor(n))
140    }
141
142    /// Classify a match `count` into a [`Verdict`].
143    pub fn eval(self, count: u64) -> Verdict {
144        let pass = match self {
145            Expect::AtLeast(n) => count >= n,
146            Expect::Eq(n) => count == n,
147            Expect::Gt(n) => count > n,
148            Expect::Lt(n) => count < n,
149        };
150        if pass {
151            Verdict::Success
152        } else {
153            Verdict::Error
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn default_expectation_is_any() {
164        assert_eq!(Expect::default(), Expect::AtLeast(1));
165        assert_eq!(Expect::default().eval(0), Verdict::Error);
166        assert_eq!(Expect::default().eval(3), Verdict::Success);
167    }
168
169    #[test]
170    fn keywords_parse_to_numeric_forms() {
171        assert_eq!(Expect::parse("any").unwrap(), Expect::AtLeast(1));
172        assert_eq!(Expect::parse("none").unwrap(), Expect::Eq(0));
173    }
174
175    #[test]
176    fn threshold_grammar_matches_size_conventions() {
177        // bare N => at least N; +N => more than N; -N => fewer than N.
178        assert_eq!(Expect::parse("3").unwrap(), Expect::AtLeast(3));
179        assert_eq!(Expect::parse("+3").unwrap(), Expect::Gt(3));
180        assert_eq!(Expect::parse("-3").unwrap(), Expect::Lt(3));
181        assert_eq!(Expect::parse("=3").unwrap(), Expect::Eq(3));
182    }
183
184    #[test]
185    fn none_passes_only_on_zero() {
186        let none = Expect::parse("none").unwrap();
187        assert_eq!(none.eval(0), Verdict::Success);
188        assert_eq!(none.eval(1), Verdict::Error);
189    }
190
191    #[test]
192    fn thresholds_classify_counts() {
193        assert_eq!(Expect::Gt(0).eval(0), Verdict::Error);
194        assert_eq!(Expect::Gt(0).eval(1), Verdict::Success);
195        assert_eq!(Expect::Lt(10).eval(9), Verdict::Success);
196        assert_eq!(Expect::Lt(10).eval(10), Verdict::Error);
197        assert_eq!(Expect::Eq(1).eval(1), Verdict::Success);
198        assert_eq!(Expect::Eq(1).eval(2), Verdict::Error);
199    }
200
201    #[test]
202    fn rejects_non_numeric_specs() {
203        assert!(Expect::parse("lots").is_err());
204        assert!(Expect::parse("+").is_err());
205        assert!(Expect::parse("").is_err());
206    }
207}