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};
7use std::io::{BufWriter, Write};
8use std::path::Path;
9
10use super::fragments::{Entry, Fragments, Item};
11use crate::xml::types::XmlElement;
12
13pub(super) fn value_to_string(v: &Value) -> String {
14    match v {
15        Value::String(s) => s.clone(),
16        Value::Number(n) => n.to_string(),
17        Value::Bool(b) => b.to_string(),
18        Value::Null => String::new(),
19        _ => serde_json::to_string(v).unwrap_or_default(),
20    }
21}
22
23/// Keys of an element's map that are not child elements: its own text, CDATA,
24/// comment and formatting markers. (Keys starting with `@` are attributes.)
25pub(super) const CONTENT_KEYS: [&str; 6] = [
26    "#text",
27    "#raw-text",
28    "#comment",
29    "#text-tail",
30    "#cdata",
31    "#compact",
32];
33
34/// Whether `key` names a child element rather than an attribute or content key.
35pub(super) fn is_child_key(key: &str) -> bool {
36    !key.starts_with('@') && !CONTENT_KEYS.contains(&key)
37}
38
39/// One field of an element being written: a plain value, or a merged entry from
40/// fragment reassembly (see `fragments.rs`), whose items may be pre-serialized.
41#[derive(Clone, Copy)]
42pub(super) enum Field<'a> {
43    Value(&'a Value),
44    Entry(&'a Entry, &'a Fragments<'a>),
45}
46
47impl<'a> Field<'a> {
48    /// The plain value this field holds, when it holds exactly one.
49    fn as_value(self) -> Option<&'a Value> {
50        match self {
51            Field::Value(v) => Some(v),
52            Field::Entry(Entry::One(Item::Val(v)), _) => Some(v),
53            Field::Entry(..) => None,
54        }
55    }
56
57    /// Text of an attribute or content field. Those keys are never fragmented, so
58    /// an entry's items are all plain values; a multi-item entry is written as the
59    /// array value the tree merge would have built.
60    fn to_text(self) -> String {
61        match self {
62            Field::Value(v) => value_to_string(v),
63            Field::Entry(entry, _) => match self.as_value() {
64                Some(v) => value_to_string(v),
65                None => value_to_string(&entry.to_value()),
66            },
67        }
68    }
69}
70
71pub(super) fn write_element<W: std::io::Write>(
72    writer: &mut Writer<W>,
73    name: &str,
74    content: &Value,
75    indent_level: usize,
76) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
77    match content {
78        Value::Object(obj) => write_object(
79            writer,
80            name,
81            obj.iter().map(|(k, v)| (k.as_str(), Field::Value(v))),
82            indent_level,
83        )?,
84        Value::Array(arr) => {
85            for item in arr {
86                write_element(writer, name, item, indent_level)?;
87            }
88        }
89        _ => {
90            writer.write_event(Event::Start(BytesStart::new(name)))?;
91            writer.write_event(Event::Text(BytesText::new(
92                value_to_string(content).as_str(),
93            )))?;
94            writer.write_event(Event::End(BytesEnd::new(name)))?;
95        }
96    }
97
98    Ok(())
99}
100
101/// Write one item of a merged child entry at `indent_level` (the child's level).
102fn write_item<W: std::io::Write>(
103    writer: &mut Writer<W>,
104    name: &str,
105    item: &Item,
106    fragments: &Fragments<'_>,
107    indent_level: usize,
108) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
109    match item {
110        // Already serialized, by `write_element(name, item, indent_level)`.
111        Item::Frag(frag) => writer.get_mut().write_all(fragments.bytes(frag))?,
112        Item::Val(v) => write_element(writer, name, v, indent_level)?,
113    }
114    Ok(())
115}
116
117/// Write an element whose content is the map `fields` (attributes, content keys
118/// and child elements, in order).
119pub(super) fn write_object<'a, W: std::io::Write>(
120    writer: &mut Writer<W>,
121    name: &str,
122    fields: impl Iterator<Item = (&'a str, Field<'a>)>,
123    indent_level: usize,
124) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
125    let indent = "    ".repeat(indent_level);
126    let child_indent = "    ".repeat(indent_level + 1);
127
128    let mut attrs: Vec<(&str, String)> = Vec::new();
129    let mut text_content = String::new();
130    let mut raw_text_content = String::new();
131    let mut comment_content = String::new();
132    let mut text_tail_content = String::new();
133    let mut cdata_content = String::new();
134    let mut is_compact = false;
135    let mut child_elements: Vec<(&str, Field<'a>)> = Vec::new();
136    for (k, field) in fields {
137        if k.starts_with('@') {
138            attrs.push((k.trim_start_matches('@'), field.to_text()));
139        } else if k == "#text" {
140            text_content = field.to_text();
141        } else if k == "#raw-text" {
142            raw_text_content = field.to_text();
143        } else if k == "#comment" {
144            comment_content = field.to_text();
145        } else if k == "#text-tail" {
146            text_tail_content = field.to_text();
147        } else if k == "#cdata" {
148            cdata_content = field.to_text();
149        } else if k == "#compact" {
150            is_compact = field.as_value().and_then(Value::as_bool).unwrap_or(false);
151        } else {
152            child_elements.push((k, field));
153        }
154    }
155
156    let mut start = BytesStart::new(name);
157    for (k, v) in &attrs {
158        start.push_attribute((*k, v.as_str()));
159    }
160    writer.write_event(Event::Start(start))?;
161
162    let has_mixed_content = !cdata_content.is_empty()
163        || !text_content.is_empty()
164        || !raw_text_content.is_empty()
165        || !comment_content.is_empty()
166        || !text_tail_content.is_empty();
167
168    let compact_child = match child_elements.as_slice() {
169        [(child_name, field)] if is_compact => match field.as_value() {
170            Some(v @ Value::Object(_)) => Some((*child_name, v)),
171            // A pre-serialized child was written for the indented layout, so it
172            // can't be placed inline; fragment reassembly takes the tree path for
173            // such a root instead (see `fragments::needs_tree_path`).
174            None if matches!(field, Field::Entry(Entry::One(Item::Frag(_)), _)) => {
175                return Err("compact element with a pre-serialized child".into());
176            }
177            _ => None,
178        },
179        _ => None,
180    };
181
182    if let Some((child_name, child_value)) = compact_child {
183        // Single-element wrapper with zero surrounding whitespace in the source
184        // (e.g. `<connector><targetReference>X</targetReference></connector>`) --
185        // render the wrapper and its one child on the same line, with no
186        // indent/newline in between, matching the original formatting.
187        write_element(writer, child_name, child_value, indent_level)?;
188    } else {
189        if !child_elements.is_empty() {
190            writer.write_event(Event::Text(BytesText::new(
191                format!("\n{}", child_indent).as_str(),
192            )))?;
193
194            let child_count = child_elements.len();
195            for (idx, (child_name, field)) in child_elements.iter().enumerate() {
196                let is_last = idx == child_count - 1;
197                match *field {
198                    Field::Value(Value::Array(arr))
199                    | Field::Entry(Entry::One(Item::Val(Value::Array(arr))), _) => {
200                        let arr_len = arr.len();
201                        for (i, item) in arr.iter().enumerate() {
202                            let arr_last = i == arr_len - 1;
203                            write_element(writer, child_name, item, indent_level + 1)?;
204                            if !arr_last {
205                                writer.write_event(Event::Text(BytesText::new(
206                                    format!("\n{}", child_indent).as_str(),
207                                )))?;
208                            }
209                        }
210                    }
211                    Field::Entry(Entry::Many(items), fragments) => {
212                        let arr_len = items.len();
213                        for (i, item) in items.iter().enumerate() {
214                            let arr_last = i == arr_len - 1;
215                            write_item(writer, child_name, item, fragments, indent_level + 1)?;
216                            if !arr_last {
217                                writer.write_event(Event::Text(BytesText::new(
218                                    format!("\n{}", child_indent).as_str(),
219                                )))?;
220                            }
221                        }
222                    }
223                    Field::Value(v @ Value::Object(_))
224                    | Field::Entry(Entry::One(Item::Val(v @ Value::Object(_))), _) => {
225                        write_element(writer, child_name, v, indent_level + 1)?;
226                    }
227                    Field::Entry(Entry::One(Item::Frag(frag)), fragments) => {
228                        // Already serialized, at this child's level.
229                        writer.get_mut().write_all(fragments.bytes(frag))?;
230                    }
231                    Field::Value(v) | Field::Entry(Entry::One(Item::Val(v)), _) => {
232                        writer.write_event(Event::Start(BytesStart::new(*child_name)))?;
233                        writer.write_event(Event::Text(BytesText::new(
234                            value_to_string(v).as_str(),
235                        )))?;
236                        writer.write_event(Event::End(BytesEnd::new(*child_name)))?;
237                    }
238                }
239                if !is_last {
240                    writer.write_event(Event::Text(BytesText::new(
241                        format!("\n{}", child_indent).as_str(),
242                    )))?;
243                }
244            }
245
246            writer.write_event(Event::Text(BytesText::new(
247                format!("\n{}", indent).as_str(),
248            )))?;
249        }
250
251        // A single element can carry both real child elements *and* its own
252        // direct mixed content (e.g. `<item><fullName>X</fullName><![CDATA[..]]></item>`).
253        // child_elements and the various *_content locals are mutually
254        // populated from disjoint keys (see the filter_map above), so this
255        // must run independently of the child_elements branch rather than as
256        // an `else if` - otherwise a real child element sibling silently
257        // discards any #text/#cdata/#comment/#text-tail on the same element.
258        if has_mixed_content {
259            // Add newline+indent before content when no leading text and no
260            // child elements already provided one (keeps CDATA/comment on
261            // their own line without doubling up on the child_elements
262            // branch's own trailing newline+indent above).
263            if child_elements.is_empty()
264                && text_content.is_empty()
265                && raw_text_content.is_empty()
266                && comment_content.is_empty()
267            {
268                writer.write_event(Event::Text(BytesText::new(
269                    format!("\n{}", child_indent).as_str(),
270                )))?;
271            }
272            // Output in order: #text, #raw-text, #comment, #text-tail, #cdata
273            if !text_content.is_empty() {
274                writer.write_event(Event::Text(BytesText::new(text_content.as_str())))?;
275            }
276            // #raw-text: sidecar content injected pre-unescaped; use partial_escape so
277            // literal " in YAML is not converted to &quot; but < > & are still safe.
278            if !raw_text_content.is_empty() {
279                writer.write_event(Event::Text(BytesText::from_escaped(partial_escape(
280                    raw_text_content.as_str(),
281                ))))?;
282            }
283            if !comment_content.is_empty() {
284                // XML comment content is never subject to entity resolution on
285                // read (the reader stores it raw - see parse_xml_cdata.rs's
286                // Event::Comment handling), so it must never be escaped on
287                // write either. `BytesText::new` escapes (quick-xml treats it
288                // like text content); `from_escaped` writes the bytes verbatim.
289                // Using `new` here made every `&` in a comment gain one more
290                // `&amp;` layer on each successive disassemble/reassemble cycle.
291                writer.write_event(Event::Comment(BytesText::from_escaped(
292                    comment_content.as_str(),
293                )))?;
294            }
295            if !text_tail_content.is_empty() {
296                writer.write_event(Event::Text(BytesText::new(text_tail_content.as_str())))?;
297            }
298            if !cdata_content.is_empty() {
299                writer.write_event(Event::CData(BytesCData::new(cdata_content.as_str())))?;
300            }
301            // Add newline+indent before closing tag only for CDATA (keeps compact
302            // for text-only), and only when child_elements didn't already add its
303            // own trailing newline+indent above.
304            if !cdata_content.is_empty() && child_elements.is_empty() {
305                writer.write_event(Event::Text(BytesText::new(
306                    format!("\n{}", indent).as_str(),
307                )))?;
308            }
309        }
310    }
311
312    writer.write_event(Event::End(BytesEnd::new(name)))?;
313    Ok(())
314}
315
316/// Whether `write_element` writes anything for `value`. Only an array whose items
317/// are all (recursively) empty arrays writes nothing; every other value writes at
318/// least its own tags.
319fn writes_output(value: &Value) -> bool {
320    match value {
321        Value::Array(items) => items.iter().any(writes_output),
322        _ => true,
323    }
324}
325
326/// Serialize `element` into `out` and return `out`. A non-object `element`
327/// writes nothing.
328///
329/// The output never ends with whitespace, so it needs no trimming: it ends with
330/// the `>` of the root's last tag, or -- when the root writes nothing -- with the
331/// declaration, whose `\n` separator is then left out. (It used to be written and
332/// trimmed off the finished `String`, which needs the whole output in memory.)
333fn write_xml<W: Write>(
334    element: &XmlElement,
335    out: W,
336) -> Result<W, Box<dyn std::error::Error + Send + Sync>> {
337    // Use Writer::new (no indent) so leaf elements stay compact and match fixture format
338    let mut writer = Writer::new(out);
339    let Value::Object(element) = element else {
340        return Ok(writer.into_inner());
341    };
342
343    // The root is BORROWED for serialization. It used to be `.cloned()` out of
344    // `element` first -- a deep copy of the whole document, live alongside the
345    // original for the entire write; heap-profiling sf-decomposer's workload
346    // showed it as the largest avoidable share of reassemble's heap peak.
347    let empty_root = Value::Object(Map::new());
348    let (declaration, root_key, root_value) = if let Some(decl) = element.get("?xml") {
349        let root_key = element
350            .keys()
351            .find(|k| *k != "?xml")
352            .cloned()
353            .unwrap_or_else(|| "root".to_string());
354        let root_value = element.get(&root_key).unwrap_or(&empty_root);
355        (Some(decl), root_key, root_value)
356    } else {
357        let root_key = element
358            .keys()
359            .next()
360            .cloned()
361            .unwrap_or_else(|| "root".to_string());
362        let root_value = element.get(&root_key).unwrap_or(&empty_root);
363        (None, root_key, root_value)
364    };
365
366    let root_writes_output = writes_output(root_value);
367    if let Some(obj) = declaration.and_then(|d| d.as_object()) {
368        let version = obj
369            .get("@version")
370            .and_then(|v| v.as_str())
371            .unwrap_or("1.0");
372        let encoding = obj.get("@encoding").and_then(|v| v.as_str());
373        let standalone = obj.get("@standalone").and_then(|v| v.as_str());
374        writer.write_event(Event::Decl(BytesDecl::new(version, encoding, standalone)))?;
375        if root_writes_output {
376            writer.write_event(Event::Text(BytesText::new("\n")))?;
377        }
378    }
379
380    if root_writes_output {
381        write_element(&mut writer, &root_key, root_value, 0)?;
382    }
383    Ok(writer.into_inner())
384}
385
386/// Serialize `element` straight to a file at `path` (created or truncated),
387/// followed by a `\n` when `trailing_newline` is set.
388///
389/// Produces the same bytes as writing `build_xml_string(element)` (plus the
390/// newline), but never holds the serialized document in memory: reassemble used
391/// to keep the whole output twice over (the writer's buffer and the `String`)
392/// alongside the merged tree at its heap peak.
393pub(crate) fn write_xml_file(
394    element: &XmlElement,
395    path: &Path,
396    trailing_newline: bool,
397) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
398    let file = std::fs::File::create(path)?;
399    let mut out = write_xml(element, BufWriter::new(file))?;
400    if trailing_newline {
401        out.write_all(b"\n")?;
402    }
403    // BufWriter's drop flushes but swallows the error; flush explicitly to surface it.
404    out.flush()?;
405    Ok(())
406}
407
408/// Build XML string from XmlElement.
409pub fn build_xml_string(element: &XmlElement) -> String {
410    let out = write_xml(element, Vec::new())
411        .ok()
412        .and_then(|bytes| String::from_utf8(bytes).ok())
413        .unwrap_or_default();
414    debug_assert_eq!(
415        out.trim_end().len(),
416        out.len(),
417        "serialized XML must not end with whitespace"
418    );
419    out
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use serde_json::json;
426
427    #[test]
428    fn build_xml_string_non_object_returns_empty() {
429        assert!(build_xml_string(&Value::Array(vec![])).is_empty());
430        assert!(build_xml_string(&Value::Null).is_empty());
431    }
432
433    #[test]
434    fn build_xml_string_simple_root() {
435        let el = json!({
436            "?xml": { "@version": "1.0", "@encoding": "UTF-8" },
437            "root": { "child": "value" }
438        });
439        let out = build_xml_string(&el);
440        assert!(out.contains("<?xml"));
441        assert!(out.contains("<root>"));
442        assert!(out.contains("<child>value</child>"));
443        assert!(out.contains("</root>"));
444    }
445
446    #[test]
447    fn build_xml_string_with_attributes() {
448        let el = json!({
449            "root": { "@xmlns": "http://example.com", "a": "b" }
450        });
451        let out = build_xml_string(&el);
452        assert!(out.contains("xmlns"));
453        assert!(out.contains("http://example.com"));
454        assert!(out.contains("<a>b</a>"));
455    }
456
457    #[test]
458    fn build_xml_string_with_array() {
459        let el = json!({
460            "root": { "item": [ { "x": "1" }, { "x": "2" } ] }
461        });
462        let out = build_xml_string(&el);
463        assert!(out.contains("<item>"));
464        assert!(out.contains("<x>1</x>"));
465        assert!(out.contains("<x>2</x>"));
466    }
467
468    #[test]
469    fn build_xml_string_without_declaration() {
470        let el = json!({ "root": { "a": "b" } });
471        let out = build_xml_string(&el);
472        assert!(!out.contains("<?xml"));
473        assert!(out.contains("<root>"));
474    }
475
476    #[test]
477    fn build_xml_string_with_text_comment_cdata() {
478        let root = json!({
479            "#text": "text",
480            "#comment": " a comment ",
481            "#cdata": "<cdata>"
482        });
483        let el = json!({
484            "?xml": { "@version": "1.0" },
485            "root": root
486        });
487        let out = build_xml_string(&el);
488        assert!(out.contains("text"));
489        assert!(out.contains("<!--"));
490        assert!(out.contains(" a comment "));
491        assert!(out.contains("<![CDATA["));
492        assert!(out.contains("<cdata>"));
493    }
494
495    #[test]
496    fn build_xml_string_with_declaration_encoding_standalone() {
497        let el = json!({
498            "?xml": { "@version": "1.0", "@encoding": "UTF-8", "@standalone": "yes" },
499            "root": { "a": "b" }
500        });
501        let out = build_xml_string(&el);
502        assert!(out.contains("<?xml"));
503        assert!(out.contains("UTF-8"));
504        assert!(out.contains("standalone"));
505        assert!(out.contains("<root>"));
506    }
507
508    #[test]
509    fn build_xml_string_primitive_sibling_children() {
510        // Root with multiple children: one object, one primitive (hits _ => branch)
511        let el = json!({
512            "root": { "obj": { "x": "1" }, "num": 42, "flag": true }
513        });
514        let out = build_xml_string(&el);
515        assert!(out.contains("<obj>"));
516        assert!(out.contains("<num>42</num>"));
517        assert!(out.contains("<flag>true</flag>"));
518    }
519
520    #[test]
521    fn build_xml_string_null_child_value() {
522        let el = json!({
523            "root": { "empty": null }
524        });
525        let out = build_xml_string(&el);
526        assert!(out.contains("<empty>"));
527        assert!(out.contains("</empty>"));
528        // value_to_string maps Value::Null -> "" explicitly; without that
529        // explicit arm it would fall through to serde_json::to_string and
530        // emit the literal `null`, so guard against that regression.
531        assert!(
532            !out.contains("null"),
533            "Value::Null child should render as empty content, not the string \"null\": {out}"
534        );
535        assert!(out.contains("<empty></empty>"));
536    }
537
538    #[test]
539    fn build_xml_string_primitive_siblings_have_inter_element_indent() {
540        // Two primitive sibling children: between the non-last `<a>` and the
541        // last `<b>` the writer should emit a newline+indent. The `!is_last`
542        // guard on the close-tag indent is otherwise unobservable from
543        // single-sibling or array-only fixtures.
544        let el = json!({ "root": { "a": 1, "b": 2 } });
545        let out = build_xml_string(&el);
546        assert!(
547            out.contains("<a>1</a>\n    <b>2</b>"),
548            "expected `<a>1</a>` to be followed by newline + 4-space indent then `<b>2</b>`, got:\n{out}"
549        );
550        // And the last sibling should NOT have a trailing inter-sibling indent
551        // before `</root>` (only the closing-tag-level indent).
552        assert!(
553            out.contains("<b>2</b>\n</root>"),
554            "expected `<b>2</b>` to be followed directly by the root close tag, got:\n{out}"
555        );
556    }
557
558    #[test]
559    fn build_xml_string_comment_only_leaf() {
560        // Comment-only leaf isolates the `!comment_content.is_empty()` leg of
561        // the leaf-content guard. Without it the comment branch is never
562        // entered and the comment vanishes from the output.
563        let el = json!({
564            "?xml": { "@version": "1.0" },
565            "root": { "#comment": " just a comment " }
566        });
567        let out = build_xml_string(&el);
568        assert!(out.contains("<!--"), "expected comment open in: {out}");
569        assert!(
570            out.contains(" just a comment "),
571            "expected comment text preserved verbatim in: {out}"
572        );
573        assert!(out.contains("-->"));
574    }
575
576    #[test]
577    fn build_xml_string_text_tail_only_leaf() {
578        // Text-tail-only leaf isolates both the `||` joiner and the
579        // `!text_tail_content.is_empty()` check on the leaf-content guard.
580        // Without either, the text-tail content is silently dropped.
581        let el = json!({
582            "?xml": { "@version": "1.0" },
583            "root": { "#text-tail": "tail-only-content" }
584        });
585        let out = build_xml_string(&el);
586        assert!(
587            out.contains("tail-only-content"),
588            "expected text-tail content rendered between root tags, got:\n{out}"
589        );
590        assert!(out.contains("<root>"));
591        assert!(out.contains("</root>"));
592    }
593
594    #[test]
595    fn build_xml_string_cdata_only_no_text_or_comment() {
596        let root = json!({ "#cdata": "only cdata content" });
597        let el = json!({ "?xml": { "@version": "1.0" }, "root": root });
598        let out = build_xml_string(&el);
599        assert!(out.contains("<![CDATA["));
600        assert!(out.contains("only cdata content"));
601    }
602
603    #[test]
604    fn build_xml_string_declaration_only_defaults_root_key() {
605        let el = json!({ "?xml": { "@version": "1.0", "@encoding": "UTF-8" } });
606        let out = build_xml_string(&el);
607        assert!(out.contains("<?xml"));
608        assert!(out.contains("<root>"));
609    }
610
611    #[test]
612    fn build_xml_string_declaration_non_object_skips_decl_write() {
613        let el = json!({ "?xml": "not-an-object", "root": { "a": "b" } });
614        let out = build_xml_string(&el);
615        assert!(!out.contains("<?xml"));
616        assert!(out.contains("<root>"));
617    }
618
619    #[test]
620    fn build_xml_string_root_value_array_sibling_elements() {
621        // Root value is Array (write_element Value::Array branch)
622        let el = json!({
623            "root": [ { "a": "1" }, { "b": "2" } ]
624        });
625        let out = build_xml_string(&el);
626        assert!(out.contains("<root>"));
627        assert!(out.contains("<a>1</a>"));
628        assert!(out.contains("<b>2</b>"));
629        assert!(out.contains("</root>"));
630    }
631
632    #[test]
633    fn build_xml_string_root_value_primitive() {
634        // Root value is primitive (write_element _ branch for top-level content)
635        let el = json!({ "root": 42 });
636        let out = build_xml_string(&el);
637        assert!(out.contains("<root>42</root>"));
638    }
639
640    #[test]
641    fn build_xml_string_array_child_not_last_sibling_writes_inter_element_indent() {
642        // When an array child is NOT the last sibling, `if !is_last` in the
643        // Value::Array arm must write an inter-element indent after all array
644        // items. Without this path covered, mutating `!is_last` to always-false
645        // would drop the indent and merge consecutive tags in the output.
646        let el = json!({
647            "root": {
648                "items": [{ "x": "1" }],
649                "sibling": "y"
650            }
651        });
652        let out = build_xml_string(&el);
653        assert!(
654            out.contains("<items>"),
655            "items element must be present: {out}"
656        );
657        assert!(
658            out.contains("<sibling>y</sibling>"),
659            "sibling must be present: {out}"
660        );
661    }
662
663    #[test]
664    fn build_xml_string_empty_array_child_produces_no_elements() {
665        // A child whose value is an empty array: the inner for-loop body never
666        // executes (arr_len == 0) but the is_last/not-is_last indent logic still runs.
667        let el = json!({ "root": { "items": [] } });
668        let out = build_xml_string(&el);
669        // No <items> tags should appear since there are no items
670        assert!(
671            !out.contains("<items>"),
672            "no elements for empty array: {out}"
673        );
674        assert!(
675            out.contains("<root>"),
676            "root element must be present: {out}"
677        );
678    }
679
680    #[test]
681    fn build_xml_string_attribute_value_array_uses_serde_fallback() {
682        // An attribute value that is an Array hits value_to_string's `_` arm.
683        let el = json!({
684            "root": { "@tags": ["a", "b"], "child": "v" }
685        });
686        let out = build_xml_string(&el);
687        assert!(
688            out.contains("child"),
689            "child element must be present: {out}"
690        );
691    }
692
693    #[test]
694    fn build_xml_string_attribute_value_object_uses_serde_fallback() {
695        // Attribute value that is Object hits value_to_string _ branch (serde_json::to_string)
696        let el = json!({
697            "root": { "@complex": { "nested": true }, "child": "v" }
698        });
699        let out = build_xml_string(&el);
700        assert!(out.contains("child"));
701        assert!(out.contains("v"));
702    }
703
704    // ---- write_xml_file -------------------------------------------------------
705
706    fn sample_doc() -> XmlElement {
707        json!({
708            "?xml": { "@version": "1.0", "@encoding": "UTF-8" },
709            "Profile": {
710                "@xmlns": "http://soap.sforce.com/2006/04/metadata",
711                "custom": "false",
712                "fieldPermissions": [
713                    { "editable": "true", "field": "Account.Name" },
714                    { "editable": "false", "field": "Account.Phone" }
715                ],
716                "description": { "#cdata": "a < b" }
717            }
718        })
719    }
720
721    #[test]
722    fn write_xml_file_matches_build_xml_string() {
723        let dir = tempfile::tempdir().unwrap();
724        let doc = sample_doc();
725        for trailing_newline in [false, true] {
726            let path = dir.path().join(format!("out-{trailing_newline}.xml"));
727            write_xml_file(&doc, &path, trailing_newline).unwrap();
728            let mut expected = build_xml_string(&doc);
729            if trailing_newline {
730                expected.push('\n');
731            }
732            assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
733        }
734    }
735
736    #[test]
737    fn write_xml_file_truncates_existing_file() {
738        let dir = tempfile::tempdir().unwrap();
739        let path = dir.path().join("out.xml");
740        std::fs::write(&path, "x".repeat(100_000)).unwrap();
741        let doc = json!({ "root": { "a": "1" } });
742        write_xml_file(&doc, &path, false).unwrap();
743        assert_eq!(
744            std::fs::read_to_string(&path).unwrap(),
745            build_xml_string(&doc)
746        );
747    }
748
749    #[test]
750    fn write_xml_file_non_object_writes_empty_file() {
751        let dir = tempfile::tempdir().unwrap();
752        let path = dir.path().join("out.xml");
753        write_xml_file(&json!("scalar"), &path, false).unwrap();
754        assert_eq!(std::fs::read_to_string(&path).unwrap(), "");
755    }
756
757    #[test]
758    fn write_xml_file_errors_when_directory_missing() {
759        let dir = tempfile::tempdir().unwrap();
760        let path = dir.path().join("missing").join("out.xml");
761        assert!(write_xml_file(&sample_doc(), &path, false).is_err());
762    }
763
764    #[test]
765    fn build_xml_string_trims_newline_after_declaration_only_document() {
766        // An empty-array root writes nothing after the declaration's trailing "\n";
767        // that "\n" is the one case where trimming actually removes bytes.
768        let doc = json!({ "?xml": { "@version": "1.0" }, "root": [] });
769        let out = build_xml_string(&doc);
770        assert_eq!(out, r#"<?xml version="1.0"?>"#);
771    }
772
773    #[test]
774    fn writes_output_only_false_for_arrays_of_empty_arrays() {
775        assert!(!writes_output(&json!([])));
776        assert!(!writes_output(&json!([[], [[]]])));
777        assert!(writes_output(&json!([[], ["x"]])));
778        assert!(writes_output(&json!([{}])));
779        assert!(writes_output(&json!({})));
780        assert!(writes_output(&json!("")));
781        assert!(writes_output(&json!(null)));
782    }
783
784    #[test]
785    fn declaration_newline_kept_when_root_writes_output() {
786        let doc = json!({ "?xml": { "@version": "1.0" }, "root": [[], { "a": "1" }] });
787        assert_eq!(
788            build_xml_string(&doc),
789            "<?xml version=\"1.0\"?>\n<root>\n    <a>1</a>\n</root>"
790        );
791    }
792
793    #[test]
794    fn nested_empty_array_root_writes_declaration_only() {
795        let doc = json!({ "?xml": { "@version": "1.0" }, "root": [[], [[]]] });
796        assert_eq!(build_xml_string(&doc), r#"<?xml version="1.0"?>"#);
797        assert_eq!(build_xml_string(&json!({ "root": [] })), "");
798    }
799
800    #[test]
801    fn write_xml_file_matches_build_xml_string_for_empty_root() {
802        let dir = tempfile::tempdir().unwrap();
803        let path = dir.path().join("out.xml");
804        let doc = json!({ "?xml": { "@version": "1.0" }, "root": [] });
805        write_xml_file(&doc, &path, true).unwrap();
806        assert_eq!(
807            std::fs::read_to_string(&path).unwrap(),
808            format!("{}\n", build_xml_string(&doc))
809        );
810    }
811}