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    // The root is BORROWED for serialization. It used to be `.cloned()` out of
243    // `element` first -- a deep copy of the whole document, live alongside the
244    // original for the entire write; heap-profiling sf-decomposer's workload
245    // showed it as the largest avoidable share of reassemble's heap peak.
246    let empty_root = Value::Object(Map::new());
247    let (declaration, root_key, root_value) = if let Some(decl) = element.get("?xml") {
248        let root_key = element
249            .keys()
250            .find(|k| *k != "?xml")
251            .cloned()
252            .unwrap_or_else(|| "root".to_string());
253        let root_value = element.get(&root_key).unwrap_or(&empty_root);
254        (Some(decl), root_key, root_value)
255    } else {
256        let root_key = element
257            .keys()
258            .next()
259            .cloned()
260            .unwrap_or_else(|| "root".to_string());
261        let root_value = element.get(&root_key).unwrap_or(&empty_root);
262        (None, root_key, root_value)
263    };
264
265    if let Some(obj) = declaration.and_then(|d| d.as_object()) {
266        let version = obj
267            .get("@version")
268            .and_then(|v| v.as_str())
269            .unwrap_or("1.0");
270        let encoding = obj.get("@encoding").and_then(|v| v.as_str());
271        let standalone = obj.get("@standalone").and_then(|v| v.as_str());
272        writer.write_event(Event::Decl(BytesDecl::new(version, encoding, standalone)))?;
273        writer.write_event(Event::Text(BytesText::new("\n")))?;
274    }
275
276    write_element(&mut writer, &root_key, root_value, 0)?;
277
278    // Trim trailing whitespace in place rather than copying the whole output
279    // into a new String (`trim_end().to_string()`).
280    let mut result = String::from_utf8(writer.into_inner())?;
281    let trimmed_len = result.trim_end().len();
282    result.truncate(trimmed_len);
283    Ok(result)
284}
285
286/// Build XML string from XmlElement.
287pub fn build_xml_string(element: &XmlElement) -> String {
288    match element {
289        Value::Object(obj) => build_xml_from_object(obj).unwrap_or_default(),
290        _ => String::new(),
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use serde_json::json;
298
299    #[test]
300    fn build_xml_string_non_object_returns_empty() {
301        assert!(build_xml_string(&Value::Array(vec![])).is_empty());
302        assert!(build_xml_string(&Value::Null).is_empty());
303    }
304
305    #[test]
306    fn build_xml_string_simple_root() {
307        let el = json!({
308            "?xml": { "@version": "1.0", "@encoding": "UTF-8" },
309            "root": { "child": "value" }
310        });
311        let out = build_xml_string(&el);
312        assert!(out.contains("<?xml"));
313        assert!(out.contains("<root>"));
314        assert!(out.contains("<child>value</child>"));
315        assert!(out.contains("</root>"));
316    }
317
318    #[test]
319    fn build_xml_string_with_attributes() {
320        let el = json!({
321            "root": { "@xmlns": "http://example.com", "a": "b" }
322        });
323        let out = build_xml_string(&el);
324        assert!(out.contains("xmlns"));
325        assert!(out.contains("http://example.com"));
326        assert!(out.contains("<a>b</a>"));
327    }
328
329    #[test]
330    fn build_xml_string_with_array() {
331        let el = json!({
332            "root": { "item": [ { "x": "1" }, { "x": "2" } ] }
333        });
334        let out = build_xml_string(&el);
335        assert!(out.contains("<item>"));
336        assert!(out.contains("<x>1</x>"));
337        assert!(out.contains("<x>2</x>"));
338    }
339
340    #[test]
341    fn build_xml_string_without_declaration() {
342        let el = json!({ "root": { "a": "b" } });
343        let out = build_xml_string(&el);
344        assert!(!out.contains("<?xml"));
345        assert!(out.contains("<root>"));
346    }
347
348    #[test]
349    fn build_xml_string_with_text_comment_cdata() {
350        let root = json!({
351            "#text": "text",
352            "#comment": " a comment ",
353            "#cdata": "<cdata>"
354        });
355        let el = json!({
356            "?xml": { "@version": "1.0" },
357            "root": root
358        });
359        let out = build_xml_string(&el);
360        assert!(out.contains("text"));
361        assert!(out.contains("<!--"));
362        assert!(out.contains(" a comment "));
363        assert!(out.contains("<![CDATA["));
364        assert!(out.contains("<cdata>"));
365    }
366
367    #[test]
368    fn build_xml_string_with_declaration_encoding_standalone() {
369        let el = json!({
370            "?xml": { "@version": "1.0", "@encoding": "UTF-8", "@standalone": "yes" },
371            "root": { "a": "b" }
372        });
373        let out = build_xml_string(&el);
374        assert!(out.contains("<?xml"));
375        assert!(out.contains("UTF-8"));
376        assert!(out.contains("standalone"));
377        assert!(out.contains("<root>"));
378    }
379
380    #[test]
381    fn build_xml_string_primitive_sibling_children() {
382        // Root with multiple children: one object, one primitive (hits _ => branch)
383        let el = json!({
384            "root": { "obj": { "x": "1" }, "num": 42, "flag": true }
385        });
386        let out = build_xml_string(&el);
387        assert!(out.contains("<obj>"));
388        assert!(out.contains("<num>42</num>"));
389        assert!(out.contains("<flag>true</flag>"));
390    }
391
392    #[test]
393    fn build_xml_string_null_child_value() {
394        let el = json!({
395            "root": { "empty": null }
396        });
397        let out = build_xml_string(&el);
398        assert!(out.contains("<empty>"));
399        assert!(out.contains("</empty>"));
400        // value_to_string maps Value::Null -> "" explicitly; without that
401        // explicit arm it would fall through to serde_json::to_string and
402        // emit the literal `null`, so guard against that regression.
403        assert!(
404            !out.contains("null"),
405            "Value::Null child should render as empty content, not the string \"null\": {out}"
406        );
407        assert!(out.contains("<empty></empty>"));
408    }
409
410    #[test]
411    fn build_xml_string_primitive_siblings_have_inter_element_indent() {
412        // Two primitive sibling children: between the non-last `<a>` and the
413        // last `<b>` the writer should emit a newline+indent. The `!is_last`
414        // guard on the close-tag indent is otherwise unobservable from
415        // single-sibling or array-only fixtures.
416        let el = json!({ "root": { "a": 1, "b": 2 } });
417        let out = build_xml_string(&el);
418        assert!(
419            out.contains("<a>1</a>\n    <b>2</b>"),
420            "expected `<a>1</a>` to be followed by newline + 4-space indent then `<b>2</b>`, got:\n{out}"
421        );
422        // And the last sibling should NOT have a trailing inter-sibling indent
423        // before `</root>` (only the closing-tag-level indent).
424        assert!(
425            out.contains("<b>2</b>\n</root>"),
426            "expected `<b>2</b>` to be followed directly by the root close tag, got:\n{out}"
427        );
428    }
429
430    #[test]
431    fn build_xml_string_comment_only_leaf() {
432        // Comment-only leaf isolates the `!comment_content.is_empty()` leg of
433        // the leaf-content guard. Without it the comment branch is never
434        // entered and the comment vanishes from the output.
435        let el = json!({
436            "?xml": { "@version": "1.0" },
437            "root": { "#comment": " just a comment " }
438        });
439        let out = build_xml_string(&el);
440        assert!(out.contains("<!--"), "expected comment open in: {out}");
441        assert!(
442            out.contains(" just a comment "),
443            "expected comment text preserved verbatim in: {out}"
444        );
445        assert!(out.contains("-->"));
446    }
447
448    #[test]
449    fn build_xml_string_text_tail_only_leaf() {
450        // Text-tail-only leaf isolates both the `||` joiner and the
451        // `!text_tail_content.is_empty()` check on the leaf-content guard.
452        // Without either, the text-tail content is silently dropped.
453        let el = json!({
454            "?xml": { "@version": "1.0" },
455            "root": { "#text-tail": "tail-only-content" }
456        });
457        let out = build_xml_string(&el);
458        assert!(
459            out.contains("tail-only-content"),
460            "expected text-tail content rendered between root tags, got:\n{out}"
461        );
462        assert!(out.contains("<root>"));
463        assert!(out.contains("</root>"));
464    }
465
466    #[test]
467    fn build_xml_string_cdata_only_no_text_or_comment() {
468        let root = json!({ "#cdata": "only cdata content" });
469        let el = json!({ "?xml": { "@version": "1.0" }, "root": root });
470        let out = build_xml_string(&el);
471        assert!(out.contains("<![CDATA["));
472        assert!(out.contains("only cdata content"));
473    }
474
475    #[test]
476    fn build_xml_string_declaration_only_defaults_root_key() {
477        let el = json!({ "?xml": { "@version": "1.0", "@encoding": "UTF-8" } });
478        let out = build_xml_string(&el);
479        assert!(out.contains("<?xml"));
480        assert!(out.contains("<root>"));
481    }
482
483    #[test]
484    fn build_xml_string_declaration_non_object_skips_decl_write() {
485        let el = json!({ "?xml": "not-an-object", "root": { "a": "b" } });
486        let out = build_xml_string(&el);
487        assert!(!out.contains("<?xml"));
488        assert!(out.contains("<root>"));
489    }
490
491    #[test]
492    fn build_xml_string_root_value_array_sibling_elements() {
493        // Root value is Array (write_element Value::Array branch)
494        let el = json!({
495            "root": [ { "a": "1" }, { "b": "2" } ]
496        });
497        let out = build_xml_string(&el);
498        assert!(out.contains("<root>"));
499        assert!(out.contains("<a>1</a>"));
500        assert!(out.contains("<b>2</b>"));
501        assert!(out.contains("</root>"));
502    }
503
504    #[test]
505    fn build_xml_string_root_value_primitive() {
506        // Root value is primitive (write_element _ branch for top-level content)
507        let el = json!({ "root": 42 });
508        let out = build_xml_string(&el);
509        assert!(out.contains("<root>42</root>"));
510    }
511
512    #[test]
513    fn build_xml_string_array_child_not_last_sibling_writes_inter_element_indent() {
514        // When an array child is NOT the last sibling, `if !is_last` in the
515        // Value::Array arm must write an inter-element indent after all array
516        // items. Without this path covered, mutating `!is_last` to always-false
517        // would drop the indent and merge consecutive tags in the output.
518        let el = json!({
519            "root": {
520                "items": [{ "x": "1" }],
521                "sibling": "y"
522            }
523        });
524        let out = build_xml_string(&el);
525        assert!(
526            out.contains("<items>"),
527            "items element must be present: {out}"
528        );
529        assert!(
530            out.contains("<sibling>y</sibling>"),
531            "sibling must be present: {out}"
532        );
533    }
534
535    #[test]
536    fn build_xml_string_empty_array_child_produces_no_elements() {
537        // A child whose value is an empty array: the inner for-loop body never
538        // executes (arr_len == 0) but the is_last/not-is_last indent logic still runs.
539        let el = json!({ "root": { "items": [] } });
540        let out = build_xml_string(&el);
541        // No <items> tags should appear since there are no items
542        assert!(
543            !out.contains("<items>"),
544            "no elements for empty array: {out}"
545        );
546        assert!(
547            out.contains("<root>"),
548            "root element must be present: {out}"
549        );
550    }
551
552    #[test]
553    fn build_xml_string_attribute_value_array_uses_serde_fallback() {
554        // An attribute value that is an Array hits value_to_string's `_` arm.
555        let el = json!({
556            "root": { "@tags": ["a", "b"], "child": "v" }
557        });
558        let out = build_xml_string(&el);
559        assert!(
560            out.contains("child"),
561            "child element must be present: {out}"
562        );
563    }
564
565    #[test]
566    fn build_xml_string_attribute_value_object_uses_serde_fallback() {
567        // Attribute value that is Object hits value_to_string _ branch (serde_json::to_string)
568        let el = json!({
569            "root": { "@complex": { "nested": true }, "child": "v" }
570        });
571        let out = build_xml_string(&el);
572        assert!(out.contains("child"));
573        assert!(out.contains("v"));
574    }
575}