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). 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}