use crate::FiscalError;
const ROOT_TAG_LIST: &[&str] = &[
"distDFeInt",
"resNFe",
"resEvento",
"envEvento",
"ConsCad",
"consSitNFe",
"consReciNFe",
"downloadNFe",
"enviNFe",
"inutNFe",
"admCscNFCe",
"consStatServ",
"retDistDFeInt",
"retEnvEvento",
"retConsCad",
"retConsSitNFe",
"retConsReciNFe",
"retDownloadNFe",
"retEnviNFe",
"retInutNFe",
"retAdmCscNFCe",
"retConsStatServ",
"procInutNFe",
"procEventoNFe",
"procNFe",
"nfeProc",
"NFe",
];
pub fn identify_xml_type(xml: &str) -> Result<String, FiscalError> {
let trimmed = xml.trim();
if trimmed.is_empty() {
return Err(FiscalError::XmlParsing("XML is empty.".into()));
}
if !trimmed.starts_with('<') {
return Err(FiscalError::XmlParsing(
"Invalid document: not valid XML.".into(),
));
}
let reader = quick_xml::Reader::from_str(trimmed);
let _ = reader;
if let Some(tag) = find_root_tag(trimmed) {
return Ok(tag.to_string());
}
Err(FiscalError::XmlParsing(
"Document does not belong to the NFe project.".into(),
))
}
fn find_root_tag(xml: &str) -> Option<&'static str> {
for &tag in ROOT_TAG_LIST {
let pattern_space = format!("<{tag} ");
let pattern_close = format!("<{tag}>");
let pattern_newline = format!("<{tag}\n");
if xml.contains(&pattern_space)
|| xml.contains(&pattern_close)
|| xml.contains(&pattern_newline)
{
return Some(tag);
}
}
None
}
pub fn xml_to_json(xml: &str) -> Result<String, FiscalError> {
let value = xml_to_value(xml)?;
serde_json::to_string(&value)
.map_err(|e| FiscalError::XmlParsing(format!("JSON serialization failed: {e}")))
}
pub fn xml_to_value(xml: &str) -> Result<serde_json::Value, FiscalError> {
let root_tag = identify_xml_type(xml)?;
let full = xml_str_to_json_value(xml.trim())?;
match full {
serde_json::Value::Object(map) => {
if let Some(inner) = map.get(&root_tag) {
Ok(inner.clone())
} else {
Ok(serde_json::Value::Object(map))
}
}
other => Ok(other),
}
}
pub fn xml_to_map(xml: &str) -> Result<serde_json::Map<String, serde_json::Value>, FiscalError> {
let value = xml_to_value(xml)?;
match value {
serde_json::Value::Object(map) => Ok(map),
_ => Err(FiscalError::XmlParsing(
"Top-level XML value is not an object.".into(),
)),
}
}
fn xml_str_to_json_value(xml: &str) -> Result<serde_json::Value, FiscalError> {
use quick_xml::Reader;
use quick_xml::events::Event;
use serde_json::{Map, Value};
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut stack: Vec<(String, Map<String, Value>)> = Vec::new();
let mut root_map = Map::new();
loop {
match reader.read_event() {
Ok(Event::Start(ref e)) => {
let raw_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
let local_name = strip_ns_prefix(&raw_name);
let mut attrs_map = Map::new();
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
if is_xmlns_attr(&key) {
continue;
}
let val = String::from_utf8_lossy(&attr.value).to_string();
attrs_map.insert(key, Value::String(val));
}
stack.push((local_name, attrs_map));
}
Ok(Event::Empty(ref e)) => {
let raw_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
let local_name = strip_ns_prefix(&raw_name);
let mut attrs_map = Map::new();
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
if is_xmlns_attr(&key) {
continue;
}
let val = String::from_utf8_lossy(&attr.value).to_string();
attrs_map.insert(key, Value::String(val));
}
let child_val = if attrs_map.is_empty() {
Value::String(String::new())
} else {
Value::Object(attrs_map)
};
if let Some((_name, map)) = stack.last_mut() {
insert_into_map(map, &local_name, child_val);
} else {
root_map.insert(local_name, child_val);
}
}
Ok(Event::Text(ref e)) => {
let text = e.decode().unwrap_or_default().to_string();
if !text.is_empty() {
if let Some((_name, map)) = stack.last_mut() {
map.insert("#text".to_string(), Value::String(text));
}
}
}
Ok(Event::End(_)) => {
if let Some((name, map)) = stack.pop() {
let child_val = if map.len() == 1 {
if let Some(text) = map.get("#text") {
text.clone()
} else {
Value::Object(map)
}
} else if map.is_empty() {
Value::String(String::new())
} else {
Value::Object(map)
};
if let Some((_parent_name, parent_map)) = stack.last_mut() {
insert_into_map(parent_map, &name, child_val);
} else {
root_map.insert(name, child_val);
}
}
}
Ok(Event::Decl(_)) | Ok(Event::Comment(_)) | Ok(Event::PI(_)) => {}
Ok(Event::CData(ref e)) => {
let text = String::from_utf8_lossy(e.as_ref()).to_string();
if let Some((_name, map)) = stack.last_mut() {
map.insert("#text".to_string(), Value::String(text));
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(FiscalError::XmlParsing(format!("XML parse error: {e}"))),
_ => {}
}
}
Ok(Value::Object(root_map))
}
fn strip_ns_prefix(name: &str) -> String {
match name.find(':') {
Some(idx) => name[idx + 1..].to_string(),
None => name.to_string(),
}
}
fn is_xmlns_attr(key: &str) -> bool {
key == "xmlns" || key.starts_with("xmlns:")
}
fn insert_into_map(
map: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: serde_json::Value,
) {
use serde_json::Value;
if let Some(existing) = map.get_mut(key) {
match existing {
Value::Array(arr) => {
arr.push(value);
}
_ => {
let prev = existing.take();
*existing = Value::Array(vec![prev, value]);
}
}
} else {
map.insert(key.to_string(), value);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identify_nfe() {
let xml = r#"<?xml version="1.0"?><NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe/></NFe>"#;
assert_eq!(identify_xml_type(xml).unwrap(), "NFe");
}
#[test]
fn identify_nfe_proc() {
let xml = r#"<nfeProc versao="4.00"><NFe/><protNFe/></nfeProc>"#;
assert_eq!(identify_xml_type(xml).unwrap(), "nfeProc");
}
#[test]
fn empty_returns_err() {
assert!(identify_xml_type("").is_err());
}
#[test]
fn non_xml_returns_err() {
assert!(identify_xml_type("hello world").is_err());
}
#[test]
fn unknown_root_returns_err() {
let xml = "<other><data/></other>";
assert!(identify_xml_type(xml).is_err());
}
#[test]
fn identify_env_evento() {
let xml = r#"<envEvento versao="1.00"><evento/></envEvento>"#;
assert_eq!(identify_xml_type(xml).unwrap(), "envEvento");
}
#[test]
fn identify_ret_cons_sit_nfe() {
let xml = "<retConsSitNFe><cStat>100</cStat></retConsSitNFe>";
assert_eq!(identify_xml_type(xml).unwrap(), "retConsSitNFe");
}
#[test]
fn identify_cons_cad() {
let xml = r#"<ConsCad versao="2.00"><infCons/></ConsCad>"#;
assert_eq!(identify_xml_type(xml).unwrap(), "ConsCad");
}
#[test]
fn identify_inut_nfe() {
let xml = r#"<inutNFe versao="4.00"><infInut/></inutNFe>"#;
assert_eq!(identify_xml_type(xml).unwrap(), "inutNFe");
}
#[test]
fn identify_ret_env_evento() {
let xml = "<retEnvEvento><cStat>128</cStat></retEnvEvento>";
assert_eq!(identify_xml_type(xml).unwrap(), "retEnvEvento");
}
#[test]
fn identify_ret_inut_nfe() {
let xml = "<retInutNFe><infInut/></retInutNFe>";
assert_eq!(identify_xml_type(xml).unwrap(), "retInutNFe");
}
#[test]
fn identify_adm_csc_nfce() {
let xml = r#"<admCscNFCe versao="1.00"><data/></admCscNFCe>"#;
assert_eq!(identify_xml_type(xml).unwrap(), "admCscNFCe");
}
#[test]
fn identify_dist_dfe_int() {
let xml = r#"<distDFeInt versao="1.01"><data/></distDFeInt>"#;
assert_eq!(identify_xml_type(xml).unwrap(), "distDFeInt");
}
#[test]
fn identify_proc_evento_nfe() {
let xml = r#"<procEventoNFe versao="1.00"><evento/></procEventoNFe>"#;
assert_eq!(identify_xml_type(xml).unwrap(), "procEventoNFe");
}
#[test]
fn xml_to_json_empty_elements() {
let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe Id="NFe123"><empty/></infNFe></NFe>"#;
let json = xml_to_json(xml).unwrap();
assert!(json.contains("infNFe"));
assert!(!json.contains("xmlns"));
}
#[test]
fn xml_to_json_non_nfe_document_fails() {
let xml = "<garbage><data>hello</data></garbage>";
assert!(xml_to_json(xml).is_err());
}
#[test]
fn xml_to_json_empty_element_with_attrs() {
let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><tag attr="val"/></NFe>"#;
let json = xml_to_json(xml).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v.get("tag").is_some());
assert!(v.get("NFe").is_none(), "root tag NFe must be unwrapped");
}
#[test]
fn xml_to_json_repeated_elements_become_array() {
let xml =
r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><det>a</det><det>b</det></NFe>"#;
let json = xml_to_json(xml).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let det = v
.get("det")
.expect("det should be at top level after unwrap");
assert!(det.is_array());
assert_eq!(det.as_array().expect("is array").len(), 2);
}
#[test]
fn xml_to_json_element_with_only_attrs_no_text() {
let xml =
r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><item a="1" b="2"></item></NFe>"#;
let json = xml_to_json(xml).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v.get("item").is_some());
}
#[test]
fn strip_ns_prefix_with_colon() {
assert_eq!(strip_ns_prefix("nfe:NFe"), "NFe");
}
#[test]
fn strip_ns_prefix_without_colon() {
assert_eq!(strip_ns_prefix("NFe"), "NFe");
}
#[test]
fn is_xmlns_attr_cases() {
assert!(is_xmlns_attr("xmlns"));
assert!(is_xmlns_attr("xmlns:nfe"));
assert!(!is_xmlns_attr("versao"));
assert!(!is_xmlns_attr("Id"));
}
#[test]
fn xml_to_json_basic() {
let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe Id="NFe123"><ide><cUF>35</cUF></ide></infNFe></NFe>"#;
let json = xml_to_json(xml).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v.get("infNFe").is_some(), "infNFe should be at top level");
assert!(v.get("NFe").is_none(), "NFe root should be unwrapped");
}
#[test]
fn xml_to_json_attributes_inline() {
let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00" Id="NFe123"><ide><cUF>35</cUF></ide></infNFe></NFe>"#;
let json = xml_to_json(xml).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let inf_nfe = v.get("infNFe").expect("infNFe at top level");
assert_eq!(inf_nfe.get("versao").and_then(|v| v.as_str()), Some("4.00"));
assert_eq!(inf_nfe.get("Id").and_then(|v| v.as_str()), Some("NFe123"));
assert!(
inf_nfe.get("attributes").is_none(),
"should NOT have @attributes/attributes key"
);
}
#[test]
fn xml_to_json_cdata_in_qrcode() {
let xml = concat!(
r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe">"#,
r#"<infNFe versao="4.00" Id="NFe123"><ide><cUF>35</cUF></ide></infNFe>"#,
r#"<infNFeSupl>"#,
r#"<qrCode><![CDATA[http://example.com/nfce?p=123&x=456]]></qrCode>"#,
r#"<urlChave>http://example.com/nfce/consulta</urlChave>"#,
r#"</infNFeSupl>"#,
r#"</NFe>"#,
);
let json = xml_to_json(xml).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let supl = v.get("infNFeSupl").expect("infNFeSupl at top level");
assert_eq!(
supl.get("qrCode").and_then(|v| v.as_str()),
Some("http://example.com/nfce?p=123&x=456"),
"CDATA content should be preserved without mangling"
);
assert_eq!(
supl.get("urlChave").and_then(|v| v.as_str()),
Some("http://example.com/nfce/consulta"),
);
}
#[test]
fn xml_to_json_xmlns_stripped() {
let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe" xmlns:nfe="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00"><ide><cUF>35</cUF></ide></infNFe></NFe>"#;
let json = xml_to_json(xml).unwrap();
assert!(
!json.contains("xmlns"),
"xmlns must not appear in JSON output"
);
assert!(json.contains("versao"), "non-xmlns attributes must be kept");
}
#[test]
fn xml_to_value_navigable_fields() {
let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00" Id="NFe35..."><ide><cUF>35</cUF><nNF>12345</nNF></ide></infNFe></NFe>"#;
let value = xml_to_value(xml).unwrap();
assert_eq!(value["infNFe"]["ide"]["cUF"].as_str(), Some("35"));
assert_eq!(value["infNFe"]["ide"]["nNF"].as_str(), Some("12345"));
assert_eq!(value["infNFe"]["versao"].as_str(), Some("4.00"));
assert_eq!(value["infNFe"]["Id"].as_str(), Some("NFe35..."));
assert!(value.get("xmlns").is_none(), "xmlns must not appear");
}
#[test]
fn xml_to_value_ret_cons_sit_nfe() {
let xml = concat!(
r#"<retConsSitNFe versao="4.00">"#,
"<cStat>100</cStat>",
"<xMotivo>Autorizado o uso da NF-e</xMotivo>",
"<chNFe>35200612345678901234550010000000011000000019</chNFe>",
"</retConsSitNFe>"
);
let value = xml_to_value(xml).unwrap();
assert_eq!(value["cStat"].as_str(), Some("100"));
assert_eq!(value["xMotivo"].as_str(), Some("Autorizado o uso da NF-e"));
assert_eq!(
value["chNFe"].as_str(),
Some("35200612345678901234550010000000011000000019")
);
assert_eq!(value["versao"].as_str(), Some("4.00"));
}
#[test]
fn xml_to_value_empty_xml_fails() {
assert!(xml_to_value("").is_err());
}
#[test]
fn xml_to_value_non_nfe_fails() {
assert!(xml_to_value("<other/>").is_err());
}
#[test]
fn xml_to_value_cdata_preserved() {
let xml = concat!(
r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe">"#,
r#"<infNFeSupl>"#,
r#"<qrCode><![CDATA[http://example.com?a=1&b=2]]></qrCode>"#,
r#"</infNFeSupl>"#,
r#"</NFe>"#,
);
let value = xml_to_value(xml).unwrap();
assert_eq!(
value["infNFeSupl"]["qrCode"].as_str(),
Some("http://example.com?a=1&b=2")
);
}
#[test]
fn xml_to_map_returns_top_level_keys() {
let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe><ide><cUF>35</cUF></ide></infNFe></NFe>"#;
let map = xml_to_map(xml).unwrap();
assert!(
map.contains_key("infNFe"),
"top-level map must contain 'infNFe' key (root NFe unwrapped)"
);
assert_eq!(map["infNFe"]["ide"]["cUF"].as_str(), Some("35"));
}
#[test]
fn xml_to_map_nfe_proc() {
let xml = concat!(
r#"<nfeProc versao="4.00">"#,
r#"<NFe><infNFe><ide><cUF>31</cUF></ide></infNFe></NFe>"#,
r#"<protNFe><infProt><cStat>100</cStat></infProt></protNFe>"#,
r#"</nfeProc>"#
);
let map = xml_to_map(xml).unwrap();
assert_eq!(map["versao"].as_str(), Some("4.00"));
assert_eq!(map["NFe"]["infNFe"]["ide"]["cUF"].as_str(), Some("31"));
assert_eq!(map["protNFe"]["infProt"]["cStat"].as_str(), Some("100"));
}
#[test]
fn xml_to_map_empty_xml_fails() {
assert!(xml_to_map("").is_err());
}
#[test]
fn xml_to_map_non_nfe_fails() {
assert!(xml_to_map("<garbage/>").is_err());
}
#[test]
fn xml_to_map_repeated_elements() {
let xml = concat!(
r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe">"#,
r#"<det nItem="1"><prod><cProd>001</cProd></prod></det>"#,
r#"<det nItem="2"><prod><cProd>002</cProd></prod></det>"#,
r#"</NFe>"#
);
let map = xml_to_map(xml).unwrap();
let det = &map["det"];
assert!(det.is_array(), "repeated elements must become an array");
let arr = det.as_array().expect("is array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["prod"]["cProd"].as_str(), Some("001"));
assert_eq!(arr[1]["prod"]["cProd"].as_str(), Some("002"));
}
#[test]
fn xml_to_value_and_json_produce_equivalent_output() {
let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00"><ide><cUF>35</cUF></ide></infNFe></NFe>"#;
let value = xml_to_value(xml).unwrap();
let json_str = xml_to_json(xml).unwrap();
let from_json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(value, from_json);
}
#[test]
fn xml_to_value_matches_php_to_std_nfe() {
let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00" Id="NFe123"><ide><cUF>35</cUF></ide></infNFe></NFe>"#;
let value = xml_to_value(xml).unwrap();
assert!(value.get("NFe").is_none(), "NFe wrapper must be removed");
assert!(value.get("xmlns").is_none(), "xmlns must be stripped");
assert!(value.get("infNFe").is_some(), "infNFe must be at top level");
assert_eq!(value["infNFe"]["versao"].as_str(), Some("4.00"));
assert_eq!(value["infNFe"]["Id"].as_str(), Some("NFe123"));
assert_eq!(value["infNFe"]["ide"]["cUF"].as_str(), Some("35"));
}
#[test]
fn xml_to_value_matches_php_to_std_ret_envi_nfe() {
let xml = concat!(
r#"<retEnviNFe versao="4.00">"#,
"<tpAmb>2</tpAmb>",
"<cStat>103</cStat>",
"<xMotivo>Lote recebido com sucesso</xMotivo>",
"</retEnviNFe>"
);
let value = xml_to_value(xml).unwrap();
assert!(value.get("retEnviNFe").is_none(), "root must be unwrapped");
assert_eq!(value["versao"].as_str(), Some("4.00"));
assert_eq!(value["tpAmb"].as_str(), Some("2"));
assert_eq!(value["cStat"].as_str(), Some("103"));
assert_eq!(value["xMotivo"].as_str(), Some("Lote recebido com sucesso"));
}
#[test]
fn xml_to_value_matches_php_to_std_nfe_proc() {
let xml = concat!(
r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
r#"<NFe><infNFe><ide><cUF>35</cUF></ide></infNFe></NFe>"#,
r#"<protNFe><infProt><cStat>100</cStat></infProt></protNFe>"#,
r#"</nfeProc>"#
);
let value = xml_to_value(xml).unwrap();
assert!(
value.get("nfeProc").is_none(),
"root nfeProc must be unwrapped"
);
assert!(value.get("xmlns").is_none(), "xmlns must not appear");
assert_eq!(value["versao"].as_str(), Some("4.00"));
assert!(value.get("NFe").is_some());
assert!(value.get("protNFe").is_some());
assert_eq!(value["protNFe"]["infProt"]["cStat"].as_str(), Some("100"));
}
}