gen_platform/dispatcher.rs
1//! Runtime `Dispatcher<V, C, O>` — the Rust-side peer of
2//! `mk-typed-dispatcher.nix`. Same catamorphism, native enum types.
3//!
4//! Type parameters:
5//!
6//! - `V`: the typed variant universe (a Rust enum implementing
7//! `gen_types::TypedDispatcher`). Used for compile-time coverage
8//! reflection.
9//! - `C`: the runtime context the helpers mutate / read.
10//! - `O`: the override type each helper produces. Folded across
11//! the variant list per the `MergeStrategy`.
12//!
13//! Sealing: after `Dispatcher::new()` + N `helper(...)` calls, the
14//! consumer calls `into_sealed()`. The seal asserts that every
15//! kind in `V::variant_kinds()` has a registered helper — silent
16//! unknown variants become impossible at the type level. Missing
17//! coverage returns a typed `DispatcherError`.
18//!
19//! Anti-pattern this primitive eliminates: hand-rolled `match`
20//! ladders sprinkled across pleme-io controllers/daemons/migration
21//! runners. Pillar 12 (generation over composition) applied at
22//! the Rust runtime layer.
23
24use std::collections::BTreeMap;
25use std::marker::PhantomData;
26
27use gen_types::TypedDispatcher;
28
29use crate::merge::MergeStrategy;
30
31/// Build-time / seal-time dispatcher error.
32#[derive(Debug, thiserror::Error)]
33pub enum DispatcherError {
34 /// One or more variant kinds reflected by `V::variant_kinds()`
35 /// have no registered helper. Surfaces the exact missing kinds
36 /// so the consumer can fix at compile time.
37 #[error("dispatcher missing helpers for variant kinds: {missing:?}")]
38 MissingCoverage { missing: Vec<String> },
39 /// Two helpers registered for the same kind. The dispatcher
40 /// refuses ambiguity — pick one.
41 #[error("dispatcher already has a helper for kind '{kind}' — register only once")]
42 DuplicateHelper { kind: String },
43 /// An applied variant carried a kind not in the variant
44 /// universe. Can only happen if the variant enum was hand-
45 /// constructed via serde without the typed enum (i.e. the
46 /// reflection contract was violated upstream).
47 #[error("variant kind '{kind}' is unknown to the dispatcher (not in V::variant_kinds())")]
48 UnknownKind { kind: String },
49}
50
51type Helper<V, C, O> = Box<dyn Fn(&V, &mut C) -> O + Send + Sync + 'static>;
52
53/// Unsealed dispatcher builder. Register helpers per variant kind,
54/// then call `into_sealed()` to prove coverage.
55pub struct Dispatcher<V, C, O>
56where
57 V: TypedDispatcher,
58{
59 helpers: BTreeMap<String, Helper<V, C, O>>,
60 strategy: MergeStrategy,
61 _marker: PhantomData<fn() -> V>,
62}
63
64impl<V, C, O> Default for Dispatcher<V, C, O>
65where
66 V: TypedDispatcher,
67{
68 fn default() -> Self {
69 Self {
70 helpers: BTreeMap::new(),
71 strategy: MergeStrategy::default(),
72 _marker: PhantomData,
73 }
74 }
75}
76
77impl<V, C, O> Dispatcher<V, C, O>
78where
79 V: TypedDispatcher,
80{
81 /// Start a fresh dispatcher with the default `OverrideLast`
82 /// merge strategy.
83 #[must_use]
84 pub fn new() -> Self {
85 Self::default()
86 }
87
88 /// Override the merge strategy used by `apply` when folding
89 /// per-variant overrides. Defaults to `OverrideLast`.
90 #[must_use]
91 pub fn with_strategy(mut self, strategy: MergeStrategy) -> Self {
92 self.strategy = strategy;
93 self
94 }
95
96 /// Register a helper for a kind. Returns `DuplicateHelper` if
97 /// the kind already has one — pleme-io's typed-dispatch rule
98 /// is: one kind, one helper.
99 ///
100 /// # Errors
101 /// Returns `DispatcherError::DuplicateHelper` if a helper for
102 /// `kind` has already been registered.
103 pub fn helper<F>(mut self, kind: &str, f: F) -> Result<Self, DispatcherError>
104 where
105 F: Fn(&V, &mut C) -> O + Send + Sync + 'static,
106 {
107 if self.helpers.contains_key(kind) {
108 return Err(DispatcherError::DuplicateHelper {
109 kind: kind.to_string(),
110 });
111 }
112 self.helpers.insert(kind.to_string(), Box::new(f));
113 Ok(self)
114 }
115
116 /// Convenience: register a helper, panic on duplicate. Use only
117 /// when you control both sides of the registration and want
118 /// builder-style ergonomics.
119 #[must_use]
120 pub fn with_helper<F>(self, kind: &str, f: F) -> Self
121 where
122 F: Fn(&V, &mut C) -> O + Send + Sync + 'static,
123 {
124 self.helper(kind, f).expect("duplicate helper")
125 }
126
127 /// Seal the dispatcher — asserts every kind in
128 /// `V::variant_kinds()` has a registered helper. Returns a
129 /// `SealedDispatcher` that can apply variants without runtime
130 /// coverage checks.
131 ///
132 /// # Errors
133 /// Returns `DispatcherError::MissingCoverage` if any kind in
134 /// the variant universe has no registered helper.
135 pub fn into_sealed(self) -> Result<SealedDispatcher<V, C, O>, DispatcherError> {
136 let universe = V::variant_kinds();
137 let mut missing = Vec::new();
138 for k in &universe {
139 if !self.helpers.contains_key(*k) {
140 missing.push((*k).to_string());
141 }
142 }
143 if !missing.is_empty() {
144 return Err(DispatcherError::MissingCoverage { missing });
145 }
146 Ok(SealedDispatcher {
147 helpers: self.helpers,
148 strategy: self.strategy,
149 _marker: PhantomData,
150 })
151 }
152}
153
154/// Post-seal dispatcher — coverage proved at construction. `apply`
155/// folds helpers over the variant list using the configured merge
156/// strategy.
157pub struct SealedDispatcher<V, C, O>
158where
159 V: TypedDispatcher,
160{
161 helpers: BTreeMap<String, Helper<V, C, O>>,
162 strategy: MergeStrategy,
163 _marker: PhantomData<fn() -> V>,
164}
165
166impl<V, C, O> SealedDispatcher<V, C, O>
167where
168 V: TypedDispatcher,
169 O: Default,
170{
171 /// The strategy the dispatcher was sealed with.
172 #[must_use]
173 pub fn strategy(&self) -> MergeStrategy {
174 self.strategy
175 }
176
177 /// Apply each variant via the matching helper and collect every
178 /// produced override into a `Vec<O>`. The caller decides how to
179 /// fold — typical pattern: `MergeStrategy::Accumulate` consumers
180 /// keep the Vec; `MergeStrategy::OverrideLast` consumers
181 /// reduce it to a single `O` via their own `Monoid`-flavored
182 /// reducer (the `O` type's `Default + Add` impl).
183 pub fn apply_each(&self, variants: &[V], ctx: &mut C) -> Vec<O>
184 where
185 V: serde::Serialize,
186 {
187 let mut out = Vec::with_capacity(variants.len());
188 for v in variants {
189 let kind = serde_variant_kind(v);
190 if let Some(h) = self.helpers.get(&kind) {
191 out.push(h(v, ctx));
192 }
193 // Cannot reach here normally: seal guarantees coverage,
194 // and the variant came from a typed V (no string-key
195 // construction). Defensive: skip silently. Consumers
196 // with a stronger contract can use `try_apply_each`
197 // below for typed UnknownKind reporting.
198 }
199 out
200 }
201
202 /// `apply_each` with explicit error surfacing — returns
203 /// `UnknownKind` if a variant's serialized kind isn't in the
204 /// helpers table. In typed pleme-io use this never fires; it's
205 /// available for defensive code paths.
206 ///
207 /// # Errors
208 /// Returns `DispatcherError::UnknownKind` if a variant's
209 /// serialized kind has no registered helper.
210 pub fn try_apply_each(
211 &self,
212 variants: &[V],
213 ctx: &mut C,
214 ) -> Result<Vec<O>, DispatcherError>
215 where
216 V: serde::Serialize,
217 {
218 let mut out = Vec::with_capacity(variants.len());
219 for v in variants {
220 let kind = serde_variant_kind(v);
221 let Some(h) = self.helpers.get(&kind) else {
222 return Err(DispatcherError::UnknownKind { kind });
223 };
224 out.push(h(v, ctx));
225 }
226 Ok(out)
227 }
228
229 /// Saturation witness — list of every kind the dispatcher
230 /// covers, in canonical order. Equal to `V::variant_kinds()`
231 /// by construction (the seal proved this). Useful for catalog
232 /// tooling that wants to expose what a dispatcher accepts
233 /// without recomputing the universe from the trait.
234 #[must_use]
235 pub fn saturation_witness(&self) -> Vec<&str> {
236 self.helpers.keys().map(String::as_str).collect()
237 }
238
239 /// The number of registered helpers. Equal to
240 /// `V::variant_count()` by construction.
241 #[must_use]
242 pub fn helper_count(&self) -> usize {
243 self.helpers.len()
244 }
245}
246
247impl<V, C, O> SealedDispatcher<V, C, O>
248where
249 V: TypedDispatcher + serde::Serialize,
250 C: Clone,
251 O: Default + Clone + PartialEq,
252{
253 /// Determinism law — assert that applying the same variants
254 /// twice (against a clone of the context) produces the same
255 /// output vector. The substrate-wide invariant from
256 /// `theory/QUIRK-APPLIER.md` §IV-bis.2: "determinism — same
257 /// input → same output, property-test."
258 ///
259 /// Returns `true` if the law holds for this input; `false` if
260 /// the helpers carry observable side effects that diverge on
261 /// re-apply. Consumers typically `assert!()` this in tests.
262 pub fn is_deterministic(&self, variants: &[V], ctx: &C) -> bool {
263 let mut a = ctx.clone();
264 let mut b = ctx.clone();
265 let r1 = self.apply_each(variants, &mut a);
266 let r2 = self.apply_each(variants, &mut b);
267 r1 == r2
268 }
269
270 /// Idempotence law — assert that applying the variant list
271 /// twice in sequence produces the same output as applying it
272 /// once (for the `Override`/last-wins merge family). Consumers
273 /// whose helpers accumulate state (counters, append-only logs)
274 /// will return `false` here; that's the diagnostic — they're
275 /// not idempotent in the algebraic sense.
276 ///
277 /// Returns `true` iff `apply_each(variants) == apply_each(variants ++ variants)`.
278 pub fn is_idempotent(&self, variants: &[V], ctx: &C) -> bool
279 where
280 V: Clone,
281 {
282 let mut a = ctx.clone();
283 let r1 = self.apply_each(variants, &mut a);
284
285 let mut b = ctx.clone();
286 let doubled: Vec<V> = variants.iter().chain(variants.iter()).cloned().collect();
287 let r2 = self.apply_each(&doubled, &mut b);
288
289 // For OverrideLast semantics: the second pass replays the
290 // same variants, and the last application of each kind wins
291 // — so r2's tail (last len(variants) entries) should equal
292 // r1.
293 r2.len() == 2 * r1.len()
294 && r2.iter().skip(r1.len()).cloned().collect::<Vec<_>>() == r1
295 }
296}
297
298/// Pull the kebab-case serde tag out of a `#[serde(tag = "kind", ...)]`
299/// variant. Round-trips through `serde_json::Value` once — cheap and
300/// correct independent of the enum's field shape.
301fn serde_variant_kind<V: serde::Serialize>(v: &V) -> String {
302 serde_json::to_value(v)
303 .ok()
304 .and_then(|val| val.get("kind").and_then(|k| k.as_str().map(String::from)))
305 .unwrap_or_default()
306}