Skip to main content

shigoto_types/
classify.rs

1//! Typed input classification — the canonical `Classifier<I, O>`
2//! trait every fleet-wide raw-input-to-typed-event pipeline consumes.
3//! Spec: `theory/CONVERGENCE-ADOPTION.md` §II.5, Phase 0.2.
4//!
5//! Subsumes the hand-rolled classification shapes that previously
6//! lived in:
7//!
8//! - `shigoto_types::failure::classify(raw: &str) -> FailureKind`
9//!   (now wrapped via `FailureClassifier`, becomes the canonical
10//!   first Classifier impl)
11//! - `tend::drift::classify_pull_failure(workspace, repo, stderr)
12//!   -> DriftEvent` (shipped 2026-05-28 M1; future migration to
13//!   `Classifier<PullFailureContext, DriftEvent>` lands in 0.2b)
14//!
15//! # Why a trait
16//!
17//! Classification is a deterministic pure-function pattern that
18//! recurs across the fleet (anywhere an opaque input — stderr string,
19//! HTTP status, JSON shape — becomes a typed variant). The trait
20//! gives consumers:
21//!
22//! - **Composability** — chain rules + fallback via `ChainedClassifier`
23//! - **Testability** — proptest the trait law (determinism for same
24//!   inputs) once, every impl inherits
25//! - **Polymorphism** — pass `&dyn Classifier<I, O>` to a higher-level
26//!   pipeline without locking in the implementation
27//! - **Override** — per-workspace operator can swap in a custom
28//!   `Classifier<&str, FailureKind>` that classifies their site's
29//!   stderr shapes correctly
30//!
31//! # The trait law
32//!
33//! For any classifier `c` and any input `i`:
34//!
35//!   `c.classify(i) == c.classify(i)`   (determinism)
36//!
37//! No I/O, no randomness, no hidden state. Implementations that need
38//! side effects classify a Context wrapper that records them
39//! externally — the trait is for pure shape extraction only.
40//!
41//! # `ChainedClassifier<I, O>` composition
42//!
43//! Each rule is a closure `&I -> Option<O>` — `Some(out)` matches,
44//! `None` falls through. The fallback closure `&I -> O` produces a
45//! typed default when no rule matches. Order matters: rules fire in
46//! `with_rule` order; the first match wins.
47
48use std::marker::PhantomData;
49use std::sync::Arc;
50
51/// The canonical typed-input classifier. Implementations map an
52/// opaque input `&I` to a typed output `O` deterministically.
53pub trait Classifier<I: ?Sized, O>: Send + Sync {
54    /// Classify one input. Pure — no side effects, deterministic for
55    /// the same input.
56    fn classify(&self, input: &I) -> O;
57}
58
59/// Adapter that wraps a free function `fn(&I) -> O` as a
60/// `Classifier<I, O>`. Useful when a typed primitive already exists
61/// as a free function (e.g. `shigoto_types::failure::classify`) and
62/// you want to use it polymorphically without rewriting.
63pub struct FnClassifier<I: ?Sized, O, F>
64where
65    F: Fn(&I) -> O + Send + Sync,
66{
67    f: F,
68    _phantom: PhantomData<fn(&I) -> O>,
69}
70
71impl<I: ?Sized, O, F> FnClassifier<I, O, F>
72where
73    F: Fn(&I) -> O + Send + Sync,
74{
75    pub fn new(f: F) -> Self {
76        Self {
77            f,
78            _phantom: PhantomData,
79        }
80    }
81}
82
83impl<I: ?Sized, O, F> Classifier<I, O> for FnClassifier<I, O, F>
84where
85    F: Fn(&I) -> O + Send + Sync,
86{
87    fn classify(&self, input: &I) -> O {
88        (self.f)(input)
89    }
90}
91
92/// Composes a chain of rules + a fallback into a single typed
93/// classifier. Each rule is tried in declared order; the first
94/// `Some(out)` wins. If no rule matches, the fallback fires.
95///
96/// As of the Chain extraction (PATTERN-EXTRACTION.md Pattern 3),
97/// `ChainedClassifier<I, O>` is a typed alias over the generic
98/// [`crate::chain::Chain<I, O>`]. Construction (`Chain::new` +
99/// `with_rule`) and the `Classifier::classify` impl are inherited
100/// from the generic via a blanket impl below.
101///
102/// Construction is fluent:
103///
104/// ```ignore
105/// let c = ChainedClassifier::<str, FailureKind>::new(|_| FailureKind::Transient)
106///     .with_rule(|s| s.contains("does not exist").then_some(FailureKind::Declarative))
107///     .with_rule(|s| s.contains("infinite recursion").then_some(FailureKind::Declarative));
108/// ```
109pub type ChainedClassifier<I, O> = crate::chain::Chain<I, O>;
110
111impl<I: ?Sized, O> Classifier<I, O> for crate::chain::Chain<I, O> {
112    fn classify(&self, input: &I) -> O {
113        self.evaluate(input)
114    }
115}
116
117// ── First canonical consumer: failure::classify ───────────────────
118//
119// Wraps the existing free-function `failure::classify` as a
120// `Classifier<str, FailureKind>`. Demonstrates the migration shape
121// every other consumer follows.
122
123/// `Classifier<str, FailureKind>` impl wrapping
124/// [`crate::failure::classify`]. The canonical first consumer of
125/// the `Classifier` trait; future fleet classifiers follow this
126/// shape (small newtype struct + impl Classifier).
127#[derive(Debug, Default, Copy, Clone)]
128pub struct FailureClassifier;
129
130impl Classifier<str, crate::failure::FailureKind> for FailureClassifier {
131    fn classify(&self, input: &str) -> crate::failure::FailureKind {
132        crate::failure::classify(input)
133    }
134}
135
136// ── Tests ─────────────────────────────────────────────────────────
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::failure::FailureKind;
142
143    #[derive(Debug, Clone, PartialEq, Eq)]
144    enum Color {
145        Red,
146        Green,
147        Blue,
148        Unknown,
149    }
150
151    #[test]
152    fn fn_classifier_wraps_free_function() {
153        let c = FnClassifier::new(|s: &str| {
154            if s == "red" {
155                Color::Red
156            } else {
157                Color::Unknown
158            }
159        });
160        assert_eq!(c.classify("red"), Color::Red);
161        assert_eq!(c.classify("orange"), Color::Unknown);
162    }
163
164    #[test]
165    fn chained_classifier_first_match_wins() {
166        let c = ChainedClassifier::<str, Color>::new(|_| Color::Unknown)
167            .with_rule(|s| s.contains("red").then_some(Color::Red))
168            .with_rule(|s| s.contains("green").then_some(Color::Green))
169            .with_rule(|s| s.contains("blue").then_some(Color::Blue));
170
171        assert_eq!(c.classify("the red car"), Color::Red);
172        assert_eq!(c.classify("green tree"), Color::Green);
173        assert_eq!(c.classify("blue sky"), Color::Blue);
174    }
175
176    #[test]
177    fn chained_classifier_fallback_fires_on_no_match() {
178        let c = ChainedClassifier::<str, Color>::new(|_| Color::Unknown)
179            .with_rule(|s| s.contains("red").then_some(Color::Red));
180
181        assert_eq!(c.classify("totally unrelated"), Color::Unknown);
182        assert_eq!(c.classify(""), Color::Unknown);
183    }
184
185    #[test]
186    fn chained_classifier_rule_order_matters() {
187        // First match wins — the same input matching multiple rules
188        // returns the first rule's output, never the later one.
189        let c = ChainedClassifier::<str, Color>::new(|_| Color::Unknown)
190            .with_rule(|s| s.contains("multi").then_some(Color::Red))
191            .with_rule(|s| s.contains("multi").then_some(Color::Green));
192
193        assert_eq!(c.classify("multi"), Color::Red, "first rule wins");
194    }
195
196    #[test]
197    fn chained_classifier_empty_chain_returns_fallback() {
198        let c = ChainedClassifier::<str, Color>::new(|_| Color::Blue);
199        assert_eq!(c.rule_count(), 0);
200        assert_eq!(c.classify("anything"), Color::Blue);
201    }
202
203    #[test]
204    fn classifier_dyn_polymorphism_works() {
205        let c1: Box<dyn Classifier<str, Color>> = Box::new(
206            ChainedClassifier::new(|_| Color::Unknown)
207                .with_rule(|s: &str| s.contains("r").then_some(Color::Red)),
208        );
209        let c2: Box<dyn Classifier<str, Color>> = Box::new(FnClassifier::new(|_| Color::Green));
210
211        assert_eq!(c1.classify("red"), Color::Red);
212        assert_eq!(c2.classify("anything"), Color::Green);
213    }
214
215    #[test]
216    fn classifier_law_determinism() {
217        // The trait law: same input → same output, every time.
218        let c = ChainedClassifier::<str, Color>::new(|_| Color::Unknown)
219            .with_rule(|s| s.contains("r").then_some(Color::Red))
220            .with_rule(|s| s.contains("g").then_some(Color::Green));
221
222        crate::testing::assert_deterministic_over(
223            &["red", "green", "ranger", "neither", ""],
224            |&input| c.classify(input),
225        );
226    }
227
228    #[test]
229    fn failure_classifier_wraps_free_function() {
230        // The canonical first consumer: shigoto_types::failure::classify
231        // available polymorphically as a Classifier impl.
232        let c = FailureClassifier;
233        assert_eq!(
234            c.classify("does not exist"),
235            FailureKind::Declarative,
236            "declarative pattern routes correctly"
237        );
238        assert_eq!(
239            c.classify("network timeout"),
240            FailureKind::Transient,
241            "non-declarative stderr defaults to Transient"
242        );
243    }
244
245    #[test]
246    fn failure_classifier_via_dyn() {
247        // The polymorphic surface: pass FailureClassifier as a
248        // &dyn Classifier<str, FailureKind> to higher-level pipelines.
249        let c: &dyn Classifier<str, FailureKind> = &FailureClassifier;
250        assert_eq!(
251            c.classify("infinite recursion encountered"),
252            FailureKind::Declarative
253        );
254        assert_eq!(c.classify("connection refused"), FailureKind::Transient);
255    }
256}