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}