XML ↔ JSON Converter — Rust source
Convert XML to JSON and back, preserving attributes. Validates input and reports errors clearly, runs entirely in your browser, with a shareable link to your exact input.
This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! xml-to-json — bidirectional XML <-> JSON converter.
//!
//! Language: Rust (edition 2021, standard library only)
//! Source: CosmoDev polyglot showcase port of the XML-to-JSON tool, ported
//! from cli/xml-to-json/xml-to-json.go (the canonical Go CLI twin).
//! License: display source — part of CosmoDev's polyglot tool pages.
//!
//! Design goals:
//! - Pure + deterministic; never panics (the public API returns `Option`,
//! `None` for malformed input).
//! - Functionally equivalent to the Go twin: same inputs -> same output.
//! - Self-contained: std only (no crates.io dependencies — Rust's std has no
//! XML or JSON support, so both are hand-rolled here, mirroring the Go
//! twin's `encoding/xml` + `encoding/json` semantics).
//!
//! Contract (matches the Go twin):
//! - `xml_to_json` returns `None` on malformed XML (any parse error, no root,
//! multiple roots, mismatched tags).
//! - Attributes become `@_<name>` string keys.
//! - A text-only element with no attributes becomes its bare string value.
//! - An element with attributes and/or children becomes an object; its
//! direct text becomes the `#text` key.
//! - Repeated child tags become a JSON array.
//! - Object keys are emitted sorted (BTreeMap), matching Go's `json.Marshal`
//! which sorts `map[string]interface{}` keys. JSON output is pretty-printed
//! with 2-space indentation, matching `json.MarshalIndent`.
//!
//! XML parsing note: the Go twin leans on `encoding/xml`'s strict, well-formed
//! tokenizer. Rust's std has no equivalent, so we hand-roll a minimal
//! recursive-descent parser over the byte slice: it handles the subset the tool
//! is designed for (elements, attributes, text, CDATA, comments, processing
//! instructions, the five named entities plus numeric character references).
//! All slicing happens at ASCII delimiter boundaries (`<`, `>`, `/`, `"`, `'`,
//! `=`, whitespace), which are single bytes that never occur inside a UTF-8
//! multibyte sequence, so the text slices are always valid UTF-8.
use std::collections::BTreeMap;
use std::str::from_utf8;
/// A minimal JSON value tree. Element text and attributes are always `Str`
/// (the Go twin does no number coercion); `Num`/`Bool`/`Null` only arise when
/// parsing the input JSON for `json_to_xml`. `Obj` keys are sorted via
/// `BTreeMap`, matching Go's `json.Marshal` key ordering.
#[derive(Debug, Clone, PartialEq)]
enum Json {
Str(String),
Num(f64),
Bool(bool),
Null,
Arr(Vec<Json>),
Obj(BTreeMap<String, Json>),
}
/// Parse a well-formed XML string to pretty-printed JSON, or `None` if
/// malformed.
pub fn xml_to_json(input: &str) -> Option<String> {
let mut p = Parser::new(input);
// Skip the prologue (leading CharData, comments, PIs, DOCTYPE). If the
// stream ends before an element starts, the document has no root.
loop {
p.skip_ws();
if p.eof() {
return None;
}
if p.peek() != b'<' {
// Leading text before the root — allowed by the Go twin (CharData).
while !p.eof() && p.peek() != b'<' {
p.pos += 1;
}
continue;
}
if p.starts_with(b"<?") {
p.skip_pi()?;
continue;
}
if p.starts_with(b"<!--") {
p.skip_comment()?;
continue;
}
if p.starts_with(b"<!") {
p.skip_doctype()?;
continue;
}
break; // element start
}
let (root_tag, value) = p.parse_element()?;
// After the root closes, consume the remainder: trailing CharData,
// comments, and PIs are fine; a second element or a decoder error is not.
loop {
p.skip_ws();
if p.eof() {
break;
}
if p.starts_with(b"<!--") {
p.skip_comment()?;
continue;
}
if p.starts_with(b"<?") {
p.skip_pi()?;
continue;
}
if p.peek() == b'<' {
// A second root element or a stray close tag — not well-formed.
return None;
}
// Trailing CharData — allowed by the Go twin.
while !p.eof() && p.peek() != b'<' {
p.pos += 1;
}
}
let mut root = BTreeMap::new();
root.insert(root_tag, value);
Some(pretty_json(&Json::Obj(root)))
}
/// Build an indented XML string from a JSON string whose root is an object, or
/// `None` if the JSON does not parse or the root is not an object (numbers,
/// strings, bools, null, arrays are rejected).
pub fn json_to_xml(input: &str) -> Option<String> {
let value = parse_json(input)?;
let map = match value {
Json::Obj(m) => m,
_ => return None, // reject non-object roots
};
let mut out = String::new();
// BTreeMap iterates in sorted key order, matching the Go twin.
for (tag, val) in map.iter() {
write_element(&mut out, tag, val, 0);
}
Some(out)
}
// ----------------------------------------------------------------------------
// XML parser
// ----------------------------------------------------------------------------
/// A byte-index cursor over the XML input. Positions always land on ASCII
/// delimiter bytes, so `from_utf8` on any sliced range is valid.
struct Parser<'a> {
b: &'a [u8],
pos: usize,
}
impl<'a> Parser<'a> {
fn new(s: &'a str) -> Self {
Parser { b: s.as_bytes(), pos: 0 }
}
fn eof(&self) -> bool {
self.pos >= self.b.len()
}
fn peek(&self) -> u8 {
self.b[self.pos]
}
fn starts_with(&self, needle: &[u8]) -> bool {
self.b[self.pos..].starts_with(needle)
}
/// Slice the input bytes from `from` to the current position as a &str.
/// Safe: both bounds sit at ASCII delimiter bytes.
fn slice_from(&self, from: usize) -> &'a str {
from_utf8(&self.b[from..self.pos]).unwrap_or("")
}
fn skip_ws(&mut self) {
while !self.eof() && (self.peek()).is_ascii_whitespace() {
self.pos += 1;
}
}
/// At `<?`, advance past the processing instruction to its closing `?>`.
fn skip_pi(&mut self) -> Option<()> {
self.pos += 2; // "<?"
while self.pos + 2 <= self.b.len() && !(self.b[self.pos] == b'?' && self.b[self.pos + 1] == b'>') {
self.pos += 1;
}
if self.pos + 2 > self.b.len() {
return None;
}
self.pos += 2;
Some(())
}
/// At `<!--`, advance past the comment to its closing `-->`.
fn skip_comment(&mut self) -> Option<()> {
self.pos += 4; // "<!--"
while self.pos + 3 <= self.b.len() && !self.starts_with(b"-->") {
self.pos += 1;
}
if self.pos + 3 > self.b.len() {
return None;
}
self.pos += 3;
Some(())
}
/// At `<!` (not a comment), advance past the DOCTYPE to its closing `>`.
/// Limitation: a DOCTYPE with an internal subset (`[ ... ]`) containing a
/// nested `>` is not tracked — the showcase input set doesn't use one.
fn skip_doctype(&mut self) -> Option<()> {
self.pos += 2; // "<!"
while !self.eof() && self.peek() != b'>' {
self.pos += 1;
}
if self.eof() {
return None;
}
self.pos += 1;
Some(())
}
/// Read an XML name: `[A-Za-z_:][A-Za-z0-9_:.\-]*`. The caller guarantees
/// the current byte is a valid name-start.
fn parse_name(&mut self) -> Option<String> {
if self.eof() || !is_name_start(self.peek()) {
return None;
}
let start = self.pos;
self.pos += 1;
while !self.eof() && is_name_char(self.peek()) {
self.pos += 1;
}
Some(self.slice_from(start).to_string())
}
/// Parse `<tag ...>...</tag>`. The cursor is at the leading `<`. Returns
/// the tag name and the composed value.
fn parse_element(&mut self) -> Option<(String, Json)> {
self.pos += 1; // "<"
let tag = self.parse_name()?;
let mut attrs: Vec<(String, String)> = Vec::new();
let mut children: BTreeMap<String, Json> = BTreeMap::new();
let mut has_child = false;
let mut text = String::new();
// Parse attributes (or detect self-closing) until the opening tag ends.
loop {
self.skip_ws();
if self.eof() {
return None;
}
match self.peek() {
b'/' => {
// Self-closing element: <tag ... />
if !self.starts_with(b"/>") {
return None;
}
self.pos += 2;
return Some((tag, compose_value(attrs, children, text, has_child)));
}
b'>' => {
self.pos += 1;
break;
}
_ => {
// attribute name = "value"
let aname = self.parse_name()?;
self.skip_ws();
if self.eof() || self.peek() != b'=' {
return None;
}
self.pos += 1; // "="
self.skip_ws();
if self.eof() || (self.peek() != b'"' && self.peek() != b'\'') {
return None;
}
let quote = self.peek();
self.pos += 1;
let start = self.pos;
while !self.eof() && self.peek() != quote {
self.pos += 1;
}
if self.eof() {
return None;
}
let raw = self.slice_from(start);
self.pos += 1; // closing quote
attrs.push((aname, xml_unescape(raw)));
}
}
}
// Parse content until the matching close tag.
loop {
if self.eof() {
return None; // unterminated element
}
if self.starts_with(b"</") {
self.pos += 2; // "</"
let cname = self.parse_name()?;
self.skip_ws();
if self.eof() || self.peek() != b'>' {
return None;
}
self.pos += 1; // ">"
if cname != tag {
return None; // mismatched close tag
}
break;
}
if self.starts_with(b"<!--") {
self.skip_comment()?;
continue;
}
if self.starts_with(b"<![CDATA[") {
self.pos += b"<![CDATA[".len();
let start = self.pos;
while !self.eof() && !self.starts_with(b"]]>") {
self.pos += 1;
}
if self.eof() {
return None;
}
text.push_str(self.slice_from(start));
self.pos += 3; // "]]>"
continue;
}
if self.starts_with(b"<?") {
self.skip_pi()?;
continue;
}
if self.peek() == b'<' {
// Child element.
has_child = true;
let (ctag, cval) = self.parse_element()?;
if let Some(existing) = children.remove(&ctag) {
let merged = match existing {
Json::Arr(arr) => {
let mut a = arr;
a.push(cval);
Json::Arr(a)
}
other => Json::Arr(vec![other, cval]),
};
children.insert(ctag, merged);
} else {
children.insert(ctag, cval);
}
continue;
}
// A run of character data until the next markup byte.
let start = self.pos;
while !self.eof() && self.peek() != b'<' {
self.pos += 1;
}
text.push_str(&xml_unescape(self.slice_from(start)));
}
Some((tag, compose_value(attrs, children, text, has_child)))
}
}
/// Assemble an element's value from its parsed parts, mirroring the Go twin's
/// `composeValue`.
fn compose_value(
attrs: Vec<(String, String)>,
children: BTreeMap<String, Json>,
text: String,
has_child: bool,
) -> Json {
let trimmed = text.trim().to_string();
if !has_child && attrs.is_empty() {
// Pure text element (or empty element) — value is the bare string.
return Json::Str(trimmed);
}
let mut m = attrs
.into_iter()
.map(|(k, v)| (format!("@_{}", k), Json::Str(v)))
.collect::<BTreeMap<String, Json>>();
for (k, v) in children {
m.insert(k, v);
}
if !trimmed.is_empty() {
m.insert("#text".to_string(), Json::Str(trimmed));
}
Json::Obj(m)
}
fn is_name_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_' || b == b':'
}
fn is_name_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b':' | b'.' | b'-')
}
/// Decode the five predefined XML named entities plus numeric character
/// references (`&#NN;` / `&#xHH;`). Unknown/invalid entities are left intact.
fn xml_unescape(s: &str) -> String {
let b = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'&' {
if let Some(rel) = s[i + 1..].find(';') {
let ent = &s[i + 1..i + 1 + rel];
if let Some(rep) = decode_entity(ent) {
out.push_str(&rep);
i += 1 + rel + 1; // & + entity + ;
continue;
}
}
}
// Push one char (UTF-8 safe advance).
let ch = s[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}
fn decode_entity(ent: &str) -> Option<String> {
match ent {
"lt" => Some("<".into()),
"gt" => Some(">".into()),
"amp" => Some("&".into()),
"quot" => Some("\"".into()),
"apos" => Some("'".into()),
_ => {
let rest = ent.strip_prefix('#')?;
let code = if let Some(h) = rest.strip_prefix('x').or_else(|| rest.strip_prefix('X')) {
u32::from_str_radix(h, 16).ok()?
} else {
rest.parse::<u32>().ok()?
};
char::from_u32(code).map(|c| c.to_string())
}
}
}
// ----------------------------------------------------------------------------
// XML serialization (json -> xml)
// ----------------------------------------------------------------------------
/// Render `<tag ...>...</tag>` for a value at an indent depth, mirroring the
/// Go twin's `writeElement`.
fn write_element(out: &mut String, tag: &str, val: &Json, depth: usize) {
let indent = " ".repeat(depth);
let inner = " ".repeat(depth + 1);
let mut attr_parts: Vec<String> = Vec::new();
let mut children: Vec<(String, Json)> = Vec::new();
let mut text_part = String::new();
let mut has_text = false;
match val {
Json::Obj(m) => {
for (k, v) in m.iter() {
if let Some(name) = k.strip_prefix("@_") {
attr_parts.push(format!("{}=\"{}\"", name, escape_xml(&stringify(v))));
} else if k == "#text" {
text_part = stringify(v);
has_text = true;
} else {
children.push((k.clone(), v.clone()));
}
}
}
_ => {
text_part = stringify(val);
has_text = true;
}
}
out.push_str(&indent);
out.push('<');
out.push_str(tag);
for a in &attr_parts {
out.push(' ');
out.push_str(a);
}
let no_children = children.is_empty();
if no_children && !has_text {
out.push_str("></");
out.push_str(tag);
out.push_str(">\n");
return;
}
if no_children {
out.push('>');
out.push_str(&escape_xml(&text_part));
out.push_str("</");
out.push_str(tag);
out.push_str(">\n");
return;
}
out.push_str(">\n");
if has_text && !text_part.trim().is_empty() {
out.push_str(&inner);
out.push_str(&escape_xml(&text_part));
out.push('\n');
}
for (ctag, cval) in &children {
match cval {
Json::Arr(arr) => {
for item in arr {
write_element(out, ctag, item, depth + 1);
}
}
other => write_element(out, ctag, other, depth + 1),
}
}
out.push_str(&indent);
out.push_str("</");
out.push_str(tag);
out.push_str(">\n");
}
/// Escape the XML special characters (& < > " ') for text/attribute content,
/// matching the Go twin's `xml.EscapeText`.
fn escape_xml(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
/// Render a JSON value as a string for XML text/attribute content (mirrors the
/// Go twin's `stringify`).
fn stringify(v: &Json) -> String {
match v {
Json::Str(s) => s.clone(),
Json::Num(n) => format_num(*n),
Json::Bool(b) => if *b { "true".into() } else { "false".into() },
Json::Null => String::new(),
other => compact_json(other),
}
}
// ----------------------------------------------------------------------------
// JSON serialization (the pretty 2-space emitter for xml_to_json output)
// ----------------------------------------------------------------------------
fn pretty_json(v: &Json) -> String {
let mut out = String::new();
write_json(v, 0, &mut out);
out
}
fn push_indent(out: &mut String, depth: usize) {
for _ in 0..depth {
out.push_str(" ");
}
}
fn write_json(v: &Json, depth: usize, out: &mut String) {
match v {
Json::Str(s) => out.push_str(&json_string(s)),
Json::Num(n) => out.push_str(&format_num(*n)),
Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Json::Null => out.push_str("null"),
Json::Arr(a) => {
if a.is_empty() {
out.push_str("[]");
return;
}
out.push('[');
for (i, item) in a.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('\n');
push_indent(out, depth + 1);
write_json(item, depth + 1, out);
}
out.push('\n');
push_indent(out, depth);
out.push(']');
}
Json::Obj(m) => {
if m.is_empty() {
out.push_str("{}");
return;
}
out.push('{');
for (i, (k, val)) in m.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('\n');
push_indent(out, depth + 1);
out.push_str(&json_string(k));
out.push_str(": ");
write_json(val, depth + 1, out);
}
out.push('\n');
push_indent(out, depth);
out.push('}');
}
}
}
/// Compact JSON emitter for the `stringify` fallback (object/array values).
fn compact_json(v: &Json) -> String {
let mut out = String::new();
write_compact(v, &mut out);
out
}
fn write_compact(v: &Json, out: &mut String) {
match v {
Json::Str(s) => out.push_str(&json_string(s)),
Json::Num(n) => out.push_str(&format_num(*n)),
Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Json::Null => out.push_str("null"),
Json::Arr(a) => {
out.push('[');
for (i, item) in a.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_compact(item, out);
}
out.push(']');
}
Json::Obj(m) => {
out.push('{');
for (i, (k, val)) in m.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&json_string(k));
out.push(':');
write_compact(val, out);
}
out.push('}');
}
}
}
/// Render a `&str` as a complete quoted JSON string literal — the surrounding
/// `"` plus escaped inner characters. Both the pretty and compact emitters
/// route string values and object keys through here.
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{08}' => out.push_str("\\b"),
'\u{0c}' => out.push_str("\\f"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn format_num(n: f64) -> String {
if n == n.trunc() && n.abs() < 1e15 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
// ----------------------------------------------------------------------------
// JSON parser (for json_to_xml input)
// ----------------------------------------------------------------------------
struct JsonParser<'a> {
b: &'a [u8],
pos: usize,
}
fn parse_json(s: &str) -> Option<Json> {
let mut p = JsonParser { b: s.as_bytes(), pos: 0 };
p.skip_ws();
let v = p.parse_value()?;
p.skip_ws();
if p.pos != p.b.len() {
return None; // trailing junk
}
Some(v)
}
impl<'a> JsonParser<'a> {
fn eof(&self) -> bool {
self.pos >= self.b.len()
}
fn peek(&self) -> u8 {
self.b[self.pos]
}
fn skip_ws(&mut self) {
while !self.eof() && (self.peek()).is_ascii_whitespace() {
self.pos += 1;
}
}
fn parse_value(&mut self) -> Option<Json> {
self.skip_ws();
if self.eof() {
return None;
}
match self.peek() {
b'{' => self.parse_object(),
b'[' => self.parse_array(),
b'"' => self.parse_string().map(Json::Str),
b't' | b'f' => self.parse_bool(),
b'n' => self.parse_null(),
b'-' | b'0'..=b'9' => self.parse_number(),
_ => None,
}
}
fn parse_object(&mut self) -> Option<Json> {
self.pos += 1; // "{"
let mut m = BTreeMap::new();
self.skip_ws();
if !self.eof() && self.peek() == b'}' {
self.pos += 1;
return Some(Json::Obj(m));
}
loop {
self.skip_ws();
let key = self.parse_string()?;
self.skip_ws();
if self.eof() || self.peek() != b':' {
return None;
}
self.pos += 1; // ":"
let val = self.parse_value()?;
m.insert(key, val);
self.skip_ws();
if self.eof() {
return None;
}
match self.peek() {
b',' => {
self.pos += 1;
}
b'}' => {
self.pos += 1;
return Some(Json::Obj(m));
}
_ => return None,
}
}
}
fn parse_array(&mut self) -> Option<Json> {
self.pos += 1; // "["
let mut a = Vec::new();
self.skip_ws();
if !self.eof() && self.peek() == b']' {
self.pos += 1;
return Some(Json::Arr(a));
}
loop {
let val = self.parse_value()?;
a.push(val);
self.skip_ws();
if self.eof() {
return None;
}
match self.peek() {
b',' => {
self.pos += 1;
}
b']' => {
self.pos += 1;
return Some(Json::Arr(a));
}
_ => return None,
}
}
}
fn parse_string(&mut self) -> Option<String> {
if self.eof() || self.peek() != b'"' {
return None;
}
self.pos += 1;
let mut out = String::new();
while !self.eof() {
let c = self.peek();
self.pos += 1;
match c {
b'"' => return Some(out),
b'\\' => {
if self.eof() {
return None;
}
let e = self.peek();
self.pos += 1;
match e {
b'"' => out.push('"'),
b'\\' => out.push('\\'),
b'/' => out.push('/'),
b'n' => out.push('\n'),
b't' => out.push('\t'),
b'r' => out.push('\r'),
b'b' => out.push('\u{08}'),
b'f' => out.push('\u{0c}'),
b'u' => {
let hex = self.slice_n(4)?;
let code = u32::from_str_radix(hex, 16).ok()?;
if let Some(ch) = char::from_u32(code) {
out.push(ch);
}
}
_ => return None,
}
}
_ => {
// Push the full UTF-8 char starting at this byte.
let rest = from_utf8(&self.b[self.pos - 1..]).ok()?;
let ch = rest.chars().next()?;
out.push(ch);
self.pos = self.pos - 1 + ch.len_utf8();
}
}
}
None // unterminated string
}
/// Read exactly `n` hex/ascii bytes as a &str slice starting at the cursor.
fn slice_n(&mut self, n: usize) -> Option<&'a str> {
if self.pos + n > self.b.len() {
return None;
}
let s = from_utf8(&self.b[self.pos..self.pos + n]).ok()?;
self.pos += n;
Some(s)
}
fn parse_bool(&mut self) -> Option<Json> {
if self.b[self.pos..].starts_with(b"true") {
self.pos += 4;
Some(Json::Bool(true))
} else if self.b[self.pos..].starts_with(b"false") {
self.pos += 5;
Some(Json::Bool(false))
} else {
None
}
}
fn parse_null(&mut self) -> Option<Json> {
if self.b[self.pos..].starts_with(b"null") {
self.pos += 4;
Some(Json::Null)
} else {
None
}
}
fn parse_number(&mut self) -> Option<Json> {
let start = self.pos;
if self.peek() == b'-' {
self.pos += 1;
}
while !self.eof() && (self.peek()).is_ascii_digit() {
self.pos += 1;
}
if !self.eof() && self.peek() == b'.' {
self.pos += 1;
while !self.eof() && (self.peek()).is_ascii_digit() {
self.pos += 1;
}
}
if !self.eof() && (self.peek() == b'e' || self.peek() == b'E') {
self.pos += 1;
if !self.eof() && (self.peek() == b'+' || self.peek() == b'-') {
self.pos += 1;
}
while !self.eof() && (self.peek()).is_ascii_digit() {
self.pos += 1;
}
}
let text = from_utf8(&self.b[start..self.pos]).ok()?;
text.parse::<f64>().ok().map(Json::Num)
}
}
// ---------- tests (showcase-only; the canonical suite lives in src/lib) ----------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_xml_to_json() {
// Exact-match: object keys sorted, 2-space indent — same bytes as the
// Go twin's json.MarshalIndent.
assert_eq!(
xml_to_json("<root><name>Alice</name></root>"),
Some("{\n \"root\": {\n \"name\": \"Alice\"\n }\n}".to_string())
);
}
#[test]
fn preserves_attributes_as_prefixed_strings() {
assert_eq!(
xml_to_json(r#"<user id="7"><name>Alice</name></user>"#),
Some(
"{\n \"user\": {\n \"@_id\": \"7\",\n \"name\": \"Alice\"\n }\n}".to_string()
)
);
}
#[test]
fn repeated_tags_become_an_array() {
assert_eq!(
xml_to_json("<list><item>a</item><item>b</item></list>"),
Some(
"{\n \"list\": {\n \"item\": [\n \"a\",\n \"b\"\n ]\n }\n}"
.to_string()
)
);
}
#[test]
fn malformed_xml_returns_none() {
assert_eq!(xml_to_json("<a><b></a>"), None); // mismatched close
assert_eq!(xml_to_json("not xml at all"), None); // no root element
assert_eq!(xml_to_json(""), None); // empty
}
#[test]
fn builds_xml_from_json() {
let xml = json_to_xml(r#"{"root":{"name":"Alice"}}"#).unwrap();
assert!(xml.contains("<root>"));
assert!(xml.contains("<name>Alice</name>"));
assert!(xml.contains("</root>"));
}
#[test]
fn builds_attributes_and_rejects_invalid_roots() {
let xml = json_to_xml(r#"{"user":{"@_id":"7","name":"Alice"}}"#).unwrap();
assert!(xml.contains("id=\"7\""));
assert_eq!(json_to_xml("{not valid json"), None);
assert_eq!(json_to_xml("42"), None); // non-object root
assert_eq!(json_to_xml("null"), None);
assert_eq!(json_to_xml("[1,2,3]"), None); // array root
}
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →