Skip to main content

config_disassembler/xml/builders/
build_xml_string.rs

1//! Build XML string from XmlElement structure.
2
3use quick_xml::escape::partial_escape;
4use quick_xml::events::{BytesCData, BytesDecl, BytesEnd, BytesStart, BytesText, Event};
5use quick_xml::Writer;
6use serde_json::{Map, Value};
7
8use crate::xml::types::XmlElement;
9
10fn value_to_string(v: &Value) -> String {
11    match v {
12        Value::String(s) => s.clone(),
13        Value::Number(n) => n.to_string(),
14        Value::Bool(b) => b.to_string(),
15        Value::Null => String::new(),
16        _ => serde_json::to_string(v).unwrap_or_default(),
17    }
18}
19
20fn write_element<W: std::io::Write>(
21    writer: &mut Writer<W>,
22    name: &str,
23    content: &Value,
24    indent_level: usize,
25) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
26    let indent = "    ".repeat(indent_level);
27    let child_indent = "    ".repeat(indent_level + 1);
28
29    match content {
30        Value::Object(obj) => {
31            let (attrs, children): (Vec<_>, Vec<_>) =
32                obj.iter().partition(|(k, _)| k.starts_with('@'));
33
34            let attr_name = |k: &str| k.trim_start_matches('@').to_string();
35
36            let mut text_content = String::new();
37            let mut raw_text_content = String::new();
38            let mut comment_content = String::new();
39            let mut text_tail_content = String::new();
40            let mut cdata_content = String::new();
41            let mut is_compact = false;
42            let child_elements: Vec<(&String, &Value)> = children
43                .iter()
44                .filter_map(|(k, v)| {
45                    if *k == "#text" {
46                        text_content = value_to_string(v);
47                        None
48                    } else if *k == "#raw-text" {
49                        raw_text_content = value_to_string(v);
50                        None
51                    } else if *k == "#comment" {
52                        comment_content = value_to_string(v);
53                        None
54                    } else if *k == "#text-tail" {
55                        text_tail_content = value_to_string(v);
56                        None
57                    } else if *k == "#cdata" {
58                        cdata_content = value_to_string(v);
59                        None
60                    } else if *k == "#compact" {
61                        is_compact = v.as_bool().unwrap_or(false);
62                        None
63                    } else {
64                        Some((*k, *v))
65                    }
66                })
67                .collect();
68
69            let attrs: Vec<(String, String)> = attrs
70                .iter()
71                .map(|(k, v)| (attr_name(k), value_to_string(v)))
72                .collect();
73
74            let mut start = BytesStart::new(name);
75            for (k, v) in &attrs {
76                start.push_attribute((k.as_str(), v.as_str()));
77            }
78            writer.write_event(Event::Start(start))?;
79
80            let has_mixed_content = !cdata_content.is_empty()
81                || !text_content.is_empty()
82                || !raw_text_content.is_empty()
83                || !comment_content.is_empty()
84                || !text_tail_content.is_empty();
85
86            if is_compact
87                && child_elements.len() == 1
88                && matches!(child_elements[0].1, Value::Object(_))
89            {
90                // Single-element wrapper with zero surrounding whitespace in the source
91                // (e.g. `<connector><targetReference>X</targetReference></connector>`) --
92                // render the wrapper and its one child on the same line, with no
93                // indent/newline in between, matching the original formatting.
94                let (child_name, child_value) = child_elements[0];
95                write_element(writer, child_name, child_value, indent_level)?;
96            } else {
97                if !child_elements.is_empty() {
98                    writer.write_event(Event::Text(BytesText::new(
99                        format!("\n{}", child_indent).as_str(),
100                    )))?;
101
102                    let child_count = child_elements.len();
103                    for (idx, (child_name, child_value)) in child_elements.iter().enumerate() {
104                        let is_last = idx == child_count - 1;
105                        match child_value {
106                            Value::Array(arr) => {
107                                let arr_len = arr.len();
108                                for (i, item) in arr.iter().enumerate() {
109                                    let arr_last = i == arr_len - 1;
110                                    write_element(writer, child_name, item, indent_level + 1)?;
111                                    if !arr_last {
112                                        writer.write_event(Event::Text(BytesText::new(
113                                            format!("\n{}", child_indent).as_str(),
114                                        )))?;
115                                    }
116                                }
117                                if !is_last {
118                                    writer.write_event(Event::Text(BytesText::new(
119                                        format!("\n{}", child_indent).as_str(),
120                                    )))?;
121                                }
122                            }
123                            Value::Object(_) => {
124                                write_element(writer, child_name, child_value, indent_level + 1)?;
125                                if !is_last {
126                                    writer.write_event(Event::Text(BytesText::new(
127                                        format!("\n{}", child_indent).as_str(),
128                                    )))?;
129                                }
130                            }
131                            _ => {
132                                writer.write_event(Event::Start(BytesStart::new(
133                                    child_name.as_str(),
134                                )))?;
135                                writer.write_event(Event::Text(BytesText::new(
136                                    value_to_string(child_value).as_str(),
137                                )))?;
138                                writer
139                                    .write_event(Event::End(BytesEnd::new(child_name.as_str())))?;
140                                if !is_last {
141                                    writer.write_event(Event::Text(BytesText::new(
142                                        format!("\n{}", child_indent).as_str(),
143                                    )))?;
144                                }
145                            }
146                        }
147                    }
148
149                    writer.write_event(Event::Text(BytesText::new(
150                        format!("\n{}", indent).as_str(),
151                    )))?;
152                }
153
154                // A single element can carry both real child elements *and* its own
155                // direct mixed content (e.g. `<item><fullName>X</fullName><![CDATA[..]]></item>`).
156                // child_elements and the various *_content locals are mutually
157                // populated from disjoint keys (see the filter_map above), so this
158                // must run independently of the child_elements branch rather than as
159                // an `else if` - otherwise a real child element sibling silently
160                // discards any #text/#cdata/#comment/#text-tail on the same element.
161                if has_mixed_content {
162                    // Add newline+indent before content when no leading text and no
163                    // child elements already provided one (keeps CDATA/comment on
164                    // their own line without doubling up on the child_elements
165                    // branch's own trailing newline+indent above).
166                    if child_elements.is_empty()
167                        && text_content.is_empty()
168                        && raw_text_content.is_empty()
169                        && comment_content.is_empty()
170                    {
171                        writer.write_event(Event::Text(BytesText::new(
172                            format!("\n{}", child_indent).as_str(),
173                        )))?;
174                    }
175                    // Output in order: #text, #raw-text, #comment, #text-tail, #cdata
176                    if !text_content.is_empty() {
177                        writer.write_event(Event::Text(BytesText::new(text_content.as_str())))?;
178                    }
179                    // #raw-text: sidecar content injected pre-unescaped; use partial_escape so
180                    // literal " in YAML is not converted to &quot; but < > & are still safe.
181                    if !raw_text_content.is_empty() {
182                        writer.write_event(Event::Text(BytesText::from_escaped(
183                            partial_escape(raw_text_content.as_str()),
184                        )))?;
185                    }
186                    if !comment_content.is_empty() {
187                        // XML comment content is never subject to entity resolution on
188                        // read (the reader stores it raw - see parse_xml_cdata.rs's
189                        // Event::Comment handling), so it must never be escaped on
190                        // write either. `BytesText::new` escapes (quick-xml treats it
191                        // like text content); `from_escaped` writes the bytes verbatim.
192                        // Using `new` here made every `&` in a comment gain one more
193                        // `&amp;` layer on each successive disassemble/reassemble cycle.
194                        writer.write_event(Event::Comment(BytesText::from_escaped(
195                            comment_content.as_str(),
196                        )))?;
197                    }
198                    if !text_tail_content.is_empty() {
199                        writer
200                            .write_event(Event::Text(BytesText::new(text_tail_content.as_str())))?;
201                    }
202                    if !cdata_content.is_empty() {
203                        writer
204                            .write_event(Event::CData(BytesCData::new(cdata_content.as_str())))?;
205                    }
206                    // Add newline+indent before closing tag only for CDATA (keeps compact
207                    // for text-only), and only when child_elements didn't already add its
208                    // own trailing newline+indent above.
209                    if !cdata_content.is_empty() && child_elements.is_empty() {
210                        writer.write_event(Event::Text(BytesText::new(
211                            format!("\n{}", indent).as_str(),
212                        )))?;
213                    }
214                }
215            }
216
217            writer.write_event(Event::End(BytesEnd::new(name)))?;
218        }
219        Value::Array(arr) => {
220            for item in arr {
221                write_element(writer, name, item, indent_level)?;
222            }
223        }
224        _ => {
225            writer.write_event(Event::Start(BytesStart::new(name)))?;
226            writer.write_event(Event::Text(BytesText::new(
227                value_to_string(content).as_str(),
228            )))?;
229            writer.write_event(Event::End(BytesEnd::new(name)))?;
230        }
231    }
232
233    Ok(())
234}
235
236fn build_xml_from_object(
237    element: &Map<String, Value>,
238) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
239    // Use Writer::new (no indent) so leaf elements stay compact and match fixture format
240    let mut writer = Writer::new(Vec::new());
241
242    let (declaration, root_key, root_value) = if let Some(decl) = element.get("?xml") {
243        let root_key = element
244            .keys()
245            .find(|k| *k != "?xml")
246            .cloned()
247            .unwrap_or_else(|| "root".to_string());
248        let root_value = element
249            .get(&root_key)
250            .cloned()
251            .unwrap_or_else(|| Value::Object(Map::new()));
252        (Some(decl), root_key, root_value)
253    } else {
254        let root_key = element
255            .keys()
256            .next()
257            .cloned()
258            .unwrap_or_else(|| "root".to_string());
259        let root_value = element
260            .get(&root_key)
261            .cloned()
262            .unwrap_or_else(|| Value::Object(Map::new()));
263        (None, root_key, root_value)
264    };
265
266    if let Some(obj) = declaration.and_then(|d| d.as_object()) {
267        let version = obj
268            .get("@version")
269            .and_then(|v| v.as_str())
270            .unwrap_or("1.0");
271        let encoding = obj.get("@encoding").and_then(|v| v.as_str());
272        let standalone = obj.get("@standalone").and_then(|v| v.as_str());
273        writer.write_event(Event::Decl(BytesDecl::new(version, encoding, standalone)))?;
274        writer.write_event(Event::Text(BytesText::new("\n")))?;
275    }
276
277    write_element(&mut writer, &root_key, &root_value, 0)?;
278
279    let result = String::from_utf8(writer.into_inner())?;
280    Ok(result.trim_end().to_string())
281}
282
283/// Build XML string from XmlElement.
284pub fn build_xml_string(element: &XmlElement) -> String {
285    match element {
286        Value::Object(obj) => build_xml_from_object(obj).unwrap_or_default(),
287        _ => String::new(),
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use serde_json::json;
295
296    #[test]
297    fn build_xml_string_non_object_returns_empty() {
298        assert!(build_xml_string(&Value::Array(vec![])).is_empty());
299        assert!(build_xml_string(&Value::Null).is_empty());
300    }
301
302    #[test]
303    fn build_xml_string_simple_root() {
304        let el = json!({
305            "?xml": { "@version": "1.0", "@encoding": "UTF-8" },
306            "root": { "child": "value" }
307        });
308        let out = build_xml_string(&el);
309        assert!(out.contains("<?xml"));
310        assert!(out.contains("<root>"));
311        assert!(out.contains("<child>value</child>"));
312        assert!(out.contains("</root>"));
313    }
314
315    #[test]
316    fn build_xml_string_with_attributes() {
317        let el = json!({
318            "root": { "@xmlns": "http://example.com", "a": "b" }
319        });
320        let out = build_xml_string(&el);
321        assert!(out.contains("xmlns"));
322        assert!(out.contains("http://example.com"));
323        assert!(out.contains("<a>b</a>"));
324    }
325
326    #[test]
327    fn build_xml_string_with_array() {
328        let el = json!({
329            "root": { "item": [ { "x": "1" }, { "x": "2" } ] }
330        });
331        let out = build_xml_string(&el);
332        assert!(out.contains("<item>"));
333        assert!(out.contains("<x>1</x>"));
334        assert!(out.contains("<x>2</x>"));
335    }
336
337    #[test]
338    fn build_xml_string_without_declaration() {
339        let el = json!({ "root": { "a": "b" } });
340        let out = build_xml_string(&el);
341        assert!(!out.contains("<?xml"));
342        assert!(out.contains("<root>"));
343    }
344
345    #[test]
346    fn build_xml_string_with_text_comment_cdata() {
347        let root = json!({
348            "#text": "text",
349            "#comment": " a comment ",
350            "#cdata": "<cdata>"
351        });
352        let el = json!({
353            "?xml": { "@version": "1.0" },
354            "root": root
355        });
356        let out = build_xml_string(&el);
357        assert!(out.contains("text"));
358        assert!(out.contains("<!--"));
359        assert!(out.contains(" a comment "));
360        assert!(out.contains("<![CDATA["));
361        assert!(out.contains("<cdata>"));
362    }
363
364    #[test]
365    fn build_xml_string_with_declaration_encoding_standalone() {
366        let el = json!({
367            "?xml": { "@version": "1.0", "@encoding": "UTF-8", "@standalone": "yes" },
368            "root": { "a": "b" }
369        });
370        let out = build_xml_string(&el);
371        assert!(out.contains("<?xml"));
372        assert!(out.contains("UTF-8"));
373        assert!(out.contains("standalone"));
374        assert!(out.contains("<root>"));
375    }
376
377    #[test]
378    fn build_xml_string_primitive_sibling_children() {
379        // Root with multiple children: one object, one primitive (hits _ => branch)
380        let el = json!({
381            "root": { "obj": { "x": "1" }, "num": 42, "flag": true }
382        });
383        let out = build_xml_string(&el);
384        assert!(out.contains("<obj>"));
385        assert!(out.contains("<num>42</num>"));
386        assert!(out.contains("<flag>true</flag>"));
387    }
388
389    #[test]
390    fn build_xml_string_null_child_value() {
391        let el = json!({
392            "root": { "empty": null }
393        });
394        let out = build_xml_string(&el);
395        assert!(out.contains("<empty>"));
396        assert!(out.contains("</empty>"));
397        // value_to_string maps Value::Null -> "" explicitly; without that
398        // explicit arm it would fall through to serde_json::to_string and
399        // emit the literal `null`, so guard against that regression.
400        assert!(
401            !out.contains("null"),
402            "Value::Null child should render as empty content, not the string \"null\": {out}"
403        );
404        assert!(out.contains("<empty></empty>"));
405    }
406
407    #[test]
408    fn build_xml_string_primitive_siblings_have_inter_element_indent() {
409        // Two primitive sibling children: between the non-last `<a>` and the
410        // last `<b>` the writer should emit a newline+indent. The `!is_last`
411        // guard on the close-tag indent is otherwise unobservable from
412        // single-sibling or array-only fixtures.
413        let el = json!({ "root": { "a": 1, "b": 2 } });
414        let out = build_xml_string(&el);
415        assert!(
416            out.contains("<a>1</a>\n    <b>2</b>"),
417            "expected `<a>1</a>` to be followed by newline + 4-space indent then `<b>2</b>`, got:\n{out}"
418        );
419        // And the last sibling should NOT have a trailing inter-sibling indent
420        // before `</root>` (only the closing-tag-level indent).
421        assert!(
422            out.contains("<b>2</b>\n</root>"),
423            "expected `<b>2</b>` to be followed directly by the root close tag, got:\n{out}"
424        );
425    }
426
427    #[test]
428    fn build_xml_string_comment_only_leaf() {
429        // Comment-only leaf isolates the `!comment_content.is_empty()` leg of
430        // the leaf-content guard. Without it the comment branch is never
431        // entered and the comment vanishes from the output.
432        let el = json!({
433            "?xml": { "@version": "1.0" },
434            "root": { "#comment": " just a comment " }
435        });
436        let out = build_xml_string(&el);
437        assert!(out.contains("<!--"), "expected comment open in: {out}");
438        assert!(
439            out.contains(" just a comment "),
440            "expected comment text preserved verbatim in: {out}"
441        );
442        assert!(out.contains("-->"));
443    }
444
445    #[test]
446    fn build_xml_string_text_tail_only_leaf() {
447        // Text-tail-only leaf isolates both the `||` joiner and the
448        // `!text_tail_content.is_empty()` check on the leaf-content guard.
449        // Without either, the text-tail content is silently dropped.
450        let el = json!({
451            "?xml": { "@version": "1.0" },
452            "root": { "#text-tail": "tail-only-content" }
453        });
454        let out = build_xml_string(&el);
455        assert!(
456            out.contains("tail-only-content"),
457            "expected text-tail content rendered between root tags, got:\n{out}"
458        );
459        assert!(out.contains("<root>"));
460        assert!(out.contains("</root>"));
461    }
462
463    #[test]
464    fn build_xml_string_cdata_only_no_text_or_comment() {
465        let root = json!({ "#cdata": "only cdata content" });
466        let el = json!({ "?xml": { "@version": "1.0" }, "root": root });
467        let out = build_xml_string(&el);
468        assert!(out.contains("<![CDATA["));
469        assert!(out.contains("only cdata content"));
470    }
471
472    #[test]
473    fn build_xml_string_declaration_only_defaults_root_key() {
474        let el = json!({ "?xml": { "@version": "1.0", "@encoding": "UTF-8" } });
475        let out = build_xml_string(&el);
476        assert!(out.contains("<?xml"));
477        assert!(out.contains("<root>"));
478    }
479
480    #[test]
481    fn build_xml_string_declaration_non_object_skips_decl_write() {
482        let el = json!({ "?xml": "not-an-object", "root": { "a": "b" } });
483        let out = build_xml_string(&el);
484        assert!(!out.contains("<?xml"));
485        assert!(out.contains("<root>"));
486    }
487
488    #[test]
489    fn build_xml_string_root_value_array_sibling_elements() {
490        // Root value is Array (write_element Value::Array branch)
491        let el = json!({
492            "root": [ { "a": "1" }, { "b": "2" } ]
493        });
494        let out = build_xml_string(&el);
495        assert!(out.contains("<root>"));
496        assert!(out.contains("<a>1</a>"));
497        assert!(out.contains("<b>2</b>"));
498        assert!(out.contains("</root>"));
499    }
500
501    #[test]
502    fn build_xml_string_root_value_primitive() {
503        // Root value is primitive (write_element _ branch for top-level content)
504        let el = json!({ "root": 42 });
505        let out = build_xml_string(&el);
506        assert!(out.contains("<root>42</root>"));
507    }
508
509    #[test]
510    fn build_xml_string_array_child_not_last_sibling_writes_inter_element_indent() {
511        // When an array child is NOT the last sibling, `if !is_last` in the
512        // Value::Array arm must write an inter-element indent after all array
513        // items. Without this path covered, mutating `!is_last` to always-false
514        // would drop the indent and merge consecutive tags in the output.
515        let el = json!({
516            "root": {
517                "items": [{ "x": "1" }],
518                "sibling": "y"
519            }
520        });
521        let out = build_xml_string(&el);
522        assert!(
523            out.contains("<items>"),
524            "items element must be present: {out}"
525        );
526        assert!(
527            out.contains("<sibling>y</sibling>"),
528            "sibling must be present: {out}"
529        );
530    }
531
532    #[test]
533    fn build_xml_string_empty_array_child_produces_no_elements() {
534        // A child whose value is an empty array: the inner for-loop body never
535        // executes (arr_len == 0) but the is_last/not-is_last indent logic still runs.
536        let el = json!({ "root": { "items": [] } });
537        let out = build_xml_string(&el);
538        // No <items> tags should appear since there are no items
539        assert!(
540            !out.contains("<items>"),
541            "no elements for empty array: {out}"
542        );
543        assert!(
544            out.contains("<root>"),
545            "root element must be present: {out}"
546        );
547    }
548
549    #[test]
550    fn build_xml_string_attribute_value_array_uses_serde_fallback() {
551        // An attribute value that is an Array hits value_to_string's `_` arm.
552        let el = json!({
553            "root": { "@tags": ["a", "b"], "child": "v" }
554        });
555        let out = build_xml_string(&el);
556        assert!(
557            out.contains("child"),
558            "child element must be present: {out}"
559        );
560    }
561
562    #[test]
563    fn build_xml_string_attribute_value_object_uses_serde_fallback() {
564        // Attribute value that is Object hits value_to_string _ branch (serde_json::to_string)
565        let el = json!({
566            "root": { "@complex": { "nested": true }, "child": "v" }
567        });
568        let out = build_xml_string(&el);
569        assert!(out.contains("child"));
570        assert!(out.contains("v"));
571    }
572}