Skip to main content

redispatch_xml/
parse.rs

1//! Two-phase XML parsing pipeline for Redispatch 2.0 documents.
2//!
3//! ## Pipeline
4//!
5//! 1. **Detect** — scan the opening bytes of the input to identify the root
6//!    element name and, where present, the `xmlns` namespace.
7//! 2. **Deserialize** — pass the full input to [`quick_xml::de::from_str`].
8//! 3. **Validate namespace** — for document types that carry a `targetNamespace`,
9//!    confirm the detected namespace matches the expected value.
10//!
11//! No libxml2 / XSD validation is performed at parse time. Use the
12//! [`crate::validation`] module for post-parse semantic/structural checks.
13
14use crate::documents::{
15    self, AcknowledgementDocument, ActivationDocument, DocumentType, Kaskade, Kostenblatt,
16    NetworkConstraintDocument, PlannedResourceScheduleDocument, Stammdaten,
17    StatusRequestMarketDocument, UnavailabilityMarketDocument,
18};
19use crate::error::RedispatchXmlError;
20
21// ── Document sum type ─────────────────────────────────────────────────────────
22
23/// A parsed Redispatch 2.0 document (any of the nine supported types).
24#[derive(Debug, Clone, PartialEq)]
25pub enum Document {
26    /// Activation document (`ActivationDocument`): ACO, ACR, or AAR.
27    Activation(Box<ActivationDocument>),
28    /// Planned resource schedule document (`PlannedResourceScheduleDocument`).
29    PlannedResourceSchedule(Box<PlannedResourceScheduleDocument>),
30    /// Acknowledgement document (`AcknowledgementDocument`).
31    Acknowledgement(Box<AcknowledgementDocument>),
32    /// Stammdaten (master data) document.
33    Stammdaten(Box<Stammdaten>),
34    /// Status request market document (`StatusRequest_MarketDocument`).
35    StatusRequest(Box<StatusRequestMarketDocument>),
36    /// Unavailability market document (`Unavailability_MarketDocument`).
37    Unavailability(Box<UnavailabilityMarketDocument>),
38    /// Kaskade (cascade) document.
39    Kaskade(Box<Kaskade>),
40    /// Network constraint document (`NetworkConstraintDocument`).
41    NetworkConstraint(Box<NetworkConstraintDocument>),
42    /// Kostenblatt (cost sheet) document.
43    Kostenblatt(Box<Kostenblatt>),
44}
45
46impl Document {
47    /// Return the [`DocumentType`] variant for this document.
48    pub fn document_type(&self) -> DocumentType {
49        match self {
50            Self::Activation(_) => DocumentType::Activation,
51            Self::PlannedResourceSchedule(_) => DocumentType::PlannedResourceSchedule,
52            Self::Acknowledgement(_) => DocumentType::Acknowledgement,
53            Self::Stammdaten(_) => DocumentType::Stammdaten,
54            Self::StatusRequest(_) => DocumentType::StatusRequest,
55            Self::Unavailability(_) => DocumentType::Unavailability,
56            Self::Kaskade(_) => DocumentType::Kaskade,
57            Self::NetworkConstraint(_) => DocumentType::NetworkConstraint,
58            Self::Kostenblatt(_) => DocumentType::Kostenblatt,
59        }
60    }
61
62    /// Return the document's primary identifier (mRID or `DocumentIdentification`).
63    ///
64    /// This is the correlation key used by the process engine to route inbound
65    /// documents to the correct workflow instance.
66    pub fn mrid(&self) -> &str {
67        match self {
68            Self::Activation(d) => d.document_identification.v.as_str(),
69            Self::PlannedResourceSchedule(d) => d.document_identification.v.as_str(),
70            Self::Acknowledgement(d) => d.document_identification.v.as_str(),
71            Self::Stammdaten(d) => d.document_identification.as_str(),
72            Self::StatusRequest(d) => d.m_rid.as_str(),
73            Self::Unavailability(d) => d.m_rid.as_str(),
74            Self::Kaskade(d) => d.m_rid.as_str(),
75            Self::NetworkConstraint(d) => d.document_identification.v.as_str(),
76            Self::Kostenblatt(d) => d.document_identification.v.as_str(),
77        }
78    }
79
80    /// Return the 13-digit GLN / EIC of the document sender.
81    pub fn sender_id(&self) -> &str {
82        match self {
83            Self::Activation(d) => d.sender_identification.v.as_str(),
84            Self::PlannedResourceSchedule(d) => d.sender_identification.v.as_str(),
85            Self::Acknowledgement(d) => d.sender_identification.v.as_str(),
86            Self::Stammdaten(d) => d.sender.code.as_str(),
87            Self::StatusRequest(d) => d.sender_market_participant.m_rid.value.as_str(),
88            Self::Unavailability(d) => d.sender_market_participant.m_rid.value.as_str(),
89            Self::Kaskade(d) => d.sender_market_participant.m_rid.value.as_str(),
90            Self::NetworkConstraint(d) => d.sender_identification.v.as_str(),
91            Self::Kostenblatt(d) => d.sender_identification.v.as_str(),
92        }
93    }
94
95    /// Return the 13-digit GLN / EIC of the document receiver.
96    pub fn receiver_id(&self) -> &str {
97        match self {
98            Self::Activation(d) => d.receiver_identification.v.as_str(),
99            Self::PlannedResourceSchedule(d) => d.receiver_identification.v.as_str(),
100            Self::Acknowledgement(d) => d.receiver_identification.v.as_str(),
101            Self::Stammdaten(d) => d.empfaenger.code.as_str(),
102            Self::StatusRequest(d) => d.receiver_market_participant.m_rid.value.as_str(),
103            Self::Unavailability(d) => d.receiver_market_participant.m_rid.value.as_str(),
104            Self::Kaskade(d) => d.receiver_market_participant.m_rid.value.as_str(),
105            Self::NetworkConstraint(d) => d.receiver_identification.v.as_str(),
106            Self::Kostenblatt(d) => d.receiver_identification.v.as_str(),
107        }
108    }
109}
110
111// ── From<T> for Document ──────────────────────────────────────────────────────
112
113impl From<ActivationDocument> for Document {
114    fn from(d: ActivationDocument) -> Self {
115        Self::Activation(Box::new(d))
116    }
117}
118impl From<PlannedResourceScheduleDocument> for Document {
119    fn from(d: PlannedResourceScheduleDocument) -> Self {
120        Self::PlannedResourceSchedule(Box::new(d))
121    }
122}
123impl From<AcknowledgementDocument> for Document {
124    fn from(d: AcknowledgementDocument) -> Self {
125        Self::Acknowledgement(Box::new(d))
126    }
127}
128impl From<Stammdaten> for Document {
129    fn from(d: Stammdaten) -> Self {
130        Self::Stammdaten(Box::new(d))
131    }
132}
133impl From<StatusRequestMarketDocument> for Document {
134    fn from(d: StatusRequestMarketDocument) -> Self {
135        Self::StatusRequest(Box::new(d))
136    }
137}
138impl From<UnavailabilityMarketDocument> for Document {
139    fn from(d: UnavailabilityMarketDocument) -> Self {
140        Self::Unavailability(Box::new(d))
141    }
142}
143impl From<Kaskade> for Document {
144    fn from(d: Kaskade) -> Self {
145        Self::Kaskade(Box::new(d))
146    }
147}
148impl From<NetworkConstraintDocument> for Document {
149    fn from(d: NetworkConstraintDocument) -> Self {
150        Self::NetworkConstraint(Box::new(d))
151    }
152}
153impl From<documents::Kostenblatt> for Document {
154    fn from(d: documents::Kostenblatt) -> Self {
155        Self::Kostenblatt(Box::new(d))
156    }
157}
158
159// ── Detection ─────────────────────────────────────────────────────────────────
160
161/// Scan the first 4 KiB of `xml` for the first element start tag and optional
162/// `xmlns` attribute, returning `(root_element_local_name, Option<namespace>)`.
163///
164/// This is intentionally a lightweight byte scan — not a full XML parse — so
165/// that detection is fast even for large documents.
166fn detect_root(xml: &[u8]) -> (String, Option<String>) {
167    // Strip UTF-8 BOM (U+FEFF, encoded as EF BB BF) if present.
168    let xml = xml.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(xml);
169
170    // Work with only the first 4096 bytes.
171    let window = &xml[..xml.len().min(4096)];
172    let text = String::from_utf8_lossy(window);
173
174    // Find the first '<' that is not '<?' or '<!'.
175    let mut root_name = String::new();
176    let mut namespace = None;
177
178    for i in 0..text.len() {
179        let ch = text.as_bytes()[i];
180        if ch != b'<' {
181            continue;
182        }
183        let rest = &text[i + 1..];
184        if rest.starts_with('?') || rest.starts_with('!') {
185            continue;
186        }
187        // Extract the local name (up to first space, '>' or '/').
188        let name_end = rest
189            .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
190            .unwrap_or(rest.len());
191        let raw_name = &rest[..name_end];
192        // Strip namespace prefix if present.
193        root_name = if let Some(pos) = raw_name.rfind(':') {
194            raw_name[pos + 1..].to_string()
195        } else {
196            raw_name.to_string()
197        };
198
199        // Scan the opening tag for xmlns="..." or xmlns:xxx="...".
200        let tag_end = rest.find('>').unwrap_or(rest.len());
201        let tag_slice = &rest[..tag_end];
202        namespace = extract_default_namespace(tag_slice);
203        break;
204    }
205
206    (root_name, namespace)
207}
208
209/// Extract the value of the first `xmlns="..."` or `xmlns:xxx="..."` attribute
210/// from a raw tag fragment.
211fn extract_default_namespace(tag: &str) -> Option<String> {
212    // Look for xmlns="..." (default namespace).
213    if let Some(pos) = tag.find("xmlns=\"") {
214        let after = &tag[pos + 7..];
215        if let Some(end) = after.find('"') {
216            return Some(after[..end].to_string());
217        }
218    }
219    // Fall back to xmlns:xxx="..." (prefixed namespace — first occurrence).
220    if let Some(pos) = tag.find("xmlns:") {
221        let after = &tag[pos..];
222        if let Some(eq) = after.find("=\"") {
223            let ns_part = &after[eq + 2..];
224            if let Some(end) = ns_part.find('"') {
225                return Some(ns_part[..end].to_string());
226            }
227        }
228    }
229    None
230}
231
232// ── Public API ────────────────────────────────────────────────────────────────
233
234/// Detect the document type of a Redispatch 2.0 XML message without fully
235/// deserializing it.
236///
237/// # Errors
238///
239/// Returns [`RedispatchXmlError::UnknownDocumentType`] if the root element is
240/// not a recognised Redispatch 2.0 document type.
241pub fn detect(xml: &[u8]) -> Result<DocumentType, RedispatchXmlError> {
242    let (root_name, _) = detect_root(xml);
243    DocumentType::from_root_element(&root_name)
244        .ok_or(RedispatchXmlError::UnknownDocumentType(root_name))
245}
246
247/// Deserialise a Redispatch 2.0 XML document into the appropriate [`Document`]
248/// variant.
249///
250/// The document type is detected automatically from the root element.
251///
252/// # Errors
253///
254/// - [`RedispatchXmlError::UnknownDocumentType`] — unrecognised root element.
255/// - [`RedispatchXmlError::Deserialize`] — XML deserialization failure.
256/// - [`RedispatchXmlError::NamespaceMismatch`] — wrong or missing namespace.
257pub fn parse(xml: &[u8]) -> Result<Document, RedispatchXmlError> {
258    let (root_name, detected_ns) = detect_root(xml);
259    let doc_type = DocumentType::from_root_element(&root_name)
260        .ok_or(RedispatchXmlError::UnknownDocumentType(root_name))?;
261
262    // Validate namespace where required.
263    if let Some(expected_ns) = doc_type.expected_namespace() {
264        match detected_ns.as_deref() {
265            Some(found) if found == expected_ns => {}
266            Some(found) => {
267                return Err(RedispatchXmlError::NamespaceMismatch {
268                    expected: expected_ns,
269                    found: found.to_string(),
270                });
271            }
272            None => {
273                return Err(RedispatchXmlError::NamespaceMismatch {
274                    expected: expected_ns,
275                    found: String::new(),
276                });
277            }
278        }
279    }
280
281    let text =
282        std::str::from_utf8(xml).map_err(|e| RedispatchXmlError::StructuralError(e.to_string()))?;
283
284    match doc_type {
285        DocumentType::Activation => {
286            let doc: ActivationDocument =
287                quick_xml::de::from_str(text).map_err(RedispatchXmlError::Deserialize)?;
288            Ok(Document::Activation(Box::new(doc)))
289        }
290        DocumentType::PlannedResourceSchedule => {
291            let doc: PlannedResourceScheduleDocument =
292                quick_xml::de::from_str(text).map_err(RedispatchXmlError::Deserialize)?;
293            Ok(Document::PlannedResourceSchedule(Box::new(doc)))
294        }
295        DocumentType::Acknowledgement => {
296            let doc: AcknowledgementDocument =
297                quick_xml::de::from_str(text).map_err(RedispatchXmlError::Deserialize)?;
298            Ok(Document::Acknowledgement(Box::new(doc)))
299        }
300        DocumentType::Stammdaten => {
301            let doc: Stammdaten =
302                quick_xml::de::from_str(text).map_err(RedispatchXmlError::Deserialize)?;
303            Ok(Document::Stammdaten(Box::new(doc)))
304        }
305        DocumentType::StatusRequest => {
306            let doc: StatusRequestMarketDocument =
307                quick_xml::de::from_str(text).map_err(RedispatchXmlError::Deserialize)?;
308            Ok(Document::StatusRequest(Box::new(doc)))
309        }
310        DocumentType::Unavailability => {
311            let doc: UnavailabilityMarketDocument =
312                quick_xml::de::from_str(text).map_err(RedispatchXmlError::Deserialize)?;
313            Ok(Document::Unavailability(Box::new(doc)))
314        }
315        DocumentType::Kaskade => {
316            let doc: Kaskade =
317                quick_xml::de::from_str(text).map_err(RedispatchXmlError::Deserialize)?;
318            Ok(Document::Kaskade(Box::new(doc)))
319        }
320        DocumentType::NetworkConstraint => {
321            let doc: NetworkConstraintDocument =
322                quick_xml::de::from_str(text).map_err(RedispatchXmlError::Deserialize)?;
323            Ok(Document::NetworkConstraint(Box::new(doc)))
324        }
325        DocumentType::Kostenblatt => {
326            let doc: documents::Kostenblatt =
327                quick_xml::de::from_str(text).map_err(RedispatchXmlError::Deserialize)?;
328            Ok(Document::Kostenblatt(Box::new(doc)))
329        }
330    }
331}
332
333/// Deserialise a Redispatch 2.0 XML document into a specific type `T`.
334///
335/// Use this when the document type is known at compile time.
336///
337/// # Errors
338///
339/// Returns [`RedispatchXmlError::Deserialize`] on parse failure.
340pub fn parse_as<T>(xml: &[u8]) -> Result<T, RedispatchXmlError>
341where
342    T: serde::de::DeserializeOwned,
343{
344    let text =
345        std::str::from_utf8(xml).map_err(|e| RedispatchXmlError::StructuralError(e.to_string()))?;
346    quick_xml::de::from_str(text).map_err(RedispatchXmlError::Deserialize)
347}
348
349/// Parse a Redispatch 2.0 XML document **and** run structural + semantic
350/// validation in one step.
351///
352/// Equivalent to calling [`parse`] followed by [`crate::validate`], but more
353/// ergonomic when you always want validation.
354///
355/// # Errors
356///
357/// Returns the first [`RedispatchXmlError`] encountered during parsing.
358/// If parsing succeeds but validation finds errors, returns the first
359/// [`RedispatchXmlError::StructuralError`].
360pub fn parse_and_validate(xml: &[u8]) -> Result<Document, RedispatchXmlError> {
361    let doc = parse(xml)?;
362    let result = crate::validation::validate(&doc);
363    result
364        .into_result()
365        .map(|_| doc)
366        .map_err(|e| RedispatchXmlError::StructuralError(e.to_string()))
367}