Skip to main content

config_disassembler/xml/builders/
build_xml_string.rs

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