Skip to main content

faucet_source_xml/
convert.rs

1//! XML to JSON conversion.
2//!
3//! Converts XML documents to `serde_json::Value` preserving the element
4//! hierarchy. Attributes are prefixed with `@`, text content uses `#text`.
5
6use faucet_core::FaucetError;
7use quick_xml::events::Event;
8use quick_xml::reader::Reader;
9use serde_json::{Map, Value, json};
10
11/// Convert an XML string to a JSON value.
12///
13/// Elements become objects, repeated elements become arrays, attributes
14/// are stored with `@` prefix, and text content uses `#text`.
15pub fn xml_to_json(xml: &str) -> Result<Value, FaucetError> {
16    let mut reader = Reader::from_str(xml);
17    let mut stack: Vec<(String, Map<String, Value>)> = vec![("$root".into(), Map::new())];
18
19    loop {
20        match reader.read_event() {
21            Ok(Event::Start(e)) => {
22                let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
23                let mut obj = Map::new();
24
25                // Collect attributes.
26                for attr in e.attributes().flatten() {
27                    let key = format!("@{}", String::from_utf8_lossy(attr.key.as_ref()));
28                    let val = String::from_utf8_lossy(&attr.value).into_owned();
29                    obj.insert(key, Value::String(val));
30                }
31
32                stack.push((name, obj));
33            }
34            Ok(Event::End(_)) => {
35                let (name, obj) = stack.pop().ok_or_else(|| {
36                    FaucetError::Transform("malformed XML: unexpected end tag".into())
37                })?;
38
39                let value = if obj.len() == 1 && obj.contains_key("#text") {
40                    // Simplify: element with only text becomes a string.
41                    obj.into_iter().next().unwrap().1
42                } else {
43                    Value::Object(obj)
44                };
45
46                let parent = stack.last_mut().ok_or_else(|| {
47                    FaucetError::Transform("malformed XML: no parent element".into())
48                })?;
49
50                // If the key already exists, convert to array.
51                match parent.1.get_mut(&name) {
52                    Some(Value::Array(arr)) => arr.push(value),
53                    Some(existing) => {
54                        let prev = existing.clone();
55                        *existing = Value::Array(vec![prev, value]);
56                    }
57                    None => {
58                        parent.1.insert(name, value);
59                    }
60                }
61            }
62            Ok(Event::Text(e)) => {
63                let text = e
64                    .unescape()
65                    .map_err(|err| FaucetError::Transform(format!("XML decode error: {err}")))?
66                    .trim()
67                    .to_string();
68
69                if !text.is_empty()
70                    && let Some(current) = stack.last_mut()
71                {
72                    match current.1.get_mut("#text") {
73                        Some(Value::String(s)) => {
74                            s.push(' ');
75                            s.push_str(&text);
76                        }
77                        _ => {
78                            current.1.insert("#text".into(), Value::String(text));
79                        }
80                    }
81                }
82            }
83            Ok(Event::CData(e)) => {
84                // CDATA is literal (un-escaped) text that quick_xml emits as a
85                // separate event; without this arm the content was silently
86                // dropped — data loss for SOAP / feed APIs that wrap markup in
87                // CDATA (audit #146 H15). Decode and append to `#text` exactly
88                // like Event::Text.
89                let text = e
90                    .decode()
91                    .map_err(|err| {
92                        FaucetError::Transform(format!("XML CDATA decode error: {err}"))
93                    })?
94                    .trim()
95                    .to_string();
96
97                if !text.is_empty()
98                    && let Some(current) = stack.last_mut()
99                {
100                    match current.1.get_mut("#text") {
101                        Some(Value::String(s)) => {
102                            s.push(' ');
103                            s.push_str(&text);
104                        }
105                        _ => {
106                            current.1.insert("#text".into(), Value::String(text));
107                        }
108                    }
109                }
110            }
111            Ok(Event::Empty(e)) => {
112                let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
113                let mut obj = Map::new();
114                for attr in e.attributes().flatten() {
115                    let key = format!("@{}", String::from_utf8_lossy(attr.key.as_ref()));
116                    let val = String::from_utf8_lossy(&attr.value).into_owned();
117                    obj.insert(key, Value::String(val));
118                }
119                let value = if obj.is_empty() {
120                    json!(null)
121                } else {
122                    Value::Object(obj)
123                };
124
125                if let Some(parent) = stack.last_mut() {
126                    match parent.1.get_mut(&name) {
127                        Some(Value::Array(arr)) => arr.push(value),
128                        Some(existing) => {
129                            let prev = existing.clone();
130                            *existing = Value::Array(vec![prev, value]);
131                        }
132                        None => {
133                            parent.1.insert(name, value);
134                        }
135                    }
136                }
137            }
138            Ok(Event::Eof) => break,
139            Ok(_) => {} // Skip comments, processing instructions, etc.
140            Err(e) => {
141                return Err(FaucetError::Transform(format!("XML parse error: {e}")));
142            }
143        }
144    }
145
146    let (_, root) = stack
147        .pop()
148        .ok_or_else(|| FaucetError::Transform("empty XML document".into()))?;
149
150    Ok(Value::Object(root))
151}
152
153/// Walk an XML document with `quick_xml::Reader::read_event` and invoke
154/// `on_record` once per element whose path matches the dot-separated
155/// `records_element_path` selector. Records are materialised as JSON values
156/// in the same shape `xml_to_json` would produce — attributes become `@key`
157/// entries, repeated children become arrays, and a single `#text` child is
158/// flattened to a bare string.
159///
160/// When `records_element_path` is `None` the entire document is emitted as
161/// a single record (matches the eager `xml_to_json` behaviour).
162///
163/// The key difference from `xml_to_json` is that subtree JSON values are
164/// only materialised while inside a matched element — surrounding elements
165/// are observed via the event stream but never accumulated, which bounds
166/// memory to one matched element + the path stack regardless of total
167/// document size. Combined with batched yielding in
168/// [`crate::stream::XmlStream`]'s `stream_pages`, this keeps client-side
169/// memory at `O(batch_size * record_size)` even for multi-gigabyte
170/// payloads.
171pub fn stream_extract<F: FnMut(Value)>(
172    xml: &str,
173    records_element_path: Option<&str>,
174    mut on_record: F,
175) -> Result<(), FaucetError> {
176    let target_segments: Option<Vec<&str>> = records_element_path.map(|p| p.split('.').collect());
177
178    let mut reader = Reader::from_str(xml);
179
180    // Current element path: outer-most → inner-most element name.
181    let mut path: Vec<String> = Vec::new();
182
183    // When `Some(start_depth)`, we are currently building a subtree rooted
184    // at the element opened at `path[start_depth]`. The subtree stack
185    // mirrors `xml_to_json`'s stack but is rooted at the matched element
186    // rather than the document.
187    let mut start_depth: Option<usize> = None;
188    let mut subtree: Vec<(String, Map<String, Value>)> = Vec::new();
189
190    // When `records_element_path` is None, we eagerly build the whole
191    // document and emit it as one record on EOF. This preserves the
192    // historical "no path = full doc" behaviour.
193    let mut full_doc: Option<Vec<(String, Map<String, Value>)>> = if target_segments.is_none() {
194        Some(vec![("$root".into(), Map::new())])
195    } else {
196        None
197    };
198
199    /// Returns true when the current open-element path matches the target
200    /// dot-path selector exactly (i.e. the element just opened is the
201    /// repeating record element).
202    fn path_matches(path: &[String], target: &[&str]) -> bool {
203        path.len() == target.len() && path.iter().zip(target).all(|(a, b)| a.as_str() == *b)
204    }
205
206    /// Append a child value under `name` to the topmost frame, converting to
207    /// an array on repetition (mirrors `xml_to_json`).
208    fn append_child(parent: &mut Map<String, Value>, name: String, value: Value) {
209        match parent.get_mut(&name) {
210            Some(Value::Array(arr)) => arr.push(value),
211            Some(existing) => {
212                let prev = existing.clone();
213                *existing = Value::Array(vec![prev, value]);
214            }
215            None => {
216                parent.insert(name, value);
217            }
218        }
219    }
220
221    loop {
222        match reader.read_event() {
223            Ok(Event::Start(e)) => {
224                let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
225                let mut obj = Map::new();
226                for attr in e.attributes().flatten() {
227                    let key = format!("@{}", String::from_utf8_lossy(attr.key.as_ref()));
228                    let val = String::from_utf8_lossy(&attr.value).into_owned();
229                    obj.insert(key, Value::String(val));
230                }
231
232                path.push(name.clone());
233
234                if let Some(doc) = full_doc.as_mut() {
235                    doc.push((name, obj));
236                } else if let Some(target) = target_segments.as_deref() {
237                    if start_depth.is_some() {
238                        subtree.push((name, obj));
239                    } else if path_matches(&path, target) {
240                        // Opening the matched element itself — start a new
241                        // subtree builder rooted at it.
242                        start_depth = Some(path.len() - 1);
243                        subtree.push((name, obj));
244                    }
245                    // Otherwise: outside any matched element — drop the
246                    // event without materialising anything.
247                }
248            }
249            Ok(Event::Empty(e)) => {
250                let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
251                let mut obj = Map::new();
252                for attr in e.attributes().flatten() {
253                    let key = format!("@{}", String::from_utf8_lossy(attr.key.as_ref()));
254                    let val = String::from_utf8_lossy(&attr.value).into_owned();
255                    obj.insert(key, Value::String(val));
256                }
257                let value = if obj.is_empty() {
258                    json!(null)
259                } else {
260                    Value::Object(obj)
261                };
262
263                // Treat self-closing tag as a transient open+close at the
264                // current depth.
265                path.push(name.clone());
266                let matches_target = target_segments
267                    .as_deref()
268                    .map(|t| path_matches(&path, t))
269                    .unwrap_or(false);
270                path.pop();
271
272                if let Some(doc) = full_doc.as_mut() {
273                    if let Some(parent) = doc.last_mut() {
274                        append_child(&mut parent.1, name, value);
275                    }
276                } else if matches_target && start_depth.is_none() {
277                    // Self-closing matched element: emit immediately.
278                    on_record(value);
279                } else if start_depth.is_some()
280                    && let Some(parent) = subtree.last_mut()
281                {
282                    append_child(&mut parent.1, name, value);
283                }
284            }
285            Ok(Event::End(_)) => {
286                let name = path.pop().ok_or_else(|| {
287                    FaucetError::Transform("malformed XML: unexpected end tag".into())
288                })?;
289
290                if let Some(doc) = full_doc.as_mut() {
291                    let (popped_name, obj) = doc.pop().ok_or_else(|| {
292                        FaucetError::Transform("malformed XML: no element on stack".into())
293                    })?;
294                    debug_assert_eq!(popped_name, name);
295                    let value = if obj.len() == 1 && obj.contains_key("#text") {
296                        obj.into_iter().next().unwrap().1
297                    } else {
298                        Value::Object(obj)
299                    };
300                    let parent = doc.last_mut().ok_or_else(|| {
301                        FaucetError::Transform("malformed XML: no parent element".into())
302                    })?;
303                    append_child(&mut parent.1, popped_name, value);
304                } else if let Some(depth) = start_depth {
305                    let (popped_name, obj) = subtree.pop().ok_or_else(|| {
306                        FaucetError::Transform("malformed XML: no element on subtree stack".into())
307                    })?;
308                    debug_assert_eq!(popped_name, name);
309                    let value = if obj.len() == 1 && obj.contains_key("#text") {
310                        obj.into_iter().next().unwrap().1
311                    } else {
312                        Value::Object(obj)
313                    };
314
315                    if subtree.is_empty() {
316                        // We just closed the matched element itself —
317                        // emit and reset.
318                        debug_assert_eq!(path.len(), depth);
319                        start_depth = None;
320                        on_record(value);
321                    } else if let Some(parent) = subtree.last_mut() {
322                        append_child(&mut parent.1, popped_name, value);
323                    }
324                }
325                // Outside any matched element and no full-doc mode: drop.
326            }
327            Ok(Event::Text(e)) => {
328                let text = e
329                    .unescape()
330                    .map_err(|err| FaucetError::Transform(format!("XML decode error: {err}")))?
331                    .trim()
332                    .to_string();
333                if text.is_empty() {
334                    continue;
335                }
336
337                if let Some(doc) = full_doc.as_mut() {
338                    if let Some(current) = doc.last_mut() {
339                        match current.1.get_mut("#text") {
340                            Some(Value::String(s)) => {
341                                s.push(' ');
342                                s.push_str(&text);
343                            }
344                            _ => {
345                                current.1.insert("#text".into(), Value::String(text));
346                            }
347                        }
348                    }
349                } else if start_depth.is_some()
350                    && let Some(current) = subtree.last_mut()
351                {
352                    match current.1.get_mut("#text") {
353                        Some(Value::String(s)) => {
354                            s.push(' ');
355                            s.push_str(&text);
356                        }
357                        _ => {
358                            current.1.insert("#text".into(), Value::String(text));
359                        }
360                    }
361                }
362            }
363            Ok(Event::CData(e)) => {
364                // CDATA is literal text emitted as its own event; capture it
365                // instead of dropping it — data loss for CDATA-wrapped markup
366                // (audit #146 H15). Decode and append to `#text` like Text.
367                let text = e
368                    .decode()
369                    .map_err(|err| {
370                        FaucetError::Transform(format!("XML CDATA decode error: {err}"))
371                    })?
372                    .trim()
373                    .to_string();
374                if text.is_empty() {
375                    continue;
376                }
377                if let Some(doc) = full_doc.as_mut() {
378                    if let Some(current) = doc.last_mut() {
379                        match current.1.get_mut("#text") {
380                            Some(Value::String(s)) => {
381                                s.push(' ');
382                                s.push_str(&text);
383                            }
384                            _ => {
385                                current.1.insert("#text".into(), Value::String(text));
386                            }
387                        }
388                    }
389                } else if start_depth.is_some()
390                    && let Some(current) = subtree.last_mut()
391                {
392                    match current.1.get_mut("#text") {
393                        Some(Value::String(s)) => {
394                            s.push(' ');
395                            s.push_str(&text);
396                        }
397                        _ => {
398                            current.1.insert("#text".into(), Value::String(text));
399                        }
400                    }
401                }
402            }
403            Ok(Event::Eof) => break,
404            Ok(_) => {} // Comments, PIs, etc.
405            Err(e) => {
406                return Err(FaucetError::Transform(format!("XML parse error: {e}")));
407            }
408        }
409    }
410
411    if let Some(mut doc) = full_doc {
412        let (_, root) = doc
413            .pop()
414            .ok_or_else(|| FaucetError::Transform("empty XML document".into()))?;
415        on_record(Value::Object(root));
416    }
417
418    Ok(())
419}
420
421/// Navigate into a JSON value using a dot-separated path and extract
422/// matching records. If the final element is an array, its items are
423/// returned individually.
424pub fn extract_at_path(value: &Value, path: &str) -> Vec<Value> {
425    let segments: Vec<&str> = path.split('.').collect();
426    let mut current = value.clone();
427
428    for seg in &segments {
429        current = match current {
430            Value::Object(ref map) => match map.get(*seg) {
431                Some(v) => v.clone(),
432                None => return vec![],
433            },
434            _ => return vec![],
435        };
436    }
437
438    match current {
439        Value::Array(arr) => arr,
440        other => vec![other],
441    }
442}
443
444/// The local name of an element key — the part after the last `:`, so a
445/// namespace-prefixed element like `soap:Body` matches on `Body`.
446fn local_name(key: &str) -> &str {
447    match key.rsplit_once(':') {
448        Some((_, local)) => local,
449        None => key,
450    }
451}
452
453/// Find the first child of `value` whose element key has the given local name
454/// (namespace-prefix-insensitive). Returns the child value.
455fn find_child_by_local<'a>(value: &'a Value, local: &str) -> Option<&'a Value> {
456    value
457        .as_object()?
458        .iter()
459        .find_map(|(k, v)| (local_name(k) == local).then_some(v))
460}
461
462/// Best-effort human-readable text of a SOAP fault subtree: the SOAP 1.1
463/// `faultstring`, else the SOAP 1.2 `Reason`/`Text`, else the whole subtree.
464fn fault_message(fault: &Value) -> String {
465    // A repeated <Fault> collapses to an array; inspect the first.
466    let fault = match fault {
467        Value::Array(items) => items.first().unwrap_or(fault),
468        other => other,
469    };
470    // SOAP 1.1: <faultstring>msg</faultstring>.
471    if let Some(fs) = find_child_by_local(fault, "faultstring") {
472        return value_to_text(fs);
473    }
474    // SOAP 1.2: <Reason><Text>msg</Text></Reason>.
475    if let Some(reason) = find_child_by_local(fault, "Reason") {
476        if let Some(text) = find_child_by_local(reason, "Text") {
477            return value_to_text(text);
478        }
479        return value_to_text(reason);
480    }
481    value_to_text(fault)
482}
483
484/// Flatten a converted-XML value to its text: a bare string, an element's
485/// `#text`, else the serialized JSON.
486fn value_to_text(value: &Value) -> String {
487    match value {
488        Value::String(s) => s.clone(),
489        Value::Object(map) => map
490            .get("#text")
491            .and_then(Value::as_str)
492            .map(str::to_string)
493            .unwrap_or_else(|| value.to_string()),
494        other => other.to_string(),
495    }
496}
497
498/// Detect a SOAP `<Fault>` under `Envelope.Body` in a converted document and
499/// return its message. Matching is namespace-prefix-insensitive (a fault under
500/// `soap:Envelope.soap:Body.soap:Fault` and one under a default-namespaced
501/// `Envelope.Body.Fault` are both detected). Returns `None` when there is no
502/// fault (the normal success path).
503pub fn detect_soap_fault(doc: &Value) -> Option<String> {
504    let envelope = find_child_by_local(doc, "Envelope")?;
505    let body = find_child_by_local(envelope, "Body")?;
506    let fault = find_child_by_local(body, "Fault")?;
507    Some(fault_message(fault))
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn simple_xml_to_json() {
516        let xml = r#"<root><name>Alice</name><age>30</age></root>"#;
517        let json = xml_to_json(xml).unwrap();
518        assert_eq!(json["root"]["name"], "Alice");
519        assert_eq!(json["root"]["age"], "30");
520    }
521
522    #[test]
523    fn repeated_elements_become_array() {
524        let xml = r#"<root><item>a</item><item>b</item><item>c</item></root>"#;
525        let json = xml_to_json(xml).unwrap();
526        let items = json["root"]["item"].as_array().unwrap();
527        assert_eq!(items.len(), 3);
528        assert_eq!(items[0], "a");
529        assert_eq!(items[1], "b");
530    }
531
532    #[test]
533    fn attributes_prefixed() {
534        let xml = r#"<user id="42"><name>Bob</name></user>"#;
535        let json = xml_to_json(xml).unwrap();
536        assert_eq!(json["user"]["@id"], "42");
537        assert_eq!(json["user"]["name"], "Bob");
538    }
539
540    #[test]
541    fn nested_elements() {
542        let xml = r#"<root><user><address><city>NYC</city></address></user></root>"#;
543        let json = xml_to_json(xml).unwrap();
544        assert_eq!(json["root"]["user"]["address"]["city"], "NYC");
545    }
546
547    #[test]
548    fn cdata_content_is_captured_not_dropped() {
549        // H15 (audit #146): quick_xml emits CDATA as a separate event; it must
550        // be captured into #text, not silently dropped (it was, before the fix).
551        let xml = r#"<root><body><![CDATA[<b>hi</b> & bye]]></body></root>"#;
552        let json = xml_to_json(xml).unwrap();
553        assert_eq!(json["root"]["body"], "<b>hi</b> & bye");
554    }
555
556    #[test]
557    fn cdata_content_captured_in_streaming_path() {
558        // H15: the streaming converter must also capture CDATA.
559        let xml = r#"<feed><item><html><![CDATA[<p>x</p>]]></html></item></feed>"#;
560        let recs = collect_stream_extract(xml, Some("feed.item"));
561        assert_eq!(recs.len(), 1);
562        assert_eq!(recs[0]["html"], "<p>x</p>");
563    }
564
565    #[test]
566    fn empty_elements() {
567        let xml = r#"<root><flag/></root>"#;
568        let json = xml_to_json(xml).unwrap();
569        assert!(json["root"]["flag"].is_null());
570    }
571
572    #[test]
573    fn empty_element_with_attr() {
574        let xml = r#"<root><flag enabled="true"/></root>"#;
575        let json = xml_to_json(xml).unwrap();
576        assert_eq!(json["root"]["flag"]["@enabled"], "true");
577    }
578
579    #[test]
580    fn extract_at_path_nested() {
581        let val = json!({"root": {"users": {"user": [{"id": 1}, {"id": 2}]}}});
582        let records = extract_at_path(&val, "root.users.user");
583        assert_eq!(records.len(), 2);
584        assert_eq!(records[0]["id"], 1);
585    }
586
587    #[test]
588    fn extract_at_path_single_element() {
589        let val = json!({"root": {"user": {"id": 1}}});
590        let records = extract_at_path(&val, "root.user");
591        assert_eq!(records.len(), 1);
592        assert_eq!(records[0]["id"], 1);
593    }
594
595    #[test]
596    fn extract_at_path_missing() {
597        let val = json!({"root": {}});
598        let records = extract_at_path(&val, "root.users.user");
599        assert!(records.is_empty());
600    }
601
602    #[test]
603    fn soap_envelope() {
604        let xml = r#"
605        <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
606            <soap:Body>
607                <GetUsersResponse>
608                    <User><Name>Alice</Name></User>
609                    <User><Name>Bob</Name></User>
610                </GetUsersResponse>
611            </soap:Body>
612        </soap:Envelope>"#;
613        let json = xml_to_json(xml).unwrap();
614        let users = extract_at_path(&json, "soap:Envelope.soap:Body.GetUsersResponse.User");
615        assert_eq!(users.len(), 2);
616    }
617
618    fn collect_stream_extract(xml: &str, path: Option<&str>) -> Vec<Value> {
619        let mut out = Vec::new();
620        stream_extract(xml, path, |v| out.push(v)).unwrap();
621        out
622    }
623
624    #[test]
625    fn stream_extract_matches_eager_path_extraction() {
626        let xml = r#"<root>
627            <user id="1"><name>Alice</name><age>30</age></user>
628            <user id="2"><name>Bob</name><age>25</age></user>
629            <user id="3"><name>Carol</name><age>40</age></user>
630        </root>"#;
631        let streamed = collect_stream_extract(xml, Some("root.user"));
632        let eager = extract_at_path(&xml_to_json(xml).unwrap(), "root.user");
633        assert_eq!(streamed, eager);
634        assert_eq!(streamed.len(), 3);
635        assert_eq!(streamed[0]["@id"], "1");
636        assert_eq!(streamed[0]["name"], "Alice");
637        assert_eq!(streamed[2]["name"], "Carol");
638    }
639
640    #[test]
641    fn stream_extract_handles_nested_children_and_attrs() {
642        let xml = r#"<root>
643            <order id="A"><line><sku>X</sku><qty>2</qty></line><line><sku>Y</sku><qty>5</qty></line></order>
644            <order id="B"><line><sku>Z</sku><qty>1</qty></line></order>
645        </root>"#;
646        let streamed = collect_stream_extract(xml, Some("root.order"));
647        let eager = extract_at_path(&xml_to_json(xml).unwrap(), "root.order");
648        assert_eq!(streamed, eager);
649        assert_eq!(streamed.len(), 2);
650        let lines = streamed[0]["line"].as_array().expect("repeated children");
651        assert_eq!(lines.len(), 2);
652        assert_eq!(lines[1]["sku"], "Y");
653    }
654
655    #[test]
656    fn stream_extract_no_path_returns_full_doc_once() {
657        let xml = r#"<root><a>1</a><b>2</b></root>"#;
658        let streamed = collect_stream_extract(xml, None);
659        let eager = xml_to_json(xml).unwrap();
660        assert_eq!(streamed.len(), 1);
661        assert_eq!(streamed[0], eager);
662    }
663
664    #[test]
665    fn stream_extract_no_matches_emits_nothing() {
666        let xml = r#"<root><a>1</a></root>"#;
667        let streamed = collect_stream_extract(xml, Some("root.missing"));
668        assert!(streamed.is_empty());
669    }
670
671    #[test]
672    fn stream_extract_self_closing_matched_element() {
673        let xml = r#"<root><item id="1"/><item id="2"/><item id="3"/></root>"#;
674        let streamed = collect_stream_extract(xml, Some("root.item"));
675        assert_eq!(streamed.len(), 3);
676        assert_eq!(streamed[0]["@id"], "1");
677        assert_eq!(streamed[2]["@id"], "3");
678    }
679
680    #[test]
681    fn stream_extract_preserves_soap_namespaces() {
682        let xml = r#"
683        <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
684            <soap:Body>
685                <GetUsersResponse>
686                    <User><Name>Alice</Name></User>
687                    <User><Name>Bob</Name></User>
688                </GetUsersResponse>
689            </soap:Body>
690        </soap:Envelope>"#;
691        let streamed =
692            collect_stream_extract(xml, Some("soap:Envelope.soap:Body.GetUsersResponse.User"));
693        let eager = extract_at_path(
694            &xml_to_json(xml).unwrap(),
695            "soap:Envelope.soap:Body.GetUsersResponse.User",
696        );
697        assert_eq!(streamed, eager);
698        assert_eq!(streamed.len(), 2);
699        assert_eq!(streamed[1]["Name"], "Bob");
700    }
701
702    #[test]
703    fn xml_to_json_mixed_text_and_children_keeps_both() {
704        // An element with both text and a child element is NOT flattened to a
705        // bare string (obj.len() != 1), so #text and the child coexist.
706        let xml = r#"<root><p>hello<b>bold</b></p></root>"#;
707        let json = xml_to_json(xml).unwrap();
708        assert_eq!(json["root"]["p"]["#text"], "hello");
709        assert_eq!(json["root"]["p"]["b"], "bold");
710    }
711
712    #[test]
713    fn xml_to_json_text_split_by_entity_is_concatenated() {
714        // An entity reference (&amp;) splits the character data into two
715        // Text events on the same element; the second event hits the
716        // "#text already a String" branch and appends with a space.
717        let xml = r#"<root><msg>foo &amp; bar</msg></root>"#;
718        let json = xml_to_json(xml).unwrap();
719        assert_eq!(json["root"]["msg"], "foo & bar");
720    }
721
722    #[test]
723    fn xml_to_json_text_then_cdata_concatenated() {
724        // Leading plain text followed by a CDATA block on the same element:
725        // the CDATA arm appends to the existing #text String.
726        let xml = r#"<root><note>before <![CDATA[<raw>]]></note></root>"#;
727        let json = xml_to_json(xml).unwrap();
728        assert_eq!(json["root"]["note"], "before <raw>");
729    }
730
731    #[test]
732    fn xml_to_json_repeated_empty_elements_become_array() {
733        // Two self-closing tags with the same name under one parent: the
734        // second Empty event converts the first scalar value into an array.
735        let xml = r#"<root><flag/><flag/></root>"#;
736        let json = xml_to_json(xml).unwrap();
737        let flags = json["root"]["flag"].as_array().expect("repeated empties");
738        assert_eq!(flags.len(), 2);
739        assert!(flags[0].is_null());
740        assert!(flags[1].is_null());
741    }
742
743    #[test]
744    fn xml_to_json_three_repeated_empty_elements_push_onto_array() {
745        // A third same-named empty element pushes onto the already-array
746        // value (the `Some(Value::Array(arr)) => arr.push(value)` arm).
747        let xml = r#"<root><flag a="1"/><flag a="2"/><flag a="3"/></root>"#;
748        let json = xml_to_json(xml).unwrap();
749        let flags = json["root"]["flag"].as_array().expect("repeated empties");
750        assert_eq!(flags.len(), 3);
751        assert_eq!(flags[2]["@a"], "3");
752    }
753
754    #[test]
755    fn xml_to_json_skips_comments_and_processing_instructions() {
756        // Comments and PIs hit the `Ok(_) => {}` skip arm; the surrounding
757        // data must still parse correctly.
758        let xml = r#"<?xml version="1.0"?><root><!-- a comment --><name>X</name></root>"#;
759        let json = xml_to_json(xml).unwrap();
760        assert_eq!(json["root"]["name"], "X");
761    }
762
763    #[test]
764    fn xml_to_json_malformed_returns_parse_error() {
765        // A mismatched end tag is rejected by quick_xml's end-name check,
766        // surfacing as FaucetError::Transform via the `Err(e)` arm.
767        let xml = r#"<root><a></b></root>"#;
768        let err = xml_to_json(xml).unwrap_err();
769        assert!(
770            matches!(&err, FaucetError::Transform(m) if m.contains("XML parse error")),
771            "got {err:?}"
772        );
773    }
774
775    #[test]
776    fn extract_at_path_descending_into_scalar_returns_empty() {
777        // The path tries to descend past a scalar leaf, hitting the
778        // `_ => return vec![]` non-object arm.
779        let val = json!({"root": {"name": "Alice"}});
780        let records = extract_at_path(&val, "root.name.first");
781        assert!(records.is_empty());
782    }
783
784    #[test]
785    fn extract_at_path_scalar_root_returns_empty() {
786        // The very first segment lookup is against a non-object value.
787        let val = json!("just a string");
788        let records = extract_at_path(&val, "anything");
789        assert!(records.is_empty());
790    }
791
792    #[test]
793    fn stream_extract_full_doc_mode_self_closing_and_repeats() {
794        // No path => full_doc mode. Exercises the Empty arm under full_doc
795        // (append_child into doc.last_mut), including repetition → array.
796        let xml = r#"<root><flag/><flag/><name>Z</name></root>"#;
797        let streamed = collect_stream_extract(xml, None);
798        let eager = xml_to_json(xml).unwrap();
799        assert_eq!(streamed.len(), 1);
800        assert_eq!(streamed[0], eager);
801        let flags = streamed[0]["root"]["flag"]
802            .as_array()
803            .expect("repeated empties in full-doc mode");
804        assert_eq!(flags.len(), 2);
805        assert_eq!(streamed[0]["root"]["name"], "Z");
806    }
807
808    #[test]
809    fn stream_extract_full_doc_mode_mixed_text_and_cdata() {
810        // full_doc mode: text then CDATA on the same element appends to the
811        // existing #text String (the full_doc Text + CData arms).
812        let xml = r#"<root><note>hi &amp; <![CDATA[<x>]]></note></root>"#;
813        let streamed = collect_stream_extract(xml, None);
814        let eager = xml_to_json(xml).unwrap();
815        assert_eq!(streamed.len(), 1);
816        assert_eq!(streamed[0], eager);
817        assert_eq!(streamed[0]["root"]["note"], "hi & <x>");
818    }
819
820    #[test]
821    fn stream_extract_subtree_self_closing_child_appends() {
822        // A self-closing child inside a matched subtree exercises the
823        // `start_depth.is_some()` Empty append_child branch.
824        let xml = r#"<root>
825            <user id="1"><active/><name>Alice</name></user>
826            <user id="2"><active/><name>Bob</name></user>
827        </root>"#;
828        let streamed = collect_stream_extract(xml, Some("root.user"));
829        let eager = extract_at_path(&xml_to_json(xml).unwrap(), "root.user");
830        assert_eq!(streamed, eager);
831        assert_eq!(streamed.len(), 2);
832        assert!(streamed[0]["active"].is_null());
833        assert_eq!(streamed[0]["name"], "Alice");
834    }
835
836    #[test]
837    fn stream_extract_subtree_text_split_by_entity_concatenated() {
838        // Entity-split text inside a matched subtree hits the subtree
839        // "#text already a String" append branch.
840        let xml = r#"<root><item><msg>a &amp; b</msg></item></root>"#;
841        let streamed = collect_stream_extract(xml, Some("root.item"));
842        assert_eq!(streamed.len(), 1);
843        assert_eq!(streamed[0]["msg"], "a & b");
844    }
845
846    #[test]
847    fn stream_extract_subtree_text_then_cdata_concatenated() {
848        // Text then CDATA inside a matched subtree appends CDATA onto the
849        // existing #text String (subtree CData "already a String" branch).
850        let xml = r#"<root><item><note>start <![CDATA[<end>]]></note></item></root>"#;
851        let streamed = collect_stream_extract(xml, Some("root.item"));
852        assert_eq!(streamed.len(), 1);
853        assert_eq!(streamed[0]["note"], "start <end>");
854    }
855
856    #[test]
857    fn stream_extract_repeated_children_push_onto_existing_array() {
858        // Three same-named children inside a matched element exercise the
859        // append_child `Some(Value::Array(arr)) => arr.push` arm.
860        let xml = r#"<root><order><line>a</line><line>b</line><line>c</line></order></root>"#;
861        let streamed = collect_stream_extract(xml, Some("root.order"));
862        assert_eq!(streamed.len(), 1);
863        let lines = streamed[0]["line"].as_array().expect("repeated children");
864        assert_eq!(lines.len(), 3);
865        assert_eq!(lines[2], "c");
866    }
867
868    #[test]
869    fn stream_extract_malformed_returns_parse_error() {
870        // A mismatched end tag surfaces via the streaming parser's Err arm.
871        let xml = r#"<root><a></b></root>"#;
872        let mut out = Vec::new();
873        let err = stream_extract(xml, Some("root.a"), |v| out.push(v)).unwrap_err();
874        assert!(
875            matches!(&err, FaucetError::Transform(m) if m.contains("XML parse error")),
876            "got {err:?}"
877        );
878    }
879
880    #[test]
881    fn local_name_strips_namespace_prefix() {
882        assert_eq!(local_name("soap:Body"), "Body");
883        assert_eq!(local_name("Body"), "Body");
884        assert_eq!(local_name("a:b:c"), "c");
885    }
886
887    #[test]
888    fn detect_soap_fault_soap11_prefixed() {
889        let xml = r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
890            <soap:Body>
891                <soap:Fault>
892                    <faultcode>soap:Server</faultcode>
893                    <faultstring>Something went wrong</faultstring>
894                </soap:Fault>
895            </soap:Body>
896        </soap:Envelope>"#;
897        let doc = xml_to_json(xml).unwrap();
898        assert_eq!(
899            detect_soap_fault(&doc).as_deref(),
900            Some("Something went wrong")
901        );
902    }
903
904    #[test]
905    fn detect_soap_fault_soap11_default_namespace() {
906        // A default-namespaced envelope (unprefixed element names).
907        let xml = r#"<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
908            <Body>
909                <Fault>
910                    <faultcode>Server</faultcode>
911                    <faultstring>boom</faultstring>
912                </Fault>
913            </Body>
914        </Envelope>"#;
915        let doc = xml_to_json(xml).unwrap();
916        assert_eq!(detect_soap_fault(&doc).as_deref(), Some("boom"));
917    }
918
919    #[test]
920    fn detect_soap_fault_soap12_reason_text() {
921        let xml = r#"<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
922            <env:Body>
923                <env:Fault>
924                    <env:Code><env:Value>env:Receiver</env:Value></env:Code>
925                    <env:Reason><env:Text xml:lang="en">server exploded</env:Text></env:Reason>
926                </env:Fault>
927            </env:Body>
928        </env:Envelope>"#;
929        let doc = xml_to_json(xml).unwrap();
930        assert_eq!(detect_soap_fault(&doc).as_deref(), Some("server exploded"));
931    }
932
933    #[test]
934    fn detect_soap_fault_returns_none_on_success_response() {
935        let xml = r#"<Envelope><Body>
936            <GetUsersResponse><User><Name>Alice</Name></User></GetUsersResponse>
937        </Body></Envelope>"#;
938        let doc = xml_to_json(xml).unwrap();
939        assert!(detect_soap_fault(&doc).is_none());
940    }
941
942    #[test]
943    fn detect_soap_fault_returns_none_when_no_envelope() {
944        let doc = xml_to_json("<root><item>a</item></root>").unwrap();
945        assert!(detect_soap_fault(&doc).is_none());
946    }
947
948    #[test]
949    fn stream_extract_skips_comments_outside_match() {
950        // Comments hit the `Ok(_) => {}` arm; surrounding records still emit.
951        let xml = r#"<root><!-- c --><item><v>1</v></item><!-- d --><item><v>2</v></item></root>"#;
952        let streamed = collect_stream_extract(xml, Some("root.item"));
953        assert_eq!(streamed.len(), 2);
954        assert_eq!(streamed[0]["v"], "1");
955        assert_eq!(streamed[1]["v"], "2");
956    }
957}