gen_platform/composed.rs
1//! `ComposedDispatcher` — closure-under-composition for the typed
2//! dispatcher catamorphism. Given N sealed dispatchers over
3//! disjoint variant universes, produce a single dispatcher that
4//! handles any variant from any of them by serde-tag dispatch.
5//!
6//! Proves the algebraic law from `theory/QUIRK-APPLIER.md`
7//! §IV-bis.3.d: "the combinator is closed under composition;
8//! polyglot caixa packages (Rust+Python+Helm) compose multiple
9//! ecosystem appliers in sequence into a unified override stack."
10//!
11//! ## Type erasure
12//!
13//! The composed view operates on `serde_json::Value` variants
14//! (each carrying a `"kind"` field). Source dispatchers stay
15//! typed (`SealedDispatcher<V, C, O>`); composition walks the
16//! per-kind helpers through a thin adapter that round-trips
17//! variants through serde_json. This is the same shape the
18//! substrate's `mk-typed-dispatcher.nix` consumes (untyped JSON
19//! values keyed by `kind`), so composed Rust dispatchers and
20//! composed Nix dispatchers share semantics by construction.
21
22use std::collections::BTreeMap;
23
24use crate::dispatcher::{DispatcherError, SealedDispatcher};
25use crate::merge::MergeStrategy;
26use gen_types::TypedDispatcher;
27
28/// One per-kind helper as installed in the composed dispatcher.
29/// Box of Fn over the untyped JSON variant + context.
30type ComposedHelper<C, O> =
31 Box<dyn Fn(&serde_json::Value, &mut C) -> O + Send + Sync + 'static>;
32
33/// Composition of multiple `SealedDispatcher`s over disjoint
34/// variant universes. Operates on `serde_json::Value` variants
35/// keyed by the `"kind"` field. Use `.add(...)` to extend.
36pub struct ComposedDispatcher<C, O> {
37 helpers: BTreeMap<String, ComposedHelper<C, O>>,
38 strategy: MergeStrategy,
39 /// Per-source labels mapped to the set of kinds they contribute.
40 /// Surfaces the composition shape for diagnostic queries
41 /// without requiring callers to track the input dispatchers.
42 sources: Vec<ComposedSource>,
43}
44
45/// One source dispatcher's contribution to the composition.
46#[derive(Debug, Clone)]
47pub struct ComposedSource {
48 /// Caller-supplied label (e.g. `"gen.cargo"`, `"gen.helm"`).
49 pub label: String,
50 /// Kinds the source contributed.
51 pub kinds: Vec<String>,
52}
53
54impl<C, O> Default for ComposedDispatcher<C, O> {
55 fn default() -> Self {
56 Self {
57 helpers: BTreeMap::new(),
58 strategy: MergeStrategy::default(),
59 sources: Vec::new(),
60 }
61 }
62}
63
64impl<C, O> ComposedDispatcher<C, O>
65where
66 O: Default,
67{
68 /// Start a fresh composition with the default strategy.
69 #[must_use]
70 pub fn new() -> Self {
71 Self::default()
72 }
73
74 /// Override the merge strategy.
75 #[must_use]
76 pub fn with_strategy(mut self, strategy: MergeStrategy) -> Self {
77 self.strategy = strategy;
78 self
79 }
80
81 /// The strategy this composition uses.
82 #[must_use]
83 pub fn strategy(&self) -> MergeStrategy {
84 self.strategy
85 }
86
87 /// Extend with another sealed dispatcher. Kinds must be
88 /// disjoint from any already-added source — overlap raises
89 /// `DuplicateHelper` (which here means "the composition's
90 /// kind universes overlap; pick one source for each kind").
91 ///
92 /// `label` is a fleet-unique tag for the source (typically
93 /// matches the `register_dispatcher!` label).
94 ///
95 /// # Errors
96 /// Returns `DispatcherError::DuplicateHelper` if any of the
97 /// added dispatcher's kinds is already covered.
98 pub fn add<V>(
99 mut self,
100 label: &str,
101 source: SealedDispatcher<V, C, O>,
102 ) -> Result<Self, DispatcherError>
103 where
104 V: TypedDispatcher
105 + serde::Serialize
106 + serde::de::DeserializeOwned
107 + 'static,
108 C: 'static,
109 O: 'static,
110 {
111 let kinds = source.saturation_witness().into_iter().map(String::from).collect::<Vec<_>>();
112 for k in &kinds {
113 if self.helpers.contains_key(k) {
114 return Err(DispatcherError::DuplicateHelper { kind: k.clone() });
115 }
116 }
117 // Convert the source's typed helpers into untyped JSON
118 // helpers. One closure per kind that round-trips
119 // serde_json::Value into V and dispatches through the
120 // typed dispatcher.
121 let arc = std::sync::Arc::new(source);
122 for k in &kinds {
123 let arc2 = arc.clone();
124 let kind_owned = k.clone();
125 self.helpers.insert(
126 kind_owned,
127 Box::new(move |value: &serde_json::Value, ctx: &mut C| {
128 let typed: V = match serde_json::from_value(value.clone()) {
129 Ok(t) => t,
130 Err(_) => return O::default(),
131 };
132 let r = arc2.apply_each(&[typed], ctx);
133 r.into_iter().next().unwrap_or_default()
134 }),
135 );
136 }
137 self.sources.push(ComposedSource {
138 label: label.to_string(),
139 kinds,
140 });
141 Ok(self)
142 }
143
144 /// Apply each `serde_json::Value` variant against the
145 /// composed helpers. Unknown kinds skip silently — use
146 /// `try_apply_each` for typed `UnknownKind` reporting.
147 pub fn apply_each(&self, variants: &[serde_json::Value], ctx: &mut C) -> Vec<O>
148 where
149 O: Default,
150 {
151 let mut out = Vec::with_capacity(variants.len());
152 for v in variants {
153 let Some(kind) = v.get("kind").and_then(|k| k.as_str()) else {
154 continue;
155 };
156 if let Some(h) = self.helpers.get(kind) {
157 out.push(h(v, ctx));
158 }
159 }
160 out
161 }
162
163 /// `apply_each` with explicit error surfacing.
164 ///
165 /// # Errors
166 /// Returns `DispatcherError::UnknownKind` if a variant's kind
167 /// has no registered helper across the composition.
168 pub fn try_apply_each(
169 &self,
170 variants: &[serde_json::Value],
171 ctx: &mut C,
172 ) -> Result<Vec<O>, DispatcherError>
173 where
174 O: Default,
175 {
176 let mut out = Vec::with_capacity(variants.len());
177 for v in variants {
178 let kind = v
179 .get("kind")
180 .and_then(|k| k.as_str())
181 .ok_or_else(|| DispatcherError::UnknownKind {
182 kind: "<no kind field>".to_string(),
183 })?;
184 let Some(h) = self.helpers.get(kind) else {
185 return Err(DispatcherError::UnknownKind {
186 kind: kind.to_string(),
187 });
188 };
189 out.push(h(v, ctx));
190 }
191 Ok(out)
192 }
193
194 /// Number of helpers in the composition (sum of source
195 /// helpers; equal to the disjoint union of source variant
196 /// universes).
197 #[must_use]
198 pub fn helper_count(&self) -> usize {
199 self.helpers.len()
200 }
201
202 /// Read-only view of every kind covered by the composition,
203 /// in canonical sorted order.
204 #[must_use]
205 pub fn covered_kinds(&self) -> Vec<&str> {
206 self.helpers.keys().map(String::as_str).collect()
207 }
208
209 /// Read-only view of the sources contributing to the
210 /// composition (label + kinds each contributed).
211 #[must_use]
212 pub fn sources(&self) -> &[ComposedSource] {
213 &self.sources
214 }
215}