Skip to main content

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}