use quick_xml::escape::partial_escape;
use quick_xml::events::{BytesCData, BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::Writer;
use serde_json::{Map, Value};
use std::io::{BufWriter, Write};
use std::path::Path;
use super::fragments::{Entry, Fragments, Item};
use crate::xml::types::XmlElement;
pub(super) fn value_to_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => String::new(),
_ => serde_json::to_string(v).unwrap_or_default(),
}
}
pub(super) const CONTENT_KEYS: [&str; 6] = [
"#text",
"#raw-text",
"#comment",
"#text-tail",
"#cdata",
"#compact",
];
pub(super) fn is_child_key(key: &str) -> bool {
!key.starts_with('@') && !CONTENT_KEYS.contains(&key)
}
#[derive(Clone, Copy)]
pub(super) enum Field<'a> {
Value(&'a Value),
Entry(&'a Entry, &'a Fragments<'a>),
}
impl<'a> Field<'a> {
fn as_value(self) -> Option<&'a Value> {
match self {
Field::Value(v) => Some(v),
Field::Entry(Entry::One(Item::Val(v)), _) => Some(v),
Field::Entry(..) => None,
}
}
fn to_text(self) -> String {
match self {
Field::Value(v) => value_to_string(v),
Field::Entry(entry, _) => match self.as_value() {
Some(v) => value_to_string(v),
None => value_to_string(&entry.to_value()),
},
}
}
}
pub(super) fn write_element<W: std::io::Write>(
writer: &mut Writer<W>,
name: &str,
content: &Value,
indent_level: usize,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match content {
Value::Object(obj) => write_object(
writer,
name,
obj.iter().map(|(k, v)| (k.as_str(), Field::Value(v))),
indent_level,
)?,
Value::Array(arr) => {
for item in arr {
write_element(writer, name, item, indent_level)?;
}
}
_ => {
writer.write_event(Event::Start(BytesStart::new(name)))?;
writer.write_event(Event::Text(BytesText::new(
value_to_string(content).as_str(),
)))?;
writer.write_event(Event::End(BytesEnd::new(name)))?;
}
}
Ok(())
}
fn write_item<W: std::io::Write>(
writer: &mut Writer<W>,
name: &str,
item: &Item,
fragments: &Fragments<'_>,
indent_level: usize,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match item {
Item::Frag(frag) => writer.get_mut().write_all(fragments.bytes(frag))?,
Item::Val(v) => write_element(writer, name, v, indent_level)?,
}
Ok(())
}
pub(super) fn write_object<'a, W: std::io::Write>(
writer: &mut Writer<W>,
name: &str,
fields: impl Iterator<Item = (&'a str, Field<'a>)>,
indent_level: usize,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let indent = " ".repeat(indent_level);
let child_indent = " ".repeat(indent_level + 1);
let mut attrs: Vec<(&str, String)> = Vec::new();
let mut text_content = String::new();
let mut raw_text_content = String::new();
let mut comment_content = String::new();
let mut text_tail_content = String::new();
let mut cdata_content = String::new();
let mut is_compact = false;
let mut child_elements: Vec<(&str, Field<'a>)> = Vec::new();
for (k, field) in fields {
if k.starts_with('@') {
attrs.push((k.trim_start_matches('@'), field.to_text()));
} else if k == "#text" {
text_content = field.to_text();
} else if k == "#raw-text" {
raw_text_content = field.to_text();
} else if k == "#comment" {
comment_content = field.to_text();
} else if k == "#text-tail" {
text_tail_content = field.to_text();
} else if k == "#cdata" {
cdata_content = field.to_text();
} else if k == "#compact" {
is_compact = field.as_value().and_then(Value::as_bool).unwrap_or(false);
} else {
child_elements.push((k, field));
}
}
let mut start = BytesStart::new(name);
for (k, v) in &attrs {
start.push_attribute((*k, v.as_str()));
}
writer.write_event(Event::Start(start))?;
let has_mixed_content = !cdata_content.is_empty()
|| !text_content.is_empty()
|| !raw_text_content.is_empty()
|| !comment_content.is_empty()
|| !text_tail_content.is_empty();
let compact_child = match child_elements.as_slice() {
[(child_name, field)] if is_compact => match field.as_value() {
Some(v @ Value::Object(_)) => Some((*child_name, v)),
None if matches!(field, Field::Entry(Entry::One(Item::Frag(_)), _)) => {
return Err("compact element with a pre-serialized child".into());
}
_ => None,
},
_ => None,
};
if let Some((child_name, child_value)) = compact_child {
write_element(writer, child_name, child_value, indent_level)?;
} else {
if !child_elements.is_empty() {
writer.write_event(Event::Text(BytesText::new(
format!("\n{}", child_indent).as_str(),
)))?;
let child_count = child_elements.len();
for (idx, (child_name, field)) in child_elements.iter().enumerate() {
let is_last = idx == child_count - 1;
match *field {
Field::Value(Value::Array(arr))
| Field::Entry(Entry::One(Item::Val(Value::Array(arr))), _) => {
let arr_len = arr.len();
for (i, item) in arr.iter().enumerate() {
let arr_last = i == arr_len - 1;
write_element(writer, child_name, item, indent_level + 1)?;
if !arr_last {
writer.write_event(Event::Text(BytesText::new(
format!("\n{}", child_indent).as_str(),
)))?;
}
}
}
Field::Entry(Entry::Many(items), fragments) => {
let arr_len = items.len();
for (i, item) in items.iter().enumerate() {
let arr_last = i == arr_len - 1;
write_item(writer, child_name, item, fragments, indent_level + 1)?;
if !arr_last {
writer.write_event(Event::Text(BytesText::new(
format!("\n{}", child_indent).as_str(),
)))?;
}
}
}
Field::Value(v @ Value::Object(_))
| Field::Entry(Entry::One(Item::Val(v @ Value::Object(_))), _) => {
write_element(writer, child_name, v, indent_level + 1)?;
}
Field::Entry(Entry::One(Item::Frag(frag)), fragments) => {
writer.get_mut().write_all(fragments.bytes(frag))?;
}
Field::Value(v) | Field::Entry(Entry::One(Item::Val(v)), _) => {
writer.write_event(Event::Start(BytesStart::new(*child_name)))?;
writer.write_event(Event::Text(BytesText::new(
value_to_string(v).as_str(),
)))?;
writer.write_event(Event::End(BytesEnd::new(*child_name)))?;
}
}
if !is_last {
writer.write_event(Event::Text(BytesText::new(
format!("\n{}", child_indent).as_str(),
)))?;
}
}
writer.write_event(Event::Text(BytesText::new(
format!("\n{}", indent).as_str(),
)))?;
}
if has_mixed_content {
if child_elements.is_empty()
&& text_content.is_empty()
&& raw_text_content.is_empty()
&& comment_content.is_empty()
{
writer.write_event(Event::Text(BytesText::new(
format!("\n{}", child_indent).as_str(),
)))?;
}
if !text_content.is_empty() {
writer.write_event(Event::Text(BytesText::new(text_content.as_str())))?;
}
if !raw_text_content.is_empty() {
writer.write_event(Event::Text(BytesText::from_escaped(partial_escape(
raw_text_content.as_str(),
))))?;
}
if !comment_content.is_empty() {
writer.write_event(Event::Comment(BytesText::from_escaped(
comment_content.as_str(),
)))?;
}
if !text_tail_content.is_empty() {
writer.write_event(Event::Text(BytesText::new(text_tail_content.as_str())))?;
}
if !cdata_content.is_empty() {
writer.write_event(Event::CData(BytesCData::new(cdata_content.as_str())))?;
}
if !cdata_content.is_empty() && child_elements.is_empty() {
writer.write_event(Event::Text(BytesText::new(
format!("\n{}", indent).as_str(),
)))?;
}
}
}
writer.write_event(Event::End(BytesEnd::new(name)))?;
Ok(())
}
fn writes_output(value: &Value) -> bool {
match value {
Value::Array(items) => items.iter().any(writes_output),
_ => true,
}
}
fn write_xml<W: Write>(
element: &XmlElement,
out: W,
) -> Result<W, Box<dyn std::error::Error + Send + Sync>> {
let mut writer = Writer::new(out);
let Value::Object(element) = element else {
return Ok(writer.into_inner());
};
let empty_root = Value::Object(Map::new());
let (declaration, root_key, root_value) = if let Some(decl) = element.get("?xml") {
let root_key = element
.keys()
.find(|k| *k != "?xml")
.cloned()
.unwrap_or_else(|| "root".to_string());
let root_value = element.get(&root_key).unwrap_or(&empty_root);
(Some(decl), root_key, root_value)
} else {
let root_key = element
.keys()
.next()
.cloned()
.unwrap_or_else(|| "root".to_string());
let root_value = element.get(&root_key).unwrap_or(&empty_root);
(None, root_key, root_value)
};
let root_writes_output = writes_output(root_value);
if let Some(obj) = declaration.and_then(|d| d.as_object()) {
let version = obj
.get("@version")
.and_then(|v| v.as_str())
.unwrap_or("1.0");
let encoding = obj.get("@encoding").and_then(|v| v.as_str());
let standalone = obj.get("@standalone").and_then(|v| v.as_str());
writer.write_event(Event::Decl(BytesDecl::new(version, encoding, standalone)))?;
if root_writes_output {
writer.write_event(Event::Text(BytesText::new("\n")))?;
}
}
if root_writes_output {
write_element(&mut writer, &root_key, root_value, 0)?;
}
Ok(writer.into_inner())
}
pub(crate) fn write_xml_file(
element: &XmlElement,
path: &Path,
trailing_newline: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let file = std::fs::File::create(path)?;
let mut out = write_xml(element, BufWriter::new(file))?;
if trailing_newline {
out.write_all(b"\n")?;
}
out.flush()?;
Ok(())
}
pub fn build_xml_string(element: &XmlElement) -> String {
let out = write_xml(element, Vec::new())
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.unwrap_or_default();
debug_assert_eq!(
out.trim_end().len(),
out.len(),
"serialized XML must not end with whitespace"
);
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn build_xml_string_non_object_returns_empty() {
assert!(build_xml_string(&Value::Array(vec![])).is_empty());
assert!(build_xml_string(&Value::Null).is_empty());
}
#[test]
fn build_xml_string_simple_root() {
let el = json!({
"?xml": { "@version": "1.0", "@encoding": "UTF-8" },
"root": { "child": "value" }
});
let out = build_xml_string(&el);
assert!(out.contains("<?xml"));
assert!(out.contains("<root>"));
assert!(out.contains("<child>value</child>"));
assert!(out.contains("</root>"));
}
#[test]
fn build_xml_string_with_attributes() {
let el = json!({
"root": { "@xmlns": "http://example.com", "a": "b" }
});
let out = build_xml_string(&el);
assert!(out.contains("xmlns"));
assert!(out.contains("http://example.com"));
assert!(out.contains("<a>b</a>"));
}
#[test]
fn build_xml_string_with_array() {
let el = json!({
"root": { "item": [ { "x": "1" }, { "x": "2" } ] }
});
let out = build_xml_string(&el);
assert!(out.contains("<item>"));
assert!(out.contains("<x>1</x>"));
assert!(out.contains("<x>2</x>"));
}
#[test]
fn build_xml_string_without_declaration() {
let el = json!({ "root": { "a": "b" } });
let out = build_xml_string(&el);
assert!(!out.contains("<?xml"));
assert!(out.contains("<root>"));
}
#[test]
fn build_xml_string_with_text_comment_cdata() {
let root = json!({
"#text": "text",
"#comment": " a comment ",
"#cdata": "<cdata>"
});
let el = json!({
"?xml": { "@version": "1.0" },
"root": root
});
let out = build_xml_string(&el);
assert!(out.contains("text"));
assert!(out.contains("<!--"));
assert!(out.contains(" a comment "));
assert!(out.contains("<![CDATA["));
assert!(out.contains("<cdata>"));
}
#[test]
fn build_xml_string_with_declaration_encoding_standalone() {
let el = json!({
"?xml": { "@version": "1.0", "@encoding": "UTF-8", "@standalone": "yes" },
"root": { "a": "b" }
});
let out = build_xml_string(&el);
assert!(out.contains("<?xml"));
assert!(out.contains("UTF-8"));
assert!(out.contains("standalone"));
assert!(out.contains("<root>"));
}
#[test]
fn build_xml_string_primitive_sibling_children() {
let el = json!({
"root": { "obj": { "x": "1" }, "num": 42, "flag": true }
});
let out = build_xml_string(&el);
assert!(out.contains("<obj>"));
assert!(out.contains("<num>42</num>"));
assert!(out.contains("<flag>true</flag>"));
}
#[test]
fn build_xml_string_null_child_value() {
let el = json!({
"root": { "empty": null }
});
let out = build_xml_string(&el);
assert!(out.contains("<empty>"));
assert!(out.contains("</empty>"));
assert!(
!out.contains("null"),
"Value::Null child should render as empty content, not the string \"null\": {out}"
);
assert!(out.contains("<empty></empty>"));
}
#[test]
fn build_xml_string_primitive_siblings_have_inter_element_indent() {
let el = json!({ "root": { "a": 1, "b": 2 } });
let out = build_xml_string(&el);
assert!(
out.contains("<a>1</a>\n <b>2</b>"),
"expected `<a>1</a>` to be followed by newline + 4-space indent then `<b>2</b>`, got:\n{out}"
);
assert!(
out.contains("<b>2</b>\n</root>"),
"expected `<b>2</b>` to be followed directly by the root close tag, got:\n{out}"
);
}
#[test]
fn build_xml_string_comment_only_leaf() {
let el = json!({
"?xml": { "@version": "1.0" },
"root": { "#comment": " just a comment " }
});
let out = build_xml_string(&el);
assert!(out.contains("<!--"), "expected comment open in: {out}");
assert!(
out.contains(" just a comment "),
"expected comment text preserved verbatim in: {out}"
);
assert!(out.contains("-->"));
}
#[test]
fn build_xml_string_text_tail_only_leaf() {
let el = json!({
"?xml": { "@version": "1.0" },
"root": { "#text-tail": "tail-only-content" }
});
let out = build_xml_string(&el);
assert!(
out.contains("tail-only-content"),
"expected text-tail content rendered between root tags, got:\n{out}"
);
assert!(out.contains("<root>"));
assert!(out.contains("</root>"));
}
#[test]
fn build_xml_string_cdata_only_no_text_or_comment() {
let root = json!({ "#cdata": "only cdata content" });
let el = json!({ "?xml": { "@version": "1.0" }, "root": root });
let out = build_xml_string(&el);
assert!(out.contains("<![CDATA["));
assert!(out.contains("only cdata content"));
}
#[test]
fn build_xml_string_declaration_only_defaults_root_key() {
let el = json!({ "?xml": { "@version": "1.0", "@encoding": "UTF-8" } });
let out = build_xml_string(&el);
assert!(out.contains("<?xml"));
assert!(out.contains("<root>"));
}
#[test]
fn build_xml_string_declaration_non_object_skips_decl_write() {
let el = json!({ "?xml": "not-an-object", "root": { "a": "b" } });
let out = build_xml_string(&el);
assert!(!out.contains("<?xml"));
assert!(out.contains("<root>"));
}
#[test]
fn build_xml_string_root_value_array_sibling_elements() {
let el = json!({
"root": [ { "a": "1" }, { "b": "2" } ]
});
let out = build_xml_string(&el);
assert!(out.contains("<root>"));
assert!(out.contains("<a>1</a>"));
assert!(out.contains("<b>2</b>"));
assert!(out.contains("</root>"));
}
#[test]
fn build_xml_string_root_value_primitive() {
let el = json!({ "root": 42 });
let out = build_xml_string(&el);
assert!(out.contains("<root>42</root>"));
}
#[test]
fn build_xml_string_array_child_not_last_sibling_writes_inter_element_indent() {
let el = json!({
"root": {
"items": [{ "x": "1" }],
"sibling": "y"
}
});
let out = build_xml_string(&el);
assert!(
out.contains("<items>"),
"items element must be present: {out}"
);
assert!(
out.contains("<sibling>y</sibling>"),
"sibling must be present: {out}"
);
}
#[test]
fn build_xml_string_empty_array_child_produces_no_elements() {
let el = json!({ "root": { "items": [] } });
let out = build_xml_string(&el);
assert!(
!out.contains("<items>"),
"no elements for empty array: {out}"
);
assert!(
out.contains("<root>"),
"root element must be present: {out}"
);
}
#[test]
fn build_xml_string_attribute_value_array_uses_serde_fallback() {
let el = json!({
"root": { "@tags": ["a", "b"], "child": "v" }
});
let out = build_xml_string(&el);
assert!(
out.contains("child"),
"child element must be present: {out}"
);
}
#[test]
fn build_xml_string_attribute_value_object_uses_serde_fallback() {
let el = json!({
"root": { "@complex": { "nested": true }, "child": "v" }
});
let out = build_xml_string(&el);
assert!(out.contains("child"));
assert!(out.contains("v"));
}
fn sample_doc() -> XmlElement {
json!({
"?xml": { "@version": "1.0", "@encoding": "UTF-8" },
"Profile": {
"@xmlns": "http://soap.sforce.com/2006/04/metadata",
"custom": "false",
"fieldPermissions": [
{ "editable": "true", "field": "Account.Name" },
{ "editable": "false", "field": "Account.Phone" }
],
"description": { "#cdata": "a < b" }
}
})
}
#[test]
fn write_xml_file_matches_build_xml_string() {
let dir = tempfile::tempdir().unwrap();
let doc = sample_doc();
for trailing_newline in [false, true] {
let path = dir.path().join(format!("out-{trailing_newline}.xml"));
write_xml_file(&doc, &path, trailing_newline).unwrap();
let mut expected = build_xml_string(&doc);
if trailing_newline {
expected.push('\n');
}
assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
}
}
#[test]
fn write_xml_file_truncates_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.xml");
std::fs::write(&path, "x".repeat(100_000)).unwrap();
let doc = json!({ "root": { "a": "1" } });
write_xml_file(&doc, &path, false).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
build_xml_string(&doc)
);
}
#[test]
fn write_xml_file_non_object_writes_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.xml");
write_xml_file(&json!("scalar"), &path, false).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "");
}
#[test]
fn write_xml_file_errors_when_directory_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing").join("out.xml");
assert!(write_xml_file(&sample_doc(), &path, false).is_err());
}
#[test]
fn build_xml_string_trims_newline_after_declaration_only_document() {
let doc = json!({ "?xml": { "@version": "1.0" }, "root": [] });
let out = build_xml_string(&doc);
assert_eq!(out, r#"<?xml version="1.0"?>"#);
}
#[test]
fn writes_output_only_false_for_arrays_of_empty_arrays() {
assert!(!writes_output(&json!([])));
assert!(!writes_output(&json!([[], [[]]])));
assert!(writes_output(&json!([[], ["x"]])));
assert!(writes_output(&json!([{}])));
assert!(writes_output(&json!({})));
assert!(writes_output(&json!("")));
assert!(writes_output(&json!(null)));
}
#[test]
fn declaration_newline_kept_when_root_writes_output() {
let doc = json!({ "?xml": { "@version": "1.0" }, "root": [[], { "a": "1" }] });
assert_eq!(
build_xml_string(&doc),
"<?xml version=\"1.0\"?>\n<root>\n <a>1</a>\n</root>"
);
}
#[test]
fn nested_empty_array_root_writes_declaration_only() {
let doc = json!({ "?xml": { "@version": "1.0" }, "root": [[], [[]]] });
assert_eq!(build_xml_string(&doc), r#"<?xml version="1.0"?>"#);
assert_eq!(build_xml_string(&json!({ "root": [] })), "");
}
#[test]
fn write_xml_file_matches_build_xml_string_for_empty_root() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.xml");
let doc = json!({ "?xml": { "@version": "1.0" }, "root": [] });
write_xml_file(&doc, &path, true).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
format!("{}\n", build_xml_string(&doc))
);
}
}