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