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(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}