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> = Box<dyn Fn(&serde_json::Value, &mut C) -> O + Send + Sync + 'static>;
31
32/// Composition of multiple `SealedDispatcher`s over disjoint
33/// variant universes. Operates on `serde_json::Value` variants
34/// keyed by the `"kind"` field. Use `.add(...)` to extend.
35pub struct ComposedDispatcher<C, O> {
36 helpers: BTreeMap<String, ComposedHelper<C, O>>,
37 strategy: MergeStrategy,
38 /// Per-source labels mapped to the set of kinds they contribute.
39 /// Surfaces the composition shape for diagnostic queries
40 /// without requiring callers to track the input dispatchers.
41 sources: Vec<ComposedSource>,
42}
43
44/// One source dispatcher's contribution to the composition.
45#[derive(Debug, Clone)]
46pub struct ComposedSource {
47 /// Caller-supplied label (e.g. `"gen.cargo"`, `"gen.helm"`).
48 pub label: String,
49 /// Kinds the source contributed.
50 pub kinds: Vec<String>,
51}
52
53impl<C, O> Default for ComposedDispatcher<C, O> {
54 fn default() -> Self {
55 Self {
56 helpers: BTreeMap::new(),
57 strategy: MergeStrategy::default(),
58 sources: Vec::new(),
59 }
60 }
61}
62
63impl<C, O> ComposedDispatcher<C, O>
64where
65 O: Default,
66{
67 /// Start a fresh composition with the default strategy.
68 #[must_use]
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 /// Override the merge strategy.
74 #[must_use]
75 pub fn with_strategy(mut self, strategy: MergeStrategy) -> Self {
76 self.strategy = strategy;
77 self
78 }
79
80 /// The strategy this composition uses.
81 #[must_use]
82 pub fn strategy(&self) -> MergeStrategy {
83 self.strategy
84 }
85
86 /// Extend with another sealed dispatcher. Kinds must be
87 /// disjoint from any already-added source — overlap raises
88 /// `DuplicateHelper` (which here means "the composition's
89 /// kind universes overlap; pick one source for each kind").
90 ///
91 /// `label` is a fleet-unique tag for the source (typically
92 /// matches the `register_dispatcher!` label).
93 ///
94 /// # Errors
95 /// Returns `DispatcherError::DuplicateHelper` if any of the
96 /// added dispatcher's kinds is already covered.
97 pub fn add<V>(
98 mut self,
99 label: &str,
100 source: SealedDispatcher<V, C, O>,
101 ) -> Result<Self, DispatcherError>
102 where
103 V: TypedDispatcher + serde::Serialize + serde::de::DeserializeOwned + 'static,
104 C: 'static,
105 O: 'static,
106 {
107 let kinds = source
108 .saturation_witness()
109 .into_iter()
110 .map(String::from)
111 .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.get("kind").and_then(|k| k.as_str()).ok_or_else(|| {
179 DispatcherError::UnknownKind {
180 kind: "<no kind field>".to_string(),
181 }
182 })?;
183 let Some(h) = self.helpers.get(kind) else {
184 return Err(DispatcherError::UnknownKind {
185 kind: kind.to_string(),
186 });
187 };
188 out.push(h(v, ctx));
189 }
190 Ok(out)
191 }
192
193 /// Number of helpers in the composition (sum of source
194 /// helpers; equal to the disjoint union of source variant
195 /// universes).
196 #[must_use]
197 pub fn helper_count(&self) -> usize {
198 self.helpers.len()
199 }
200
201 /// Read-only view of every kind covered by the composition,
202 /// in canonical sorted order.
203 #[must_use]
204 pub fn covered_kinds(&self) -> Vec<&str> {
205 self.helpers.keys().map(String::as_str).collect()
206 }
207
208 /// Read-only view of the sources contributing to the
209 /// composition (label + kinds each contributed).
210 #[must_use]
211 pub fn sources(&self) -> &[ComposedSource] {
212 &self.sources
213 }
214}