mako_redispatch/router.rs
1//! `RedispatchRouter` — maps [`RedispatchDocumentKind`]s to workflow names.
2//!
3//! Redispatch 2.0 uses **CIM/XML documents** for the primary data exchange,
4//! not EDIFACT `RFF+Z13` Prüfidentifikatoren. Routing is therefore based on
5//! [`RedispatchDocumentKind`] — a domain-owned enum that mirrors the XML root
6//! element taxonomy but carries no dependency on the `redispatch-xml` parse crate.
7//!
8//! # Layer boundary
9//!
10//! Parsing (`redispatch_xml::parse`) stays at the `makod` transport boundary.
11//! The inbound dispatcher converts the parse result to a [`RedispatchDocumentKind`]
12//! before calling [`RedispatchRouter::route`]:
13//!
14//! ```rust,ignore
15//! // In makod's AS4 ingest path — transport boundary only:
16//! let doc = redispatch_xml::parse(bytes)?;
17//! let kind = RedispatchDocumentKind::from(doc.document_type()); // From impl in makod
18//! let workflow_name = router.route(kind)?;
19//! // resume the workflow process and dispatch the command …
20//! ```
21//!
22//! [`RedispatchModule`]: crate::RedispatchModule
23
24use std::fmt;
25
26use thiserror::Error;
27
28// ── RedispatchDocumentKind ────────────────────────────────────────────────────
29
30/// Domain-owned classification of a Redispatch 2.0 XML document.
31///
32/// Mirrors the nine XML root-element types defined by the BDEW Redispatch 2.0
33/// schema family, but is **independent of `redispatch-xml`**. The conversion
34/// from a parsed `redispatch_xml::documents::DocumentType` to this type is done
35/// at the `makod` transport boundary, keeping `mako-redispatch` free of any
36/// format-layer dependency.
37///
38/// # Non-exhaustive
39///
40/// New document types may be added as the BDEW schema evolves. Match with a
41/// `_` arm or use [`RedispatchRouter::is_registered`] for membership checks.
42#[non_exhaustive]
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum RedispatchDocumentKind {
45 /// `ActivationDocument` (ACO/ACR/AAR).
46 Activation,
47 /// `PlannedResourceScheduleDocument`.
48 PlannedResourceSchedule,
49 /// `AcknowledgementDocument`.
50 ///
51 /// Routed by correlation key (`ReceivingDocumentIdentification`), not by
52 /// document type. This variant exists for completeness and for
53 /// [`RedispatchRouter::is_registered`] guards; it must **not** be registered
54 /// in the type-based router.
55 Acknowledgement,
56 /// `Stammdaten`.
57 Stammdaten,
58 /// `StatusRequest_MarketDocument`.
59 StatusRequest,
60 /// `Unavailability_MarketDocument`.
61 Unavailability,
62 /// `Kaskade`.
63 Kaskade,
64 /// `NetworkConstraintDocument`.
65 NetworkConstraint,
66 /// `Kostenblatt`.
67 Kostenblatt,
68}
69
70impl fmt::Display for RedispatchDocumentKind {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 match self {
73 Self::Activation => write!(f, "ActivationDocument"),
74 Self::PlannedResourceSchedule => write!(f, "PlannedResourceScheduleDocument"),
75 Self::Acknowledgement => write!(f, "AcknowledgementDocument"),
76 Self::Stammdaten => write!(f, "Stammdaten"),
77 Self::StatusRequest => write!(f, "StatusRequest_MarketDocument"),
78 Self::Unavailability => write!(f, "Unavailability_MarketDocument"),
79 Self::Kaskade => write!(f, "Kaskade"),
80 Self::NetworkConstraint => write!(f, "NetworkConstraintDocument"),
81 Self::Kostenblatt => write!(f, "Kostenblatt"),
82 }
83 }
84}
85
86// ── Routing error ─────────────────────────────────────────────────────────────
87
88/// Error returned when no workflow is registered for a given document kind.
89#[derive(Debug, Error)]
90#[error("no Redispatch workflow registered for document kind {doc_kind}")]
91pub struct RoutingError {
92 /// The document kind that could not be routed.
93 pub doc_kind: RedispatchDocumentKind,
94}
95
96// ── RedispatchRouter ──────────────────────────────────────────────────────────
97
98/// Routes Redispatch 2.0 [`RedispatchDocumentKind`]s to workflow names.
99///
100/// Constructed by [`crate::RedispatchModule::build_router`] during `makod` startup.
101/// After construction the mapping is **sealed** — no runtime mutation.
102///
103/// # Registration order
104///
105/// Each [`RedispatchDocumentKind`] maps to exactly one workflow name. Duplicate
106/// registrations overwrite the previous entry (last-write-wins), analogous
107/// to `PidRouter`. Use `cargo xtask validate-pruefids` to detect conflicts.
108#[derive(Debug, Default, Clone)]
109pub struct RedispatchRouter {
110 /// Mapping from `RedispatchDocumentKind` discriminant to workflow name.
111 ///
112 /// Uses a fixed-size array indexed by `RedispatchDocumentKind as usize`.
113 entries: [Option<&'static str>; Self::TABLE_SIZE],
114}
115
116impl RedispatchRouter {
117 /// Number of distinct [`RedispatchDocumentKind`] variants (keep in sync with the enum).
118 const TABLE_SIZE: usize = 16;
119
120 /// Create an empty router (no routes registered).
121 #[must_use]
122 pub fn new() -> Self {
123 Self::default()
124 }
125
126 /// Register a mapping from `doc_kind` to `workflow_name`.
127 ///
128 /// If `doc_kind` was already registered the new entry **overwrites** the
129 /// previous one. This mirrors the `PidRouter` contract.
130 pub fn register(&mut self, doc_kind: RedispatchDocumentKind, workflow_name: &'static str) {
131 let idx = doc_kind as usize;
132 debug_assert!(
133 idx < Self::TABLE_SIZE,
134 "RedispatchDocumentKind discriminant {idx} exceeds RedispatchRouter table size; \
135 increase TABLE_SIZE"
136 );
137 if idx < Self::TABLE_SIZE {
138 self.entries[idx] = Some(workflow_name);
139 }
140 }
141
142 /// Look up the workflow name for `doc_kind`.
143 ///
144 /// Returns `Ok(name)` when a mapping was registered, or a [`RoutingError`]
145 /// when the document kind is unknown.
146 ///
147 /// # Errors
148 ///
149 /// Returns [`RoutingError`] when no workflow was registered for `doc_kind`.
150 pub fn route(&self, doc_kind: RedispatchDocumentKind) -> Result<&'static str, RoutingError> {
151 let idx = doc_kind as usize;
152 if idx < Self::TABLE_SIZE {
153 self.entries[idx].ok_or(RoutingError { doc_kind })
154 } else {
155 Err(RoutingError { doc_kind })
156 }
157 }
158
159 /// Return `true` if `doc_kind` has a registered workflow.
160 #[must_use]
161 pub fn is_registered(&self, doc_kind: RedispatchDocumentKind) -> bool {
162 self.route(doc_kind).is_ok()
163 }
164
165 /// Iterate over all registered `(RedispatchDocumentKind, workflow_name)` pairs.
166 pub fn iter(&self) -> impl Iterator<Item = (RedispatchDocumentKind, &'static str)> + '_ {
167 ALL_DOC_KINDS
168 .iter()
169 .filter_map(|&dk| self.entries[dk as usize].map(|name| (dk, name)))
170 }
171}
172
173/// Canonical ordered list of all [`RedispatchDocumentKind`] variants.
174///
175/// Used by [`RedispatchRouter::iter`] to iterate registrations in a stable order.
176const ALL_DOC_KINDS: &[RedispatchDocumentKind] = &[
177 RedispatchDocumentKind::Activation,
178 RedispatchDocumentKind::PlannedResourceSchedule,
179 RedispatchDocumentKind::Acknowledgement,
180 RedispatchDocumentKind::Stammdaten,
181 RedispatchDocumentKind::StatusRequest,
182 RedispatchDocumentKind::Unavailability,
183 RedispatchDocumentKind::Kaskade,
184 RedispatchDocumentKind::NetworkConstraint,
185 RedispatchDocumentKind::Kostenblatt,
186];
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn register_and_route_roundtrip() {
194 let mut router = RedispatchRouter::new();
195 router.register(RedispatchDocumentKind::Activation, "redispatch-aktivierung");
196 router.register(RedispatchDocumentKind::Stammdaten, "redispatch-stammdaten");
197
198 assert_eq!(
199 router.route(RedispatchDocumentKind::Activation).unwrap(),
200 "redispatch-aktivierung"
201 );
202 assert_eq!(
203 router.route(RedispatchDocumentKind::Stammdaten).unwrap(),
204 "redispatch-stammdaten"
205 );
206 }
207
208 #[test]
209 fn unregistered_doc_kind_returns_error() {
210 let router = RedispatchRouter::new();
211 assert!(router.route(RedispatchDocumentKind::Kostenblatt).is_err());
212 }
213
214 #[test]
215 fn duplicate_registration_overwrites() {
216 let mut router = RedispatchRouter::new();
217 router.register(RedispatchDocumentKind::Activation, "first");
218 router.register(RedispatchDocumentKind::Activation, "second");
219 assert_eq!(
220 router.route(RedispatchDocumentKind::Activation).unwrap(),
221 "second"
222 );
223 }
224
225 #[test]
226 fn is_registered_reflects_state() {
227 let mut router = RedispatchRouter::new();
228 assert!(!router.is_registered(RedispatchDocumentKind::Activation));
229 router.register(RedispatchDocumentKind::Activation, "redispatch-aktivierung");
230 assert!(router.is_registered(RedispatchDocumentKind::Activation));
231 }
232
233 #[test]
234 fn iter_returns_only_registered() {
235 let mut router = RedispatchRouter::new();
236 router.register(RedispatchDocumentKind::Activation, "redispatch-aktivierung");
237 router.register(RedispatchDocumentKind::Stammdaten, "redispatch-stammdaten");
238
239 let pairs: Vec<_> = router.iter().collect();
240 assert_eq!(pairs.len(), 2);
241 assert!(
242 pairs
243 .iter()
244 .any(|(dk, _)| *dk == RedispatchDocumentKind::Activation)
245 );
246 assert!(
247 pairs
248 .iter()
249 .any(|(dk, _)| *dk == RedispatchDocumentKind::Stammdaten)
250 );
251 }
252}