Skip to main content

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