mako_engine/pid_router.rs
1//! PID-to-workflow routing table.
2//!
3//! Every inbound EDIFACT message carries a `Prüfidentifikator` (PID) that
4//! identifies the MaKo process family and operation. The `PidRouter` maps
5//! numeric PID values to workflow names, enabling dispatchers to instantiate
6//! the correct [`Workflow`] implementation without ad-hoc `match` chains.
7//!
8//! # Mutability contract — build-time only
9//!
10//! `PidRouter` uses `&mut self` for all registrations. In normal engine usage
11//! the router is populated **once** during `EngineBuilder::build()` and is
12//! subsequently sealed inside `EngineContext` behind a shared `&PidRouter`
13//! reference. There is no runtime mutation path — all PIDs must be registered
14//! before the engine starts serving messages.
15//!
16//! This is intentional: mutation after startup would race with concurrent
17//! dispatch calls and require a `RwLock`. The read-only runtime path is
18//! therefore always lock-free.
19//!
20//! # BDEW PID ranges (incomplete — register all PIDs for your process families)
21//!
22//! | Range | Process family |
23//! |---|---|
24//! | 11001–11099 | WiM Gerätewechsel (UTILMD) |
25//! | 13003 | MABIS Bilanzkreisabrechnung (MSCONS) |
26//! | 13002–13028 | Messwerte Gas/Strom/Redispatch (MSCONS) — fragmented across GaBi Gas, Redispatch, GPKE support |
27//! | 17001–17011 | WiM MSB commissioning (ORDERS) |
28//! | 17101–17135 | WiM Stammdaten / Konfiguration (ORDERS) |
29//! | 31001–31002, 31004–31008 | GPKE Netznutzungsabrechnung / MMM-Rechnung (INVOIC) |
30//! | 31003, 31009 | WiM-Rechnung / MSB-Rechnung (INVOIC) — WiM domain |
31//! | 31010 | Kapazitätsrechnung (INVOIC) — Kapazitätsabrechnung Ausspeisepunkte Gas |
32//! | 31011 | Rechnung sonstige Leistung (INVOIC) — AWH Sperrprozesse Gas |
33//! | 33001–33004 | REMADV Bestätigung/Abweisung — paired with INVOIC workflows |
34//! | 37000–37006 | PARTIN Kommunikationsdaten Strom (GPKE Teil 4) |
35//! | 37008–37014 | PARTIN Kommunikationsdaten Gas (GeLi Gas 2.0) |
36//! | 39000–39001 | ORDCHG Stornierung Sperr-/Entsperrauftrag (AWH Sperrprozesse Gas) |
37//! | 39002 | ORDCHG Stornierung Bestellung (WiM Strom Teil 2) |
38//! | 44001–44018 | GeLi Gas Lieferantenwechsel (UTILMD G) |
39//! | 55001–55018 | GPKE Lieferantenwechsel / Kündigung (UTILMD Strom) |
40//! | 55555 | GPKE Teil 4 — Anfrage Daten der individuellen Bestellung (UTILMD Strom) |
41//!
42//! # Usage
43//!
44//! ```rust
45//! use mako_engine::pid_router::PidRouter;
46//!
47//! let mut router = PidRouter::new();
48//! router.register(55001, "GpkeSupplierChange");
49//! router.register(55002, "GpkeSupplierChange"); // Same workflow, different step
50//!
51//! assert_eq!(router.route(55001), Some("GpkeSupplierChange"));
52//! assert_eq!(router.route(99999), None);
53//! assert_eq!(router.len(), 2);
54//! ```
55//!
56//! [`Workflow`]: crate::workflow::Workflow
57
58use std::collections::HashMap;
59
60// ── PidRouter ─────────────────────────────────────────────────────────────────
61
62/// A static mapping from `Prüfidentifikator` (PID) values to workflow names.
63///
64/// Register all PIDs your platform handles before starting the engine. At
65/// runtime, call [`route`] to look up the workflow name for an inbound PID.
66///
67/// The workflow name matches [`WorkflowId::name`] — use it to select the
68/// correct `Workflow` implementation in your message dispatcher.
69///
70/// # Mutability contract
71///
72/// `PidRouter` exposes a `&mut self` API for registrations (`register`). In
73/// the engine this mutability is exercised **only** during
74/// [`EngineBuilder::build`] — after that the router is owned by
75/// [`EngineContext`] and only shared references are available at runtime.
76/// There is no way to mutate the router from an async dispatch handler.
77///
78/// Duplicate registrations silently replace the previous mapping; the last
79/// call wins. Use `cargo xtask validate-pruefids` to detect PID conflicts
80/// between modules before they reach production.
81///
82/// # Building a complete router
83///
84/// In your `main` or integration module, register every PID that the platform
85/// must handle. PIDs not registered will return `None` from [`route`], causing
86/// the dispatcher to dead-letter the message cleanly.
87///
88/// ```rust
89/// use mako_engine::pid_router::PidRouter;
90///
91/// fn build_router() -> PidRouter {
92/// let mut r = PidRouter::new();
93/// // GPKE Lieferantenwechsel (BK6-22-024) — UTILMD
94/// r.register(55001, "GpkeSupplierChange");
95/// r.register(55002, "GpkeSupplierChange");
96/// r.register(55003, "GpkeSupplierChange");
97/// r.register(55004, "GpkeSupplierChange");
98/// r
99/// }
100/// ```
101///
102/// [`route`]: PidRouter::route
103/// [`WorkflowId::name`]: crate::version::WorkflowId::name
104/// [`EngineBuilder::build`]: crate::builder::EngineBuilder::build
105/// [`EngineContext`]: crate::builder::EngineContext
106#[derive(Debug, Default, Clone)]
107pub struct PidRouter {
108 table: HashMap<u32, Box<str>>,
109 /// Tracks which module registered each PID for conflict detection.
110 ///
111 /// Populated by [`register_with_module`]; used to produce actionable
112 /// panic messages when two modules register the same PID to different workflows.
113 ///
114 /// [`register_with_module`]: PidRouter::register_with_module
115 registered_by: HashMap<u32, Box<str>>,
116}
117
118impl PidRouter {
119 /// Create an empty router.
120 #[must_use]
121 pub fn new() -> Self {
122 Self::default()
123 }
124
125 /// Register `pid` as routing to `workflow_name`.
126 ///
127 /// If `pid` was already registered, the previous mapping is silently
128 /// replaced. Call this only at build time (via [`EngineModule::register_pids`]);
129 /// the method is `&mut self` to prevent accidental runtime mutation once the
130 /// router is sealed inside [`EngineContext`].
131 ///
132 /// Accepts any string — `&'static str`, `String`, or `Box<str>`.
133 ///
134 /// For conflict-detected registration (preferred in multi-module builds),
135 /// use [`register_with_module`] instead.
136 ///
137 /// [`EngineModule::register_pids`]: crate::builder::EngineModule::register_pids
138 /// [`EngineContext`]: crate::builder::EngineContext
139 /// [`register_with_module`]: PidRouter::register_with_module
140 pub fn register(&mut self, pid: u32, workflow_name: impl Into<Box<str>>) {
141 let wf = workflow_name.into();
142 self.table.insert(pid, wf);
143 }
144
145 /// Register `pid` → `workflow_name` with module-attribution conflict detection.
146 ///
147 /// # Panics
148 ///
149 /// Panics at **build time** (before the engine starts) if `pid` is already
150 /// registered to a *different* workflow name by a *different* module. Two
151 /// modules registering the same PID to the **same** workflow are silently
152 /// accepted (idempotent).
153 ///
154 /// Use [`DeploymentRoles`] to prevent two modules from registering the same
155 /// PID when only one role is active:
156 ///
157 /// ```rust,ignore
158 /// // Both GPKE (NB role) and WiM (nMSB role) register 19001 → different workflows.
159 /// // Set explicit roles so only one module's conditional block fires:
160 /// use mako_engine::marktrolle::{DeploymentRoles, Marktrolle};
161 /// let roles = DeploymentRoles::from_roles([Marktrolle::Nb]);
162 /// // Now only GPKE registers 19001 → "gpke-konfiguration".
163 /// ```
164 ///
165 /// [`DeploymentRoles`]: crate::marktrolle::DeploymentRoles
166 pub fn register_with_module(
167 &mut self,
168 pid: u32,
169 workflow_name: impl Into<Box<str>>,
170 module: &str,
171 ) {
172 let wf = workflow_name.into();
173 if let Some(existing_wf) = self.table.get(&pid) {
174 if *existing_wf != wf {
175 let existing_mod = self
176 .registered_by
177 .get(&pid)
178 .map_or("<unknown>", Box::as_ref);
179 panic!(
180 "PID {pid} routing conflict:\n \
181 module '{module}' tried to register PID {pid} → '{wf}'\n \
182 but it was already registered → '{existing_wf}' by module '{existing_mod}'\n \
183 Hint: use DeploymentRoles to prevent conflicting modules from \
184 both registering shared PIDs (e.g. 19001/19002 are claimed by \
185 gpke-konfiguration for NB role and wim-geraeteubernahme for nMSB role).\n \
186 Set EngineBuilder::with_deployment_roles(DeploymentRoles::nb()) to keep \
187 only the NB-role registration."
188 );
189 }
190 }
191 self.table.insert(pid, wf);
192 self.registered_by.insert(pid, module.into());
193 }
194
195 /// Look up the workflow name for `pid`.
196 ///
197 /// Returns `None` when `pid` has not been registered. The caller should
198 /// dead-letter the message and return an appropriate error to the sender
199 /// rather than panicking.
200 #[must_use]
201 pub fn route(&self, pid: u32) -> Option<&str> {
202 self.table.get(&pid).map(Box::as_ref)
203 }
204
205 /// Return an iterator over all registered PID values.
206 ///
207 /// Useful for validation (e.g. comparing against PIDs declared in
208 /// AHB profile JSON files to detect missing workflow implementations).
209 pub fn registered_pids(&self) -> impl Iterator<Item = u32> + '_ {
210 self.table.keys().copied()
211 }
212
213 /// Return the number of registered PID mappings.
214 #[must_use]
215 pub fn len(&self) -> usize {
216 self.table.len()
217 }
218
219 /// Return `true` when no PIDs have been registered.
220 #[must_use]
221 pub fn is_empty(&self) -> bool {
222 self.table.is_empty()
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn route_registered_pid() {
232 let mut r = PidRouter::new();
233 r.register(55001, "GpkeSupplierChange");
234 assert_eq!(r.route(55001), Some("GpkeSupplierChange"));
235 }
236
237 #[test]
238 fn route_unregistered_pid_returns_none() {
239 let r = PidRouter::new();
240 assert_eq!(r.route(55001), None);
241 }
242
243 #[test]
244 fn register_overwrites_previous_mapping() {
245 let mut r = PidRouter::new();
246 r.register(55001, "OldWorkflow");
247 r.register(55001, "NewWorkflow");
248 assert_eq!(r.route(55001), Some("NewWorkflow"));
249 assert_eq!(r.len(), 1);
250 }
251
252 #[test]
253 fn registered_pids_covers_all_entries() {
254 let mut r = PidRouter::new();
255 r.register(55001, "A");
256 r.register(55002, "B");
257 r.register(11001, "C");
258
259 let mut pids: Vec<u32> = r.registered_pids().collect();
260 pids.sort_unstable();
261 assert_eq!(pids, [11001, 55001, 55002]);
262 }
263
264 #[test]
265 fn multiple_pids_same_workflow() {
266 let mut r = PidRouter::new();
267 r.register(55001, "GpkeSupplierChange");
268 r.register(55002, "GpkeSupplierChange");
269 r.register(55003, "GpkeSupplierChange");
270
271 assert_eq!(r.len(), 3);
272 for pid in [55001, 55002, 55003] {
273 assert_eq!(r.route(pid), Some("GpkeSupplierChange"));
274 }
275 }
276
277 #[test]
278 fn is_empty_and_len() {
279 let mut r = PidRouter::new();
280 assert!(r.is_empty());
281 r.register(55001, "W");
282 assert!(!r.is_empty());
283 assert_eq!(r.len(), 1);
284 }
285}