Skip to main content

shigoto_types/
chain.rs

1//! `Chain<I, O>` — first-match-wins composition over typed rules.
2//!
3//! Spec: `theory/PATTERN-EXTRACTION.md` Pattern 3 (Chain).
4//!
5//! Extracted after multiple primitives shipped the same shape:
6//!
7//!   - `ChainedClassifier<I, O>` — typed-classifier chain
8//!   - `ChainedHealthCheck<R>` — typed-health-check chain (partial fit)
9//!   - `TimeoutWatcher::rules` — first state+threshold match (partial fit)
10//!
11//! Each repeated: `Vec<Fn(&I) -> Option<O>>` rules + `Fn(&I) -> O`
12//! fallback + first-match-wins evaluation.
13//!
14//! Chain factors out that shape; consumers reach for it whenever they
15//! want a composable typed rule chain with a fallback.
16//!
17//! # The Chain law
18//!
19//! For any `Chain<I, O>` and any input `i`:
20//!
21//!   - **Determinism:** `c.evaluate(&i) == c.evaluate(&i)` (pure
22//!     dispatch — rules and fallback are `Fn`)
23//!   - **First-match-wins:** If multiple rules return `Some` for `i`,
24//!     the rule added FIRST wins
25//!   - **Fallback fires last:** If no rule returns `Some`, the
26//!     fallback runs and supplies a value
27//!   - **Total:** `evaluate` always returns a value (fallback is
28//!     mandatory at construction)
29//!
30//! # Fluent construction
31//!
32//! ```
33//! use shigoto_types::chain::Chain;
34//!
35//! enum Color { Red, Green, Other }
36//!
37//! let c = Chain::<str, Color>::new(|_| Color::Other)
38//!     .with_rule(|s| s.contains("red").then_some(Color::Red))
39//!     .with_rule(|s| s.contains("green").then_some(Color::Green));
40//!
41//! assert!(matches!(c.evaluate("rare red wine"), Color::Red));
42//! assert!(matches!(c.evaluate("anything else"),  Color::Other));
43//! ```
44
45#![allow(missing_docs)]
46
47use std::sync::Arc;
48
49/// Typed first-match-wins rule chain with a mandatory fallback.
50pub struct Chain<I: ?Sized, O> {
51    rules: Vec<Arc<dyn Fn(&I) -> Option<O> + Send + Sync>>,
52    fallback: Arc<dyn Fn(&I) -> O + Send + Sync>,
53}
54
55impl<I: ?Sized, O> Chain<I, O> {
56    /// Construct an empty chain with the given fallback. Adding rules
57    /// via `with_rule` extends the chain. The fallback is mandatory
58    /// at construction — `Chain::evaluate` is total.
59    pub fn new<F>(fallback: F) -> Self
60    where
61        F: Fn(&I) -> O + Send + Sync + 'static,
62    {
63        Self {
64            rules: Vec::new(),
65            fallback: Arc::new(fallback),
66        }
67    }
68
69    /// Append a rule. Returns self for fluent chaining.
70    #[must_use]
71    pub fn with_rule<R>(mut self, rule: R) -> Self
72    where
73        R: Fn(&I) -> Option<O> + Send + Sync + 'static,
74    {
75        self.rules.push(Arc::new(rule));
76        self
77    }
78
79    /// Number of rules in the chain (excludes the fallback).
80    pub fn rule_count(&self) -> usize {
81        self.rules.len()
82    }
83
84    /// Evaluate the chain on an input. Returns the first rule's
85    /// `Some(...)` output, else the fallback's value. Total: always
86    /// returns a value.
87    pub fn evaluate(&self, input: &I) -> O {
88        for rule in &self.rules {
89            if let Some(o) = rule(input) {
90                return o;
91            }
92        }
93        (self.fallback)(input)
94    }
95}
96
97impl<I: ?Sized, O> std::fmt::Debug for Chain<I, O> {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("Chain")
100            .field("rules", &self.rules.len())
101            .finish()
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::testing::assert_deterministic_over;
109
110    #[derive(Debug, PartialEq, Eq, Clone)]
111    enum Color {
112        Red,
113        Green,
114        Blue,
115        Unknown,
116    }
117
118    #[test]
119    fn empty_chain_returns_fallback() {
120        let c = Chain::<str, Color>::new(|_| Color::Unknown);
121        assert_eq!(c.evaluate("anything"), Color::Unknown);
122        assert_eq!(c.rule_count(), 0);
123    }
124
125    #[test]
126    fn first_matching_rule_wins() {
127        let c = Chain::<str, Color>::new(|_| Color::Unknown)
128            .with_rule(|s| s.contains("red").then_some(Color::Red))
129            .with_rule(|s| s.contains("green").then_some(Color::Green))
130            .with_rule(|s| s.contains("blue").then_some(Color::Blue));
131
132        assert_eq!(c.evaluate("red and green"), Color::Red);
133        assert_eq!(c.evaluate("blue sky"), Color::Blue);
134        assert_eq!(c.evaluate("nothing"), Color::Unknown);
135        assert_eq!(c.rule_count(), 3);
136    }
137
138    #[test]
139    fn fallback_fires_on_no_match() {
140        let c = Chain::<str, Color>::new(|_| Color::Unknown)
141            .with_rule(|s| s.contains("red").then_some(Color::Red));
142        assert_eq!(c.evaluate("none of that"), Color::Unknown);
143    }
144
145    #[test]
146    fn determinism_law() {
147        let c = Chain::<str, Color>::new(|_| Color::Unknown)
148            .with_rule(|s| s.contains("r").then_some(Color::Red))
149            .with_rule(|s| s.contains("g").then_some(Color::Green));
150
151        assert_deterministic_over(&["red", "green", "ranger", "neither", ""], |&input| {
152            c.evaluate(input)
153        });
154    }
155
156    #[test]
157    fn debug_impl_shows_rule_count() {
158        let c = Chain::<str, Color>::new(|_| Color::Unknown)
159            .with_rule(|s| s.contains("r").then_some(Color::Red));
160        let debug_str = format!("{c:?}");
161        assert!(debug_str.contains("rules"));
162        assert!(debug_str.contains('1'));
163    }
164
165    #[test]
166    fn composes_with_classifier_trait() {
167        // Proof: `Chain<I, O>` IS a `Classifier<I, O>` via the
168        // blanket impl in `classify.rs`. Same shape, same semantics —
169        // the new ChainedClassifier typedef proves this is a
170        // backwards-compat substitution.
171        use crate::Classifier;
172        let c = Chain::<str, Color>::new(|_| Color::Unknown)
173            .with_rule(|s: &str| s.contains("red").then_some(Color::Red));
174        assert_eq!(c.classify("red"), Color::Red);
175        assert_eq!(c.classify("none"), Color::Unknown);
176    }
177}