use serde_json::{Map, Value};
const CONTENT_KEYS: [&str; 4] = ["#text", "#comment", "#text-tail", "#cdata"];
fn is_meta_key(key: &str) -> bool {
key.starts_with('#') || key.starts_with('@') || key == "?xml"
}
pub fn mark_compact_elements(node: &mut Value) {
match node {
Value::Array(arr) => {
for item in arr.iter_mut() {
mark_compact_elements(item);
}
}
Value::Object(obj) => {
for value in obj.values_mut() {
mark_compact_elements(value);
}
let has_content_key = obj.keys().any(|k| CONTENT_KEYS.contains(&k.as_str()));
let mut element_key_count = 0;
let mut sole_child_is_single_element = false;
for (key, value) in obj.iter() {
if !is_meta_key(key) {
element_key_count += 1;
sole_child_is_single_element = !matches!(value, Value::Array(_));
}
}
if !has_content_key && element_key_count == 1 && sole_child_is_single_element {
obj.insert("#compact".to_string(), Value::Bool(true));
}
}
_ => {}
}
}
fn is_empty_text_node(key: &str, value: &Value) -> bool {
(key == "#text" || key == "#cdata" || key == "#text-tail")
&& value.as_str().map(|s| s.trim().is_empty()).unwrap_or(false)
}
fn clean_array(arr: &[Value]) -> Vec<Value> {
arr.iter()
.filter_map(|entry| {
let cleaned = strip_whitespace_text_nodes(entry);
match &cleaned {
Value::Object(m) if m.is_empty() => None,
_ => Some(cleaned),
}
})
.collect()
}
fn clean_object(obj: &Map<String, Value>) -> Map<String, Value> {
let mut result = Map::new();
let has_cdata = obj.contains_key("#cdata");
let has_comment = obj.contains_key("#comment");
for (key, value) in obj {
if is_empty_text_node(key, value)
&& !(key == "#text" && has_cdata)
&& !(key == "#text" && has_comment)
&& !(key == "#text-tail" && has_comment)
{
continue;
}
let cleaned = strip_whitespace_text_nodes(value);
if !cleaned.is_null()
|| key == "#text"
|| key == "#cdata"
|| key == "#comment"
|| key == "#text-tail"
{
result.insert(key.clone(), cleaned);
}
}
result
}
pub fn strip_whitespace_text_nodes(node: &Value) -> Value {
match node {
Value::Array(arr) => Value::Array(clean_array(arr)),
Value::Object(obj) => Value::Object(clean_object(obj)),
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn strips_empty_text_nodes_from_array() {
let input = json!([{ "#text": " " }, { "#text": "keep me" }]);
let result = strip_whitespace_text_nodes(&input);
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(
arr[0].get("#text").and_then(|v| v.as_str()),
Some("keep me")
);
}
#[test]
fn preserves_non_empty_text() {
let input = json!({ "#text": " content " });
let result = strip_whitespace_text_nodes(&input);
assert_eq!(
result.get("#text").and_then(|v| v.as_str()),
Some(" content ")
);
}
#[test]
fn leaves_primitive_unchanged() {
let input = json!("hello");
let result = strip_whitespace_text_nodes(&input);
assert_eq!(result, json!("hello"));
}
#[test]
fn preserves_empty_text_when_element_has_cdata() {
let input = json!({ "#cdata": "content", "#text": " " });
let result = strip_whitespace_text_nodes(&input);
let obj = result.as_object().unwrap();
assert_eq!(obj.get("#cdata").and_then(|v| v.as_str()), Some("content"));
assert_eq!(obj.get("#text").and_then(|v| v.as_str()), Some(" "));
}
#[test]
fn preserves_null_special_keys() {
let input = json!({ "#text": null });
let result = strip_whitespace_text_nodes(&input);
assert!(result.get("#text").map(|v| v.is_null()) == Some(true));
}
#[test]
fn strips_whitespace_only_cdata_node() {
let input = json!([{ "#cdata": " " }, { "#text": "keep me" }]);
let result = strip_whitespace_text_nodes(&input);
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(
arr[0].get("#text").and_then(|v| v.as_str()),
Some("keep me")
);
}
#[test]
fn strips_whitespace_only_text_tail_node() {
let input = json!([{ "#text-tail": " " }, { "#text": "keep me" }]);
let result = strip_whitespace_text_nodes(&input);
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(
arr[0].get("#text").and_then(|v| v.as_str()),
Some("keep me")
);
}
#[test]
fn preserves_whitespace_text_tail_when_element_has_comment() {
let input = json!({ "#comment": "note", "#text-tail": " " });
let result = strip_whitespace_text_nodes(&input);
let obj = result.as_object().unwrap();
assert_eq!(obj.get("#comment").and_then(|v| v.as_str()), Some("note"));
assert_eq!(obj.get("#text-tail").and_then(|v| v.as_str()), Some(" "));
}
#[test]
fn preserves_null_cdata_comment_and_text_tail_keys() {
let input = json!({
"#cdata": null,
"#comment": null,
"#text-tail": null,
"a": "b"
});
let result = strip_whitespace_text_nodes(&input);
let obj = result.as_object().unwrap();
assert!(obj.get("#cdata").map(|v| v.is_null()) == Some(true));
assert!(obj.get("#comment").map(|v| v.is_null()) == Some(true));
assert!(obj.get("#text-tail").map(|v| v.is_null()) == Some(true));
assert_eq!(obj.get("a").and_then(|v| v.as_str()), Some("b"));
}
#[test]
fn mark_compact_elements_marks_sole_element_child_with_no_whitespace() {
let mut input = json!({
"connector": { "targetReference": { "#text": "X" } }
});
mark_compact_elements(&mut input);
let connector = input.get("connector").and_then(|v| v.as_object()).unwrap();
assert_eq!(connector.get("#compact"), Some(&Value::Bool(true)));
}
#[test]
fn mark_compact_elements_recurses_into_array_items() {
let mut input = json!({
"items": [
{ "wrapper": { "child": { "#text": "1" } } },
{ "unrelated": { "#text": "2" } }
]
});
mark_compact_elements(&mut input);
let items = input.get("items").and_then(|v| v.as_array()).unwrap();
let wrapper = items[0].get("wrapper").and_then(|v| v.as_object()).unwrap();
assert_eq!(
wrapper.get("#compact"),
Some(&Value::Bool(true)),
"wrapper nested inside an array item must still be marked compact"
);
}
#[test]
fn mark_compact_elements_treats_hash_prefixed_marker_as_meta_not_element() {
let mut input = json!({
"wrapper": { "#some-other-marker": "ignored", "child": { "#text": "1" } }
});
mark_compact_elements(&mut input);
let wrapper = input.get("wrapper").and_then(|v| v.as_object()).unwrap();
assert_eq!(wrapper.get("#compact"), Some(&Value::Bool(true)));
}
#[test]
fn mark_compact_elements_treats_xml_declaration_key_as_meta_not_element() {
let mut input = json!({
"wrapper": { "?xml": { "@version": "1.0" }, "child": { "#text": "1" } }
});
mark_compact_elements(&mut input);
let wrapper = input.get("wrapper").and_then(|v| v.as_object()).unwrap();
assert_eq!(wrapper.get("#compact"), Some(&Value::Bool(true)));
}
#[test]
fn mark_compact_elements_does_not_mark_block_formatted_wrapper() {
let mut input = json!({
"connector": {
"#text": "\n ",
"targetReference": { "#text": "X" }
}
});
mark_compact_elements(&mut input);
let connector = input.get("connector").and_then(|v| v.as_object()).unwrap();
assert!(connector.get("#compact").is_none());
}
#[test]
fn mark_compact_elements_recurses_into_nested_children() {
let mut input = json!({
"decisions": {
"name": { "#text": "Decision_0001" },
"value": { "stringValue": { "#text": "Match" } }
}
});
mark_compact_elements(&mut input);
let decisions = input.get("decisions").and_then(|v| v.as_object()).unwrap();
assert!(
decisions.get("#compact").is_none(),
"element with multiple children must never be marked compact"
);
let value = decisions.get("value").and_then(|v| v.as_object()).unwrap();
assert_eq!(value.get("#compact"), Some(&Value::Bool(true)));
}
#[test]
fn mark_compact_elements_ignores_array_valued_sole_key() {
let mut input = json!({
"parent": { "item": [{ "#text": "1" }, { "#text": "2" }] }
});
mark_compact_elements(&mut input);
let parent = input.get("parent").and_then(|v| v.as_object()).unwrap();
assert!(parent.get("#compact").is_none());
}
#[test]
fn mark_compact_elements_ignores_element_with_attributes_and_text() {
let mut input = json!({
"field": { "@type": "string", "#text": "value" }
});
mark_compact_elements(&mut input);
let field = input.get("field").and_then(|v| v.as_object()).unwrap();
assert!(field.get("#compact").is_none());
}
#[test]
fn mark_compact_elements_leaves_primitives_and_empty_containers_unchanged() {
let mut s = json!("hello");
mark_compact_elements(&mut s);
assert_eq!(s, json!("hello"));
let mut empty_obj = json!({});
mark_compact_elements(&mut empty_obj);
assert_eq!(empty_obj, json!({}));
let mut empty_arr = json!([]);
mark_compact_elements(&mut empty_arr);
assert_eq!(empty_arr, json!([]));
}
}