Skip to main content

camel_api/
xml_convert.rs

1use crate::error::CamelError;
2use quick_xml::Reader;
3use quick_xml::events::Event;
4
5/// Validate that the input is well-formed XML.
6///
7/// This performs syntax validation only.
8pub fn validate_xml(input: &str) -> Result<(), CamelError> {
9    let mut reader = Reader::from_str(input);
10    reader.config_mut().trim_text(true);
11    let mut buf = Vec::new();
12    let mut depth = 0usize;
13    let mut root_count = 0usize;
14
15    loop {
16        match reader.read_event_into(&mut buf) {
17            Ok(Event::Start(_)) => {
18                if depth == 0 {
19                    root_count += 1;
20                    if root_count > 1 {
21                        return Err(CamelError::TypeConversionFailed(
22                            "multiple root elements found".into(),
23                        ));
24                    }
25                }
26                depth += 1;
27            }
28            Ok(Event::Empty(_)) => {
29                if depth == 0 {
30                    root_count += 1;
31                    if root_count > 1 {
32                        return Err(CamelError::TypeConversionFailed(
33                            "multiple root elements found".into(),
34                        ));
35                    }
36                }
37            }
38            Ok(Event::End(_)) => {
39                depth = depth.saturating_sub(1);
40            }
41            Ok(Event::DocType(_)) => {
42                return Err(CamelError::TypeConversionFailed(
43                    "DOCTYPE is not allowed in XML body".into(),
44                ));
45            }
46            Ok(Event::Eof) => break,
47            Err(e) => {
48                return Err(CamelError::TypeConversionFailed(format!(
49                    "invalid XML at position {}: {e}",
50                    reader.error_position()
51                )));
52            }
53            // PI events (<?target ...?>) are allowed — they cannot carry
54            // external entity references. The XML declaration (<?xml ...?>)
55            // is emitted as Event::Decl, not Event::PI.
56            _ => {}
57        }
58        buf.clear();
59    }
60
61    if root_count == 0 {
62        return Err(CamelError::TypeConversionFailed(
63            "empty XML: no root element found".into(),
64        ));
65    }
66
67    Ok(())
68}
69
70/// Convert an XML string to a JSON value.
71///
72/// Whitespace in text content is **trimmed** (leading/trailing) consistently with
73/// Apache Camel XJ behavior. For example, `<name> Alice </name>` produces
74/// `{"name": "Alice"}` — the surrounding spaces are removed. Indentation whitespace
75/// between elements is also ignored via `trim_text(true)` on the reader.
76///
77/// # Errors
78/// Returns `CamelError::TypeConversionFailed` if the input is not well-formed XML,
79/// contains multiple root elements, or is empty.
80pub fn xml_to_json(input: &str) -> Result<serde_json::Value, CamelError> {
81    let mut reader = Reader::from_str(input);
82    reader.config_mut().trim_text(true);
83
84    let mut stack: Vec<XmlNode> = Vec::new();
85    let mut got_root = false;
86    let mut result: Option<serde_json::Value> = None;
87
88    loop {
89        match reader.read_event() {
90            Ok(Event::Start(e)) => {
91                if result.is_some() {
92                    return Err(CamelError::TypeConversionFailed(
93                        "multiple root elements found".into(),
94                    ));
95                }
96                got_root = true;
97                let name = local_name(&e);
98                let attrs = parse_attrs(&e, reader.decoder())?;
99                stack.push(XmlNode {
100                    name,
101                    attrs,
102                    children: serde_json::Map::new(),
103                    text: String::new(),
104                });
105            }
106            Ok(Event::Empty(e)) => {
107                if result.is_some() {
108                    return Err(CamelError::TypeConversionFailed(
109                        "multiple root elements found".into(),
110                    ));
111                }
112                got_root = true;
113                let name = local_name(&e);
114                let attrs = parse_attrs(&e, reader.decoder())?;
115                let value = if attrs.is_empty() {
116                    serde_json::Value::Null
117                } else {
118                    serde_json::Value::Object(attrs)
119                };
120                if let Some(parent) = stack.last_mut() {
121                    insert_child(&mut parent.children, name, value);
122                } else {
123                    result = Some(serde_json::Value::Object(single_entry_map(name, value)));
124                }
125            }
126            Ok(Event::Text(e)) => {
127                let raw = String::from_utf8(e.to_vec()).map_err(|err| {
128                    CamelError::TypeConversionFailed(format!("invalid UTF-8 in XML text: {err}"))
129                })?;
130                let text = quick_xml::escape::unescape(&raw).map_err(|err| {
131                    CamelError::TypeConversionFailed(format!("cannot unescape XML text: {err}"))
132                })?;
133                if let Some(node) = stack.last_mut() {
134                    node.text.push_str(&text);
135                }
136            }
137            Ok(Event::GeneralRef(e)) => {
138                let ref_name = String::from_utf8(e.to_vec()).map_err(|err| {
139                    CamelError::TypeConversionFailed(format!("invalid UTF-8 in XML ref: {err}"))
140                })?;
141                let escaped = format!("&{ref_name};");
142                let text = quick_xml::escape::unescape(&escaped).map_err(|err| {
143                    CamelError::TypeConversionFailed(format!(
144                        "cannot unescape XML ref &{ref_name};: {err}"
145                    ))
146                })?;
147                if let Some(node) = stack.last_mut() {
148                    node.text.push_str(&text);
149                }
150            }
151            Ok(Event::CData(e)) => {
152                let text = String::from_utf8_lossy(e.as_ref()).into_owned();
153                if let Some(node) = stack.last_mut() {
154                    node.text.push_str(&text);
155                }
156            }
157            Ok(Event::End(_)) => {
158                let node = stack.pop().ok_or_else(|| {
159                    CamelError::TypeConversionFailed("unexpected closing tag".into())
160                })?;
161                let name = node.name.clone();
162                let value = build_node_value(node);
163                if let Some(parent) = stack.last_mut() {
164                    insert_child(&mut parent.children, name, value);
165                } else {
166                    result = Some(serde_json::Value::Object(single_entry_map(name, value)));
167                }
168            }
169            Ok(Event::Eof) => {
170                if !got_root {
171                    return Err(CamelError::TypeConversionFailed(
172                        "empty XML: no root element found".into(),
173                    ));
174                }
175                if let Some(res) = result {
176                    return Ok(res);
177                }
178                break;
179            }
180            Err(e) => {
181                return Err(CamelError::TypeConversionFailed(format!(
182                    "invalid XML at position {}: {e}",
183                    reader.error_position()
184                )));
185            }
186            _ => {}
187        }
188    }
189
190    Err(CamelError::TypeConversionFailed(
191        "unexpected end of XML input".into(),
192    ))
193}
194
195/// Validate that a string is a valid XML element/attribute name per the XML Name production.
196///
197/// Uses Unicode-aware checks: NameStartChar accepts any Unicode alphabetic character,
198/// `_`, or `:`. NameChar accepts any Unicode alphanumeric, `_`, `-`, `.`, or `:`.
199/// This is intentionally permissive — invalid names are ultimately rejected by
200/// `quick-xml` when writing.
201fn is_valid_xml_name(name: &str) -> bool {
202    let mut chars = name.chars();
203    match chars.next() {
204        Some(c) if c.is_alphabetic() || c == '_' || c == ':' => {}
205        _ => return false,
206    }
207    chars.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == ':')
208}
209
210pub fn json_to_xml(value: &serde_json::Value) -> Result<String, CamelError> {
211    let obj = value.as_object().ok_or_else(|| {
212        CamelError::TypeConversionFailed(
213            "cannot convert to XML: top-level value must be a JSON object".into(),
214        )
215    })?;
216
217    // Filter out special keys (@attr, #text) to find actual element keys
218    let element_keys: Vec<&String> = obj
219        .keys()
220        .filter(|k| !k.starts_with('@') && **k != "#text")
221        .collect();
222
223    if element_keys.is_empty() {
224        return Err(CamelError::TypeConversionFailed(
225            "cannot convert to XML: JSON object must contain exactly one root element".into(),
226        ));
227    }
228    if element_keys.len() > 1 {
229        return Err(CamelError::TypeConversionFailed(format!(
230            "cannot convert to XML: expected exactly one root element, found {} ({})",
231            element_keys.len(),
232            element_keys
233                .iter()
234                .map(|k| k.as_str())
235                .collect::<Vec<_>>()
236                .join(", ")
237        )));
238    }
239
240    let root_key = element_keys[0];
241    if !is_valid_xml_name(root_key) {
242        return Err(CamelError::TypeConversionFailed(format!(
243            "invalid XML element name: {root_key:?}"
244        )));
245    }
246
247    let child = &obj[root_key];
248    let mut output = String::new();
249    serialize_node(&mut output, root_key, child)?;
250    Ok(output)
251}
252
253/// Convert any JSON value to its string representation for XML serialization.
254fn value_as_str(val: &serde_json::Value) -> String {
255    match val {
256        serde_json::Value::String(s) => s.clone(),
257        serde_json::Value::Number(n) => n.to_string(),
258        serde_json::Value::Bool(b) => b.to_string(),
259        serde_json::Value::Null => String::new(),
260        serde_json::Value::Array(_) | serde_json::Value::Object(_) => val.to_string(),
261    }
262}
263
264fn serialize_node(
265    output: &mut String,
266    tag: &str,
267    value: &serde_json::Value,
268) -> Result<(), CamelError> {
269    if !is_valid_xml_name(tag) {
270        return Err(CamelError::TypeConversionFailed(format!(
271            "invalid XML element name: {tag:?}"
272        )));
273    }
274    match value {
275        serde_json::Value::Null => {
276            output.push_str(&format!("<{tag}/>"));
277        }
278        serde_json::Value::String(s) => {
279            output.push_str(&format!("<{tag}>{}</{tag}>", escape_xml_text(s)));
280        }
281        serde_json::Value::Number(n) => {
282            output.push_str(&format!("<{tag}>{n}</{tag}>"));
283        }
284        serde_json::Value::Bool(b) => {
285            output.push_str(&format!("<{tag}>{b}</{tag}>"));
286        }
287        serde_json::Value::Array(arr) => {
288            for item in arr {
289                serialize_node(output, tag, item)?;
290            }
291        }
292        serde_json::Value::Object(map) => {
293            let mut attrs = String::new();
294            let mut children = String::new();
295            let mut text = String::new();
296
297            for (key, val) in map {
298                if let Some(attr_name) = key.strip_prefix('@') {
299                    if !is_valid_xml_name(attr_name) {
300                        return Err(CamelError::TypeConversionFailed(format!(
301                            "invalid XML attribute name: {attr_name:?}"
302                        )));
303                    }
304                    attrs.push_str(&format!(
305                        r#" {}="{}""#,
306                        attr_name,
307                        escape_xml_text(&value_as_str(val))
308                    ));
309                } else if key == "#text" {
310                    text = escape_xml_text(&value_as_str(val));
311                } else {
312                    serialize_node(&mut children, key, val)?;
313                }
314            }
315
316            if children.is_empty() && text.is_empty() {
317                output.push_str(&format!("<{tag}{attrs}/>"));
318            } else {
319                output.push_str(&format!("<{tag}{attrs}>{text}{children}</{tag}>"));
320            }
321        }
322    }
323    Ok(())
324}
325
326fn escape_xml_text(s: &str) -> String {
327    let mut out = String::with_capacity(s.len());
328    for c in s.chars() {
329        match c {
330            '&' => out.push_str("&amp;"),
331            '<' => out.push_str("&lt;"),
332            '>' => out.push_str("&gt;"),
333            '"' => out.push_str("&quot;"),
334            '\'' => out.push_str("&apos;"),
335            _ => out.push(c),
336        }
337    }
338    out
339}
340
341struct XmlNode {
342    name: String,
343    attrs: serde_json::Map<String, serde_json::Value>,
344    children: serde_json::Map<String, serde_json::Value>,
345    text: String,
346}
347
348fn local_name(e: &quick_xml::events::BytesStart<'_>) -> String {
349    String::from_utf8_lossy(e.local_name().as_ref()).into_owned()
350}
351
352fn parse_attrs(
353    e: &quick_xml::events::BytesStart<'_>,
354    decoder: quick_xml::Decoder,
355) -> Result<serde_json::Map<String, serde_json::Value>, CamelError> {
356    let mut map = serde_json::Map::new();
357    for attr_result in e.attributes() {
358        let attr = attr_result.map_err(|err| {
359            CamelError::TypeConversionFailed(format!("cannot parse attribute: {err}"))
360        })?;
361
362        let full_name = String::from_utf8_lossy(attr.key.as_ref());
363        if full_name == "xmlns" || full_name.starts_with("xmlns:") {
364            continue;
365        }
366
367        let key = format!(
368            "@{}",
369            String::from_utf8_lossy(attr.key.local_name().as_ref())
370        );
371        let val = attr.decode_and_unescape_value(decoder).map_err(|err| {
372            CamelError::TypeConversionFailed(format!("cannot unescape attribute value: {err}"))
373        })?;
374        map.insert(key, serde_json::Value::String(val.to_string()));
375    }
376    Ok(map)
377}
378
379fn build_node_value(node: XmlNode) -> serde_json::Value {
380    let has_attrs = !node.attrs.is_empty();
381    let has_children = !node.children.is_empty();
382    let trimmed = node.text.trim();
383
384    if has_children {
385        let mut map = node.attrs;
386        if !trimmed.is_empty() {
387            map.insert(
388                "#text".to_string(),
389                serde_json::Value::String(trimmed.to_string()),
390            );
391        }
392        for (k, v) in node.children {
393            insert_child(&mut map, k, v);
394        }
395        serde_json::Value::Object(map)
396    } else if has_attrs {
397        let mut map = node.attrs;
398        if !trimmed.is_empty() {
399            map.insert(
400                "#text".to_string(),
401                serde_json::Value::String(trimmed.to_string()),
402            );
403        }
404        serde_json::Value::Object(map)
405    } else if trimmed.is_empty() {
406        serde_json::Value::Null
407    } else {
408        serde_json::Value::String(trimmed.to_string())
409    }
410}
411
412fn insert_child(
413    map: &mut serde_json::Map<String, serde_json::Value>,
414    name: String,
415    value: serde_json::Value,
416) {
417    match map.remove(&name) {
418        None => {
419            map.insert(name, value);
420        }
421        Some(serde_json::Value::Array(mut arr)) => {
422            arr.push(value);
423            map.insert(name, serde_json::Value::Array(arr));
424        }
425        Some(existing) => {
426            map.insert(name, serde_json::Value::Array(vec![existing, value]));
427        }
428    }
429}
430
431fn single_entry_map(
432    key: String,
433    value: serde_json::Value,
434) -> serde_json::Map<String, serde_json::Value> {
435    let mut m = serde_json::Map::new();
436    m.insert(key, value);
437    m
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use serde_json::json;
444
445    #[test]
446    fn simple_element() {
447        let xml = "<root><name>Alice</name></root>";
448        let result = xml_to_json(xml).unwrap();
449        assert_eq!(result, json!({"root": {"name": "Alice"}}));
450    }
451
452    #[test]
453    fn nested_elements() {
454        let xml = "<root><user><city>Madrid</city></user></root>";
455        let result = xml_to_json(xml).unwrap();
456        assert_eq!(result, json!({"root": {"user": {"city": "Madrid"}}}));
457    }
458
459    #[test]
460    fn repeated_siblings_become_array() {
461        let xml = "<root><item>a</item><item>b</item></root>";
462        let result = xml_to_json(xml).unwrap();
463        assert_eq!(result, json!({"root": {"item": ["a", "b"]}}));
464    }
465
466    #[test]
467    fn single_sibling_is_scalar() {
468        let xml = "<root><item>only</item></root>";
469        let result = xml_to_json(xml).unwrap();
470        assert_eq!(result, json!({"root": {"item": "only"}}));
471    }
472
473    #[test]
474    fn attributes_use_at_prefix() {
475        let xml = r#"<root id="123"><name>Alice</name></root>"#;
476        let result = xml_to_json(xml).unwrap();
477        assert_eq!(result, json!({"root": {"@id": "123", "name": "Alice"}}));
478    }
479
480    #[test]
481    fn text_with_attrs_uses_hash_text() {
482        let xml = r#"<root id="1">hello</root>"#;
483        let result = xml_to_json(xml).unwrap();
484        assert_eq!(result, json!({"root": {"@id": "1", "#text": "hello"}}));
485    }
486
487    #[test]
488    fn self_closing_no_attrs_is_null() {
489        let xml = "<root><empty/></root>";
490        let result = xml_to_json(xml).unwrap();
491        assert_eq!(result, json!({"root": {"empty": null}}));
492    }
493
494    #[test]
495    fn self_closing_with_attrs_is_object() {
496        let xml = r#"<root><link href="http://example.com"/></root>"#;
497        let result = xml_to_json(xml).unwrap();
498        assert_eq!(
499            result,
500            json!({"root": {"link": {"@href": "http://example.com"}}})
501        );
502    }
503
504    #[test]
505    fn text_with_children_uses_hash_text() {
506        let xml = "<root>hello<child>world</child></root>";
507        let result = xml_to_json(xml).unwrap();
508        assert_eq!(
509            result,
510            json!({"root": {"#text": "hello", "child": "world"}})
511        );
512    }
513
514    #[test]
515    fn repeated_siblings_with_attrs_become_array() {
516        let xml = r#"<root><item id="1">a</item><item id="2">b</item></root>"#;
517        let result = xml_to_json(xml).unwrap();
518        assert_eq!(
519            result,
520            json!({"root": {"item": [{"@id": "1", "#text": "a"}, {"@id": "2", "#text": "b"}]}})
521        );
522    }
523
524    #[test]
525    fn parent_with_only_child_elements_no_hash_text() {
526        let xml = "<person><name>John</name><age>30</age></person>";
527        let result = xml_to_json(xml).unwrap();
528        assert_eq!(result, json!({"person": {"name": "John", "age": "30"}}));
529    }
530
531    #[test]
532    fn invalid_xml_returns_error() {
533        let result = xml_to_json("not xml <unclosed");
534        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
535    }
536
537    #[test]
538    fn empty_string_returns_error() {
539        let result = xml_to_json("");
540        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
541    }
542
543    #[test]
544    fn validate_xml_valid() {
545        assert!(validate_xml("<root/>").is_ok());
546    }
547
548    #[test]
549    fn validate_xml_rejects_doctype() {
550        let result = validate_xml("<!DOCTYPE root><root/>");
551        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
552    }
553
554    #[test]
555    fn validate_xml_rejects_multiple_roots() {
556        let result = validate_xml("<a/><b/>");
557        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
558    }
559
560    #[test]
561    fn validate_xml_rejects_empty() {
562        let result = validate_xml("");
563        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
564    }
565
566    #[test]
567    fn validate_xml_rejects_whitespace_only() {
568        let result = validate_xml("   \n\t  ");
569        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
570    }
571
572    #[test]
573    fn validate_xml_accepts_prolog() {
574        assert!(validate_xml(r#"<?xml version=\"1.0\"?><root/>"#).is_ok());
575    }
576
577    #[test]
578    fn validate_xml_rejects_prolog_only() {
579        let result = validate_xml(r#"<?xml version=\"1.0\"?>"#);
580        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
581    }
582
583    #[test]
584    fn xml_prolog_accepted() {
585        let xml = r#"<?xml version="1.0"?><root><a>1</a></root>"#;
586        let result = xml_to_json(xml).unwrap();
587        assert_eq!(result, json!({"root": {"a": "1"}}));
588    }
589
590    #[test]
591    fn complex_nested_with_arrays_and_attrs() {
592        let xml = r#"<order id="123">
593            <item>coffee</item>
594            <item>tea</item>
595            <status active="true">pending</status>
596        </order>"#;
597        let result = xml_to_json(xml).unwrap();
598        assert_eq!(
599            result,
600            json!({
601                "order": {
602                    "@id": "123",
603                    "item": ["coffee", "tea"],
604                    "status": {"@active": "true", "#text": "pending"}
605                }
606            })
607        );
608    }
609
610    #[test]
611    fn cdata_treated_as_text() {
612        let xml = "<root><msg><![CDATA[hello <world>]]></msg></root>";
613        let result = xml_to_json(xml).unwrap();
614        assert_eq!(result, json!({"root": {"msg": "hello <world>"}}));
615    }
616
617    #[test]
618    fn comments_ignored() {
619        let xml = "<root><!-- a comment --><a>1</a></root>";
620        let result = xml_to_json(xml).unwrap();
621        assert_eq!(result, json!({"root": {"a": "1"}}));
622    }
623
624    #[test]
625    fn whitespace_text_around_children_not_included() {
626        let xml = "<root>\n  <a>1</a>\n</root>";
627        let result = xml_to_json(xml).unwrap();
628        assert_eq!(result, json!({"root": {"a": "1"}}));
629    }
630
631    #[test]
632    fn test_whitespace_trimmed() {
633        // Documents that leading/trailing whitespace in text content is trimmed,
634        // consistent with Apache Camel XJ behavior.
635        let xml = "<name> Alice </name>";
636        let result = xml_to_json(xml).unwrap();
637        assert_eq!(result, json!({"name": "Alice"}));
638    }
639
640    #[test]
641    fn xml_entity_escaping_decoded() {
642        let xml = "<root><a>&amp;&lt;&gt;</a></root>";
643        let result = xml_to_json(xml).unwrap();
644        assert_eq!(result, json!({"root": {"a": "&<>"}}));
645    }
646
647    #[test]
648    fn attribute_entity_escaping_decoded() {
649        let xml = r#"<root a="&amp;val"/>"#;
650        let result = xml_to_json(xml).unwrap();
651        assert_eq!(result, json!({"root": {"@a": "&val"}}));
652    }
653
654    #[test]
655    fn self_closing_root() {
656        let xml = "<root/>";
657        let result = xml_to_json(xml).unwrap();
658        assert_eq!(result, json!({"root": null}));
659    }
660
661    #[test]
662    fn multiple_root_elements_returns_error() {
663        let result = xml_to_json("<a/><b/>");
664        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
665    }
666
667    #[test]
668    fn default_namespace_filtered() {
669        let xml = r#"<root xmlns="http://example.com"><a>1</a></root>"#;
670        let result = xml_to_json(xml).unwrap();
671        assert_eq!(result, json!({"root": {"a": "1"}}));
672    }
673
674    #[test]
675    fn prefixed_namespace_filtered() {
676        let xml = r#"<root xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><a>1</a></root>"#;
677        let result = xml_to_json(xml).unwrap();
678        assert_eq!(result, json!({"root": {"a": "1"}}));
679    }
680
681    #[test]
682    fn multiple_namespaces_filtered() {
683        let xml = r#"<root xmlns="http://default.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema"><a>1</a></root>"#;
684        let result = xml_to_json(xml).unwrap();
685        assert_eq!(result, json!({"root": {"a": "1"}}));
686    }
687
688    #[test]
689    fn mixed_namespace_and_regular_attrs() {
690        let xml = r#"<root xmlns="http://example.com" id="123"><a>1</a></root>"#;
691        let result = xml_to_json(xml).unwrap();
692        assert_eq!(result, json!({"root": {"@id": "123", "a": "1"}}));
693    }
694
695    #[test]
696    fn namespace_like_regular_attr_preserved() {
697        let xml = r#"<root xmlnsAttribute="value"><a>1</a></root>"#;
698        let result = xml_to_json(xml).unwrap();
699        assert_eq!(
700            result,
701            json!({"root": {"@xmlnsAttribute": "value", "a": "1"}})
702        );
703    }
704
705    #[test]
706    fn prefixed_element_names_stripped() {
707        let xml = "<ns:root><ns:a>1</ns:a></ns:root>";
708        let result = xml_to_json(xml).unwrap();
709        assert_eq!(result, json!({"root": {"a": "1"}}));
710    }
711
712    // --- json_to_xml tests (Task 2) ---
713
714    #[test]
715    fn json_to_xml_simple_object() {
716        let json = json!({"root": {"name": "Alice"}});
717        let result = json_to_xml(&json).unwrap();
718        assert_eq!(result, "<root><name>Alice</name></root>");
719    }
720
721    #[test]
722    fn json_to_xml_array() {
723        let json = json!({"root": {"item": ["a", "b"]}});
724        let result = json_to_xml(&json).unwrap();
725        assert_eq!(result, "<root><item>a</item><item>b</item></root>");
726    }
727
728    #[test]
729    fn json_to_xml_attributes() {
730        let json = json!({"root": {"@id": "123", "name": "Alice"}});
731        let result = json_to_xml(&json).unwrap();
732        assert!(result.contains(r#" id="123""#));
733        assert!(result.contains("<name>Alice</name>"));
734    }
735
736    #[test]
737    fn json_to_xml_null_element() {
738        let json = json!({"root": {"empty": null}});
739        let result = json_to_xml(&json).unwrap();
740        assert_eq!(result, "<root><empty/></root>");
741    }
742
743    #[test]
744    fn json_to_xml_hash_text() {
745        let json = json!({"root": {"@id": "1", "#text": "hello"}});
746        let result = json_to_xml(&json).unwrap();
747        assert!(result.contains(r#" id="1""#));
748        assert!(result.contains(">hello</root>"));
749    }
750
751    #[test]
752    fn json_to_xml_nested() {
753        let json = json!({"root": {"user": {"city": "Madrid"}}});
754        let result = json_to_xml(&json).unwrap();
755        assert_eq!(result, "<root><user><city>Madrid</city></user></root>");
756    }
757
758    #[test]
759    fn json_to_xml_non_object_returns_error() {
760        let json = json!("just a string");
761        let result = json_to_xml(&json);
762        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
763    }
764
765    #[test]
766    fn json_to_xml_array_with_attrs() {
767        let json =
768            json!({"root": {"item": [{"@id": "1", "#text": "a"}, {"@id": "2", "#text": "b"}]}});
769        let result = json_to_xml(&json).unwrap();
770        assert!(result.contains(r#" id="1""#));
771        assert!(result.contains(r#" id="2""#));
772        assert!(result.contains(">a<"));
773        assert!(result.contains(">b<"));
774    }
775
776    #[test]
777    fn json_to_xml_number_value() {
778        let json = json!({"root": {"count": 42}});
779        let result = json_to_xml(&json).unwrap();
780        assert!(result.contains("<count>42</count>"));
781    }
782
783    #[test]
784    fn json_to_xml_bool_value() {
785        let json = json!({"root": {"active": true}});
786        let result = json_to_xml(&json).unwrap();
787        assert!(result.contains("<active>true</active>"));
788    }
789
790    #[test]
791    fn json_to_xml_escapes_special_chars() {
792        let json = json!({"root": {"a": "<&>\"'"}});
793        let result = json_to_xml(&json).unwrap();
794        assert!(result.contains("&lt;&amp;&gt;&quot;&apos;"));
795    }
796
797    #[test]
798    fn json_to_xml_empty_object_becomes_self_closing() {
799        let json = json!({"root": {"empty": {}}});
800        let result = json_to_xml(&json).unwrap();
801        assert!(result.contains("<empty/>"));
802    }
803
804    #[test]
805    fn json_to_xml_number_as_attr() {
806        let json = json!({"root": {"@count": 42, "#text": "hello"}});
807        let result = json_to_xml(&json).unwrap();
808        assert!(result.contains(r#" count="42""#));
809        assert!(result.contains(">hello</root>"));
810    }
811
812    #[test]
813    fn json_to_xml_bool_as_attr() {
814        let json = json!({"root": {"@active": true, "#text": "data"}});
815        let result = json_to_xml(&json).unwrap();
816        assert!(result.contains(r#" active="true""#));
817    }
818
819    #[test]
820    fn json_to_xml_number_as_text() {
821        let json = json!({"root": {"@id": "1", "#text": 42}});
822        let result = json_to_xml(&json).unwrap();
823        assert!(result.contains(r#" id="1""#));
824        assert!(result.contains(">42</root>"));
825    }
826
827    #[test]
828    fn json_to_xml_bool_as_text() {
829        let json = json!({"root": {"#text": true}});
830        let result = json_to_xml(&json).unwrap();
831        assert!(result.contains(">true</root>"));
832    }
833
834    // --- json_to_xml validation tests ---
835
836    #[test]
837    fn json_to_xml_multiple_roots_returns_error() {
838        let json = json!({"root1": {"a": "1"}, "root2": {"b": "2"}});
839        let result = json_to_xml(&json);
840        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
841        let err = result.unwrap_err().to_string();
842        assert!(err.contains("exactly one root element"));
843        assert!(err.contains("root1"));
844        assert!(err.contains("root2"));
845    }
846
847    #[test]
848    fn json_to_xml_empty_object_returns_error() {
849        let json = json!({});
850        let result = json_to_xml(&json);
851        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
852    }
853
854    #[test]
855    fn json_to_xml_only_attrs_returns_error() {
856        let json = json!({"@id": "1", "#text": "hello"});
857        let result = json_to_xml(&json);
858        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
859    }
860
861    #[test]
862    fn json_to_xml_invalid_element_name_space() {
863        let json = json!({"my element": {"a": "1"}});
864        let result = json_to_xml(&json);
865        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
866        let err = result.unwrap_err().to_string();
867        assert!(err.contains("invalid XML element name"));
868    }
869
870    #[test]
871    fn json_to_xml_invalid_element_name_starts_with_digit() {
872        let json = json!({"123abc": {"a": "1"}});
873        let result = json_to_xml(&json);
874        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
875    }
876
877    #[test]
878    fn json_to_xml_invalid_element_name_special_chars() {
879        let json = json!({"<script>": {"a": "1"}});
880        let result = json_to_xml(&json);
881        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
882    }
883
884    #[test]
885    fn json_to_xml_invalid_child_element_name() {
886        let json = json!({"root": {"bad name": "value"}});
887        let result = json_to_xml(&json);
888        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
889    }
890
891    #[test]
892    fn json_to_xml_invalid_attribute_name() {
893        let json = json!({"root": {"@bad attr": "value"}});
894        let result = json_to_xml(&json);
895        assert!(matches!(result, Err(CamelError::TypeConversionFailed(_))));
896    }
897
898    #[test]
899    fn json_to_xml_valid_names_with_hyphens_and_underscores() {
900        let json = json!({"my-root": {"child_element": {"sub-item": "val"}}});
901        let result = json_to_xml(&json).unwrap();
902        assert!(result.contains("<my-root>"));
903        assert!(result.contains("<child_element>"));
904        assert!(result.contains("<sub-item>"));
905    }
906
907    // --- Unicode element name tests ---
908
909    #[test]
910    fn xml_to_json_unicode_element_names() {
911        // Unicode names are valid XML NameStartChar (alphabetic in Unicode)
912        let xml = "<café><nombre>María</nombre></café>";
913        let result = xml_to_json(xml).unwrap();
914        assert_eq!(result, json!({"café": {"nombre": "María"}}));
915    }
916
917    #[test]
918    fn xml_to_json_unicode_cjk_element_names() {
919        let xml = "<日本語><値>テスト</値></日本語>";
920        let result = xml_to_json(xml).unwrap();
921        assert_eq!(result, json!({"日本語": {"値": "テスト"}}));
922    }
923
924    #[test]
925    fn xml_to_json_unicode_spanish_element_names() {
926        let xml = "<ñamapa><dirección>Calle Mayor</dirección></ñamapa>";
927        let result = xml_to_json(xml).unwrap();
928        assert_eq!(result, json!({"ñamapa": {"dirección": "Calle Mayor"}}));
929    }
930
931    #[test]
932    fn json_to_xml_unicode_element_names() {
933        let json = json!({"café": {"nombre": "María"}});
934        let result = json_to_xml(&json).unwrap();
935        assert!(result.contains("<café>"));
936        assert!(result.contains("<nombre>María</nombre>"));
937    }
938
939    #[test]
940    fn json_to_xml_unicode_cjk_element_names() {
941        let json = json!({"日本語": {"値": "テスト"}});
942        let result = json_to_xml(&json).unwrap();
943        assert!(result.contains("<日本語>"));
944        assert!(result.contains("<値>テスト</値>"));
945    }
946}