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