1use 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
23pub(super) const CONTENT_KEYS: [&str; 6] = [
26 "#text",
27 "#raw-text",
28 "#comment",
29 "#text-tail",
30 "#cdata",
31 "#compact",
32];
33
34pub(super) fn is_child_key(key: &str) -> bool {
36 !key.starts_with('@') && !CONTENT_KEYS.contains(&key)
37}
38
39#[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 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 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
101fn 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 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
117pub(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 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 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 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 if has_mixed_content {
259 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 if !text_content.is_empty() {
274 writer.write_event(Event::Text(BytesText::new(text_content.as_str())))?;
275 }
276 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 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 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
316fn writes_output(value: &Value) -> bool {
320 match value {
321 Value::Array(items) => items.iter().any(writes_output),
322 _ => true,
323 }
324}
325
326fn write_xml<W: Write>(
334 element: &XmlElement,
335 out: W,
336) -> Result<W, Box<dyn std::error::Error + Send + Sync>> {
337 let mut writer = Writer::new(out);
339 let Value::Object(element) = element else {
340 return Ok(writer.into_inner());
341 };
342
343 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
386pub(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 out.flush()?;
405 Ok(())
406}
407
408pub 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 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 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 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 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 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 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 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 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 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 let el = json!({ "root": { "items": [] } });
668 let out = build_xml_string(&el);
669 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 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 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 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 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}