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). Unlike
109/// `PidRouter::register_with_module`, nothing refuses a second registration:
110/// the kinds are registered once, from this crate, in [`crate::router`].
111#[derive(Debug, Default, Clone)]
112pub struct RedispatchRouter {
113 /// Mapping from `RedispatchDocumentKind` discriminant to workflow name.
114 ///
115 /// Uses a fixed-size array indexed by `RedispatchDocumentKind as usize`.
116 entries: [Option<&'static str>; Self::TABLE_SIZE],
117}
118
119impl RedispatchRouter {
120 /// Number of distinct [`RedispatchDocumentKind`] variants (keep in sync with the enum).
121 const TABLE_SIZE: usize = 16;
122
123 /// Create an empty router (no routes registered).
124 #[must_use]
125 pub fn new() -> Self {
126 Self::default()
127 }
128
129 /// Register a mapping from `doc_kind` to `workflow_name`.
130 ///
131 /// If `doc_kind` was already registered the new entry **overwrites** the
132 /// previous one. This mirrors the `PidRouter` contract.
133 pub fn register(&mut self, doc_kind: RedispatchDocumentKind, workflow_name: &'static str) {
134 let idx = doc_kind as usize;
135 debug_assert!(
136 idx < Self::TABLE_SIZE,
137 "RedispatchDocumentKind discriminant {idx} exceeds RedispatchRouter table size; \
138 increase TABLE_SIZE"
139 );
140 if idx < Self::TABLE_SIZE {
141 self.entries[idx] = Some(workflow_name);
142 }
143 }
144
145 /// Look up the workflow name for `doc_kind`.
146 ///
147 /// Returns `Ok(name)` when a mapping was registered, or a [`RoutingError`]
148 /// when the document kind is unknown.
149 ///
150 /// # Errors
151 ///
152 /// Returns [`RoutingError`] when no workflow was registered for `doc_kind`.
153 pub fn route(&self, doc_kind: RedispatchDocumentKind) -> Result<&'static str, RoutingError> {
154 let idx = doc_kind as usize;
155 if idx < Self::TABLE_SIZE {
156 self.entries[idx].ok_or(RoutingError { doc_kind })
157 } else {
158 Err(RoutingError { doc_kind })
159 }
160 }
161
162 /// Return `true` if `doc_kind` has a registered workflow.
163 #[must_use]
164 pub fn is_registered(&self, doc_kind: RedispatchDocumentKind) -> bool {
165 self.route(doc_kind).is_ok()
166 }
167
168 /// Iterate over all registered `(RedispatchDocumentKind, workflow_name)` pairs.
169 pub fn iter(&self) -> impl Iterator<Item = (RedispatchDocumentKind, &'static str)> + '_ {
170 ALL_DOC_KINDS
171 .iter()
172 .filter_map(|&dk| self.entries[dk as usize].map(|name| (dk, name)))
173 }
174}
175
176/// Canonical ordered list of all [`RedispatchDocumentKind`] variants.
177///
178/// Used by [`RedispatchRouter::iter`] to iterate registrations in a stable order.
179const ALL_DOC_KINDS: &[RedispatchDocumentKind] = &[
180 RedispatchDocumentKind::Activation,
181 RedispatchDocumentKind::PlannedResourceSchedule,
182 RedispatchDocumentKind::Acknowledgement,
183 RedispatchDocumentKind::Stammdaten,
184 RedispatchDocumentKind::StatusRequest,
185 RedispatchDocumentKind::Unavailability,
186 RedispatchDocumentKind::Kaskade,
187 RedispatchDocumentKind::NetworkConstraint,
188 RedispatchDocumentKind::Kostenblatt,
189];
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn register_and_route_roundtrip() {
197 let mut router = RedispatchRouter::new();
198 router.register(RedispatchDocumentKind::Activation, "redispatch-aktivierung");
199 router.register(RedispatchDocumentKind::Stammdaten, "redispatch-stammdaten");
200
201 assert_eq!(
202 router.route(RedispatchDocumentKind::Activation).unwrap(),
203 "redispatch-aktivierung"
204 );
205 assert_eq!(
206 router.route(RedispatchDocumentKind::Stammdaten).unwrap(),
207 "redispatch-stammdaten"
208 );
209 }
210
211 #[test]
212 fn unregistered_doc_kind_returns_error() {
213 let router = RedispatchRouter::new();
214 assert!(router.route(RedispatchDocumentKind::Kostenblatt).is_err());
215 }
216
217 #[test]
218 fn duplicate_registration_overwrites() {
219 let mut router = RedispatchRouter::new();
220 router.register(RedispatchDocumentKind::Activation, "first");
221 router.register(RedispatchDocumentKind::Activation, "second");
222 assert_eq!(
223 router.route(RedispatchDocumentKind::Activation).unwrap(),
224 "second"
225 );
226 }
227
228 #[test]
229 fn is_registered_reflects_state() {
230 let mut router = RedispatchRouter::new();
231 assert!(!router.is_registered(RedispatchDocumentKind::Activation));
232 router.register(RedispatchDocumentKind::Activation, "redispatch-aktivierung");
233 assert!(router.is_registered(RedispatchDocumentKind::Activation));
234 }
235
236 #[test]
237 fn iter_returns_only_registered() {
238 let mut router = RedispatchRouter::new();
239 router.register(RedispatchDocumentKind::Activation, "redispatch-aktivierung");
240 router.register(RedispatchDocumentKind::Stammdaten, "redispatch-stammdaten");
241
242 let pairs: Vec<_> = router.iter().collect();
243 assert_eq!(pairs.len(), 2);
244 assert!(
245 pairs
246 .iter()
247 .any(|(dk, _)| *dk == RedispatchDocumentKind::Activation)
248 );
249 assert!(
250 pairs
251 .iter()
252 .any(|(dk, _)| *dk == RedispatchDocumentKind::Stammdaten)
253 );
254 }
255}