univert 0.4.0

Universal file converter (library and CLI)
Documentation
//! XML ↔ JSON conversion.
//!
//! Uses `quick-xml` event-stream parsing and emits a JSON object whose shape
//! mirrors the XML tree:
//! - Element attributes become keys prefixed with `@`.
//! - Element text becomes a `#text` key (only when mixed with attributes or
//!   children — elements that contain only text collapse to a string).
//! - Repeated sibling elements with the same tag collapse into an array.

use std::fs;
use std::io::Cursor;

use quick_xml::Writer;
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
use quick_xml::reader::Reader;
use serde_json::{Map, Value};

use crate::{Error, Job, Result};

pub fn xml_to_json(job: &Job) -> Result<()> {
  let raw = fs::read_to_string(job.input)?;
  let value = parse_xml(&raw)?;
  let json = serde_json::to_string_pretty(&value)
    .map_err(|e| Error::other(format!("json serialize: {e}")))?;
  fs::write(job.output, json + "\n")?;
  Ok(())
}

pub fn json_to_xml(job: &Job) -> Result<()> {
  let raw = fs::read_to_string(job.input)?;
  let value: Value = serde_json::from_str(&raw)
    .map_err(|e| Error::other(format!("invalid JSON: {e}")))?;
  let xml = render_xml(&value)?;
  fs::write(job.output, xml)?;
  Ok(())
}

fn parse_xml(raw: &str) -> Result<Value> {
  let mut reader = Reader::from_str(raw);
  reader.config_mut().trim_text(true);

  let mut stack: Vec<(String, Value)> = Vec::new();
  let mut root: Option<(String, Value)> = None;
  let mut buf = Vec::new();

  loop {
    match reader
      .read_event_into(&mut buf)
      .map_err(|e| Error::other(format!("xml parse: {e}")))?
    {
      Event::Start(ref e) => {
        let (name, node) = build_element(e)?;
        stack.push((name, node));
      }
      Event::Empty(ref e) => {
        let (name, node) = build_element(e)?;
        let collapsed = collapse_node(node);
        attach_child(&mut stack, &mut root, &name, collapsed);
      }
      Event::End(_) => {
        let (name, node) = stack.pop().ok_or_else(|| {
          Error::other("xml: unmatched closing tag".to_string())
        })?;
        let collapsed = collapse_node(node);
        attach_child(&mut stack, &mut root, &name, collapsed);
      }
      Event::Text(t) => {
        let text = String::from_utf8(t.into_inner().into_owned())
          .map_err(|e| Error::other(format!("xml text utf8: {e}")))?;
        let text = unescape_xml(&text);
        if !text.is_empty()
          && let Some((_, node)) = stack.last_mut()
        {
          append_text(node, &text);
        }
      }
      Event::CData(cdata) => {
        let text = String::from_utf8(cdata.into_inner().into_owned())
          .map_err(|e| Error::other(format!("xml cdata: {e}")))?;
        if !text.is_empty()
          && let Some((_, node)) = stack.last_mut()
        {
          append_text(node, &text);
        }
      }
      Event::Eof => break,
      _ => {}
    }
    buf.clear();
  }

  let (name, value) =
    root.ok_or_else(|| Error::other("xml: no root element".to_string()))?;
  let mut wrapper = Map::new();
  wrapper.insert(name, value);
  Ok(Value::Object(wrapper))
}

fn build_element(e: &BytesStart) -> Result<(String, Value)> {
  let name = std::str::from_utf8(e.name().as_ref())
    .map_err(|err| Error::other(format!("xml tag utf8: {err}")))?
    .to_string();

  let mut obj = Map::new();
  for attr in e.attributes() {
    let attr = attr.map_err(|err| Error::other(format!("xml attr: {err}")))?;
    let key = std::str::from_utf8(attr.key.as_ref())
      .map_err(|err| Error::other(format!("xml attr name: {err}")))?
      .to_string();
    let value = attr
      .unescape_value()
      .map_err(|err| Error::other(format!("xml attr value: {err}")))?
      .into_owned();
    obj.insert(format!("@{key}"), Value::String(value));
  }
  Ok((name, Value::Object(obj)))
}

fn attach_child(
  stack: &mut [(String, Value)],
  root: &mut Option<(String, Value)>,
  name: &str,
  child: Value,
) {
  if let Some((_, parent)) = stack.last_mut() {
    let Value::Object(map) = parent else {
      return;
    };
    if let Some(existing) = map.remove(name) {
      let arr = match existing {
        Value::Array(mut arr) => {
          arr.push(child);
          arr
        }
        other => vec![other, child],
      };
      map.insert(name.to_string(), Value::Array(arr));
    } else {
      map.insert(name.to_string(), child);
    }
  } else {
    *root = Some((name.to_string(), child));
  }
}

