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}