fn append_text(node: &mut Value, text: &str) {
  let Value::Object(map) = node else {
    return;
  };
  match map.get_mut("#text") {
    Some(Value::String(existing)) => existing.push_str(text),
    _ => {
      map.insert("#text".to_string(), Value::String(text.to_string()));
    }
  }
}

fn unescape_xml(s: &str) -> String {
  let mut out = String::with_capacity(s.len());
  let mut rest = s;
  while let Some(i) = rest.find('&') {
    out.push_str(&rest[..i]);
    rest = &rest[i..];
    if let Some(end) = rest.find(';') {
      let entity = &rest[1..end];
      match entity {
        "amp" => out.push('&'),
        "lt" => out.push('<'),
        "gt" => out.push('>'),
        "apos" => out.push('\''),
        "quot" => out.push('"'),
        e if e.starts_with("#x") || e.starts_with("#X") => {
          if let Ok(n) = u32::from_str_radix(&e[2..], 16)
            && let Some(c) = char::from_u32(n)
          {
            out.push(c);
          }
        }
        e if e.starts_with('#') => {
          if let Ok(n) = e[1..].parse::<u32>()
            && let Some(c) = char::from_u32(n)
          {
            out.push(c);
          }
        }
        _ => {
          out.push('&');
          out.push_str(entity);
          out.push(';');
        }
      }
      rest = &rest[end + 1..];
    } else {
      out.push_str(rest);
      break;
    }
  }
  out.push_str(rest);
  out
}

fn collapse_node(value: Value) -> Value {
  // Element with only a `#text` child and no attributes / children becomes
  // its text string.
  if let Value::Object(map) = &value
    && map.len() == 1
    && let Some(Value::String(text)) = map.get("#text")
  {
    return Value::String(text.clone());
  }
  value
}

fn render_xml(value: &Value) -> Result<String> {
  let mut writer = Writer::new(Cursor::new(Vec::new()));

  match value {
    Value::Object(map) if map.len() == 1 => {
      let (name, body) = map.iter().next().unwrap();
      write_node(&mut writer, name, body)?;
    }
    // Any other shape (multi-key object, array, scalar) gets wrapped in
    // a synthetic <root> element to guarantee a well-formed document.
    _ => write_node(&mut writer, "root", value)?,
  }

  let bytes = writer.into_inner().into_inner();
  String::from_utf8(bytes).map_err(|e| Error::other(format!("xml utf8: {e}")))
}

fn write_node(
  writer: &mut Writer<Cursor<Vec<u8>>>,
  tag: &str,
  value: &Value,
) -> Result<()> {
  match value {
    Value::Null => {
      writer
        .write_event(Event::Empty(BytesStart::new(tag)))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;
    }
    Value::Array(items) => {
      for item in items {
        write_node(writer, tag, item)?;
      }
    }
    Value::Object(map) => {
      let mut start = BytesStart::new(tag);
      let mut text: Option<&str> = None;
      let mut children: Vec<(&String, &Value)> = Vec::new();

      for (k, v) in map {
        if let Some(attr_name) = k.strip_prefix('@')
          && let Value::String(s) = v
        {
          start.push_attribute((attr_name, s.as_str()));
          continue;
        }
        if k == "#text"
          && let Value::String(s) = v
        {
          text = Some(s);
          continue;
        }
        children.push((k, v));
      }

      if text.is_none() && children.is_empty() {
        writer
          .write_event(Event::Empty(start))
          .map_err(|e| Error::other(format!("xml write: {e}")))?;
        return Ok(());
      }

      writer
        .write_event(Event::Start(start))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;

      if let Some(t) = text {
        writer
          .write_event(Event::Text(BytesText::new(t)))
          .map_err(|e| Error::other(format!("xml write: {e}")))?;
      }
      for (child_tag, child_val) in children {
        write_node(writer, child_tag, child_val)?;
      }

      writer
        .write_event(Event::End(BytesEnd::new(tag)))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;
    }
    Value::String(s) => {
      writer
        .write_event(Event::Start(BytesStart::new(tag)))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;
      writer
        .write_event(Event::Text(BytesText::new(s)))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;
      writer
        .write_event(Event::End(BytesEnd::new(tag)))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;
    }
    Value::Number(n) => {
      let s = n.to_string();
      writer
        .write_event(Event::Start(BytesStart::new(tag)))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;
      writer
        .write_event(Event::Text(BytesText::new(&s)))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;
      writer
        .write_event(Event::End(BytesEnd::new(tag)))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;
    }
    Value::Bool(b) => {
      let s = if *b { "true" } else { "false" };
      writer
        .write_event(Event::Start(BytesStart::new(tag)))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;
      writer
        .write_event(Event::Text(BytesText::new(s)))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;
      writer
        .write_event(Event::End(BytesEnd::new(tag)))
        .map_err(|e| Error::other(format!("xml write: {e}")))?;
    }
  }
  Ok(())
}