Skip to main content

fiscal_core/
standardize.rs

1use crate::FiscalError;
2
3/// Known root tag names for NFe-related XML documents.
4///
5/// Checked in order to identify the document type from the root element
6/// of a parsed XML string.
7const ROOT_TAG_LIST: &[&str] = &[
8    "distDFeInt",
9    "resNFe",
10    "resEvento",
11    "envEvento",
12    "ConsCad",
13    "consSitNFe",
14    "consReciNFe",
15    "downloadNFe",
16    "enviNFe",
17    "inutNFe",
18    "admCscNFCe",
19    "consStatServ",
20    "retDistDFeInt",
21    "retEnvEvento",
22    "retConsCad",
23    "retConsSitNFe",
24    "retConsReciNFe",
25    "retDownloadNFe",
26    "retEnviNFe",
27    "retInutNFe",
28    "retAdmCscNFCe",
29    "retConsStatServ",
30    "procInutNFe",
31    "procEventoNFe",
32    "procNFe",
33    "nfeProc",
34    "NFe",
35];
36
37/// Identify the type of an NF-e XML document from its content.
38///
39/// Parses the XML and checks the root element against the list of known
40/// NFe document types. Returns the matched root tag name (e.g. `"NFe"`,
41/// `"nfeProc"`, `"retConsSitNFe"`).
42///
43/// # Errors
44///
45/// Returns [`FiscalError::XmlParsing`] if the input is empty, whitespace-only,
46/// not valid XML, or not a recognised NFe document type.
47pub fn identify_xml_type(xml: &str) -> Result<String, FiscalError> {
48    let trimmed = xml.trim();
49    if trimmed.is_empty() {
50        return Err(FiscalError::XmlParsing("XML is empty.".into()));
51    }
52    if !trimmed.starts_with('<') {
53        return Err(FiscalError::XmlParsing(
54            "Invalid document: not valid XML.".into(),
55        ));
56    }
57
58    // Try to parse with quick-xml to validate it is well-formed XML
59    let reader = quick_xml::Reader::from_str(trimmed);
60    let _ = reader; // quick-xml reader is lazy, so we probe events below
61
62    // Find root tags by scanning the XML content for known element names.
63    // We search for `<TagName` (followed by space or `>`) to identify the
64    // root element even when namespaces or attributes are present.
65    if let Some(tag) = find_root_tag(trimmed) {
66        return Ok(tag.to_string());
67    }
68
69    Err(FiscalError::XmlParsing(
70        "Document does not belong to the NFe project.".into(),
71    ))
72}
73
74/// Search raw XML text for a known root tag.
75fn find_root_tag(xml: &str) -> Option<&'static str> {
76    for &tag in ROOT_TAG_LIST {
77        // Match `<tag `, `<tag>`, or `<tag\n`
78        let pattern_space = format!("<{tag} ");
79        let pattern_close = format!("<{tag}>");
80        let pattern_newline = format!("<{tag}\n");
81        if xml.contains(&pattern_space)
82            || xml.contains(&pattern_close)
83            || xml.contains(&pattern_newline)
84        {
85            return Some(tag);
86        }
87    }
88    None
89}
90
91/// Convert an NFe XML string to a JSON string representation.
92///
93/// First validates that the XML is a recognised NFe document via
94/// [`identify_xml_type`], then converts the XML tree to a JSON object.
95///
96/// ## Root-tag unwrapping (PHP parity)
97///
98/// Like PHP's `Standardize::toStd()`, the identified root element is
99/// **unwrapped**: for `<NFe xmlns="..."><infNFe>...</infNFe></NFe>` the
100/// result is `{"infNFe":{...}}`, not `{"NFe":{"infNFe":{...}}}`.
101/// Attributes on the root element (except `xmlns`) are merged into the
102/// returned object as plain keys.
103///
104/// ## Attribute handling (difference from PHP)
105///
106/// The PHP `Standardize::toStd()` method uses `simplexml_load_string` +
107/// `json_encode`, which places XML attributes under an `@attributes` key
108/// (later renamed to `attributes`). This Rust implementation places
109/// attributes **inline** alongside child elements — e.g. an element
110/// `<infNFe versao="4.00" Id="NFe123">` produces `{"versao":"4.00",
111/// "Id":"NFe123", ...}` rather than `{"attributes":{"versao":"4.00",
112/// "Id":"NFe123"}, ...}`. This is a deliberate design decision: inline
113/// attributes are more ergonomic for JSON consumers and avoid the extra
114/// nesting level. Consumers that relied on the PHP `attributes` key
115/// should adapt their field lookups accordingly.
116///
117/// ## `infNFeSupl` / CDATA handling
118///
119/// The PHP `toStd()` has special post-processing for `infNFeSupl`: it
120/// re-extracts `qrCode` and `urlChave` via DOM because PHP's
121/// `simplexml_load_string` can corrupt CDATA sections. This Rust
122/// implementation uses `quick-xml`'s `Event::CData` handler, which
123/// correctly preserves CDATA content without mangling, so no special
124/// post-processing is needed.
125///
126/// # Errors
127///
128/// Returns [`FiscalError::XmlParsing`] if the input is not valid NFe XML,
129/// or if conversion to JSON fails.
130pub fn xml_to_json(xml: &str) -> Result<String, FiscalError> {
131    let value = xml_to_value(xml)?;
132
133    serde_json::to_string(&value)
134        .map_err(|e| FiscalError::XmlParsing(format!("JSON serialization failed: {e}")))
135}
136
137/// Convert an NFe XML string to a navigable [`serde_json::Value`] tree.
138///
139/// This is the Rust equivalent of PHP's `Standardize::toStd()`, which returns
140/// a `stdClass` object. In Rust, [`serde_json::Value`] serves the same role:
141/// it is a dynamically-typed, navigable tree that can be indexed with
142/// `value["fieldName"]`.
143///
144/// The identified root element is **unwrapped** so the returned value
145/// contains the children of the root tag directly, matching PHP behaviour.
146/// For example, `<NFe xmlns="..."><infNFe>...</infNFe></NFe>` yields
147/// `{"infNFe": {...}}`. Attributes on the root element (except `xmlns`)
148/// are merged as plain keys.
149///
150/// # Example
151///
152/// ```rust,ignore
153/// let value = xml_to_value(xml)?;
154/// let cuf = &value["infNFe"]["ide"]["cUF"];
155/// assert_eq!(cuf.as_str(), Some("35"));
156/// ```
157///
158/// # Errors
159///
160/// Returns [`FiscalError::XmlParsing`] if the input is not valid NFe XML.
161pub fn xml_to_value(xml: &str) -> Result<serde_json::Value, FiscalError> {
162    let root_tag = identify_xml_type(xml)?;
163    let full = xml_str_to_json_value(xml.trim())?;
164
165    // Unwrap the identified root tag to match PHP's Standardize::toStd()
166    // behaviour, which returns the *contents* of the root element.
167    match full {
168        serde_json::Value::Object(map) => {
169            if let Some(inner) = map.get(&root_tag) {
170                Ok(inner.clone())
171            } else {
172                // Root tag not found as key — return as-is (shouldn't happen)
173                Ok(serde_json::Value::Object(map))
174            }
175        }
176        other => Ok(other),
177    }
178}
179
180/// Convert an NFe XML string to a [`serde_json::Map`] (equivalent to an
181/// associative array / hash map).
182///
183/// This is the Rust equivalent of PHP's `Standardize::toArray()`, which returns
184/// an associative array. In Rust, [`serde_json::Map<String, Value>`] is the
185/// natural equivalent: an ordered map of string keys to dynamically-typed values.
186///
187/// Like [`xml_to_value`], the identified root element is unwrapped: the map
188/// contains the *children* of the root tag, not the root tag itself.
189///
190/// # Example
191///
192/// ```rust,ignore
193/// let map = xml_to_map(xml)?;
194/// let inf_nfe = map.get("infNFe").unwrap();
195/// ```
196///
197/// # Errors
198///
199/// Returns [`FiscalError::XmlParsing`] if the input is not valid NFe XML,
200/// or if the top-level JSON value is not an object (should not happen for
201/// well-formed NFe documents).
202pub fn xml_to_map(xml: &str) -> Result<serde_json::Map<String, serde_json::Value>, FiscalError> {
203    let value = xml_to_value(xml)?;
204    match value {
205        serde_json::Value::Object(map) => Ok(map),
206        _ => Err(FiscalError::XmlParsing(
207            "Top-level XML value is not an object.".into(),
208        )),
209    }
210}
211
212/// Recursively convert an XML string into a serde_json::Value.
213///
214/// This is a simplified converter that handles elements, text content,
215/// and attributes. Namespace prefixes are stripped from tag names and
216/// `xmlns` attributes are omitted (they are XML metadata, not data).
217fn xml_str_to_json_value(xml: &str) -> Result<serde_json::Value, FiscalError> {
218    use quick_xml::Reader;
219    use quick_xml::events::Event;
220    use serde_json::{Map, Value};
221
222    let mut reader = Reader::from_str(xml);
223    reader.config_mut().trim_text(true);
224
225    let mut stack: Vec<(String, Map<String, Value>)> = Vec::new();
226    let mut root_map = Map::new();
227
228    loop {
229        match reader.read_event() {
230            Ok(Event::Start(ref e)) => {
231                let raw_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
232                let local_name = strip_ns_prefix(&raw_name);
233
234                let mut attrs_map = Map::new();
235                for attr in e.attributes().flatten() {
236                    let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
237                    if is_xmlns_attr(&key) {
238                        continue;
239                    }
240                    let val = String::from_utf8_lossy(&attr.value).to_string();
241                    attrs_map.insert(key, Value::String(val));
242                }
243
244                stack.push((local_name, attrs_map));
245            }
246            Ok(Event::Empty(ref e)) => {
247                let raw_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
248                let local_name = strip_ns_prefix(&raw_name);
249
250                let mut attrs_map = Map::new();
251                for attr in e.attributes().flatten() {
252                    let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
253                    if is_xmlns_attr(&key) {
254                        continue;
255                    }
256                    let val = String::from_utf8_lossy(&attr.value).to_string();
257                    attrs_map.insert(key, Value::String(val));
258                }
259
260                let child_val = if attrs_map.is_empty() {
261                    Value::String(String::new())
262                } else {
263                    Value::Object(attrs_map)
264                };
265
266                if let Some((_name, map)) = stack.last_mut() {
267                    insert_into_map(map, &local_name, child_val);
268                } else {
269                    root_map.insert(local_name, child_val);
270                }
271            }
272            Ok(Event::Text(ref e)) => {
273                let text = e.decode().unwrap_or_default().to_string();
274                if !text.is_empty() {
275                    if let Some((_name, map)) = stack.last_mut() {
276                        map.insert("#text".to_string(), Value::String(text));
277                    }
278                }
279            }
280            Ok(Event::End(_)) => {
281                if let Some((name, map)) = stack.pop() {
282                    let child_val = if map.len() == 1 {
283                        if let Some(text) = map.get("#text") {
284                            text.clone()
285                        } else {
286                            Value::Object(map)
287                        }
288                    } else if map.is_empty() {
289                        Value::String(String::new())
290                    } else {
291                        Value::Object(map)
292                    };
293
294                    if let Some((_parent_name, parent_map)) = stack.last_mut() {
295                        insert_into_map(parent_map, &name, child_val);
296                    } else {
297                        root_map.insert(name, child_val);
298                    }
299                }
300            }
301            Ok(Event::Decl(_)) | Ok(Event::Comment(_)) | Ok(Event::PI(_)) => {}
302            Ok(Event::CData(ref e)) => {
303                let text = String::from_utf8_lossy(e.as_ref()).to_string();
304                if let Some((_name, map)) = stack.last_mut() {
305                    map.insert("#text".to_string(), Value::String(text));
306                }
307            }
308            Ok(Event::Eof) => break,
309            Err(e) => return Err(FiscalError::XmlParsing(format!("XML parse error: {e}"))),
310            _ => {}
311        }
312    }
313
314    Ok(Value::Object(root_map))
315}
316
317/// Strip namespace prefix from a tag name (e.g. `"nfe:NFe"` -> `"NFe"`).
318fn strip_ns_prefix(name: &str) -> String {
319    match name.find(':') {
320        Some(idx) => name[idx + 1..].to_string(),
321        None => name.to_string(),
322    }
323}
324
325/// Check whether an attribute key is an XML namespace declaration.
326///
327/// Returns `true` for `xmlns` and `xmlns:*` (e.g. `xmlns:nfe`).
328fn is_xmlns_attr(key: &str) -> bool {
329    key == "xmlns" || key.starts_with("xmlns:")
330}
331
332/// Insert a value into a JSON map, converting to an array if the key already exists.
333fn insert_into_map(
334    map: &mut serde_json::Map<String, serde_json::Value>,
335    key: &str,
336    value: serde_json::Value,
337) {
338    use serde_json::Value;
339    if let Some(existing) = map.get_mut(key) {
340        match existing {
341            Value::Array(arr) => {
342                arr.push(value);
343            }
344            _ => {
345                let prev = existing.take();
346                *existing = Value::Array(vec![prev, value]);
347            }
348        }
349    } else {
350        map.insert(key.to_string(), value);
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn identify_nfe() {
360        let xml = r#"<?xml version="1.0"?><NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe/></NFe>"#;
361        assert_eq!(identify_xml_type(xml).unwrap(), "NFe");
362    }
363
364    #[test]
365    fn identify_nfe_proc() {
366        let xml = r#"<nfeProc versao="4.00"><NFe/><protNFe/></nfeProc>"#;
367        assert_eq!(identify_xml_type(xml).unwrap(), "nfeProc");
368    }
369
370    #[test]
371    fn empty_returns_err() {
372        assert!(identify_xml_type("").is_err());
373    }
374
375    #[test]
376    fn non_xml_returns_err() {
377        assert!(identify_xml_type("hello world").is_err());
378    }
379
380    #[test]
381    fn unknown_root_returns_err() {
382        let xml = "<other><data/></other>";
383        assert!(identify_xml_type(xml).is_err());
384    }
385
386    // ── identify_xml_type additional patterns ────────────────────────
387
388    #[test]
389    fn identify_env_evento() {
390        let xml = r#"<envEvento versao="1.00"><evento/></envEvento>"#;
391        assert_eq!(identify_xml_type(xml).unwrap(), "envEvento");
392    }
393
394    #[test]
395    fn identify_ret_cons_sit_nfe() {
396        let xml = "<retConsSitNFe><cStat>100</cStat></retConsSitNFe>";
397        assert_eq!(identify_xml_type(xml).unwrap(), "retConsSitNFe");
398    }
399
400    #[test]
401    fn identify_cons_cad() {
402        let xml = r#"<ConsCad versao="2.00"><infCons/></ConsCad>"#;
403        assert_eq!(identify_xml_type(xml).unwrap(), "ConsCad");
404    }
405
406    #[test]
407    fn identify_inut_nfe() {
408        let xml = r#"<inutNFe versao="4.00"><infInut/></inutNFe>"#;
409        assert_eq!(identify_xml_type(xml).unwrap(), "inutNFe");
410    }
411
412    #[test]
413    fn identify_ret_env_evento() {
414        let xml = "<retEnvEvento><cStat>128</cStat></retEnvEvento>";
415        assert_eq!(identify_xml_type(xml).unwrap(), "retEnvEvento");
416    }
417
418    #[test]
419    fn identify_ret_inut_nfe() {
420        let xml = "<retInutNFe><infInut/></retInutNFe>";
421        assert_eq!(identify_xml_type(xml).unwrap(), "retInutNFe");
422    }
423
424    #[test]
425    fn identify_adm_csc_nfce() {
426        let xml = r#"<admCscNFCe versao="1.00"><data/></admCscNFCe>"#;
427        assert_eq!(identify_xml_type(xml).unwrap(), "admCscNFCe");
428    }
429
430    #[test]
431    fn identify_dist_dfe_int() {
432        let xml = r#"<distDFeInt versao="1.01"><data/></distDFeInt>"#;
433        assert_eq!(identify_xml_type(xml).unwrap(), "distDFeInt");
434    }
435
436    #[test]
437    fn identify_proc_evento_nfe() {
438        let xml = r#"<procEventoNFe versao="1.00"><evento/></procEventoNFe>"#;
439        assert_eq!(identify_xml_type(xml).unwrap(), "procEventoNFe");
440    }
441
442    // ── xml_to_json additional coverage ──────────────────────────────
443
444    #[test]
445    fn xml_to_json_empty_elements() {
446        // Root NFe is unwrapped; infNFe becomes top-level
447        let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe Id="NFe123"><empty/></infNFe></NFe>"#;
448        let json = xml_to_json(xml).unwrap();
449        assert!(json.contains("infNFe"));
450        // xmlns should NOT appear
451        assert!(!json.contains("xmlns"));
452    }
453
454    #[test]
455    fn xml_to_json_non_nfe_document_fails() {
456        let xml = "<garbage><data>hello</data></garbage>";
457        assert!(xml_to_json(xml).is_err());
458    }
459
460    #[test]
461    fn xml_to_json_empty_element_with_attrs() {
462        // Root NFe is unwrapped; tag with attr becomes top-level
463        let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><tag attr="val"/></NFe>"#;
464        let json = xml_to_json(xml).unwrap();
465        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
466        assert!(v.get("tag").is_some());
467        assert!(v.get("NFe").is_none(), "root tag NFe must be unwrapped");
468    }
469
470    #[test]
471    fn xml_to_json_repeated_elements_become_array() {
472        // Root NFe is unwrapped; det array becomes top-level
473        let xml =
474            r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><det>a</det><det>b</det></NFe>"#;
475        let json = xml_to_json(xml).unwrap();
476        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
477        let det = v
478            .get("det")
479            .expect("det should be at top level after unwrap");
480        assert!(det.is_array());
481        assert_eq!(det.as_array().expect("is array").len(), 2);
482    }
483
484    #[test]
485    fn xml_to_json_element_with_only_attrs_no_text() {
486        // Root NFe is unwrapped; item with attrs becomes top-level
487        let xml =
488            r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><item a="1" b="2"></item></NFe>"#;
489        let json = xml_to_json(xml).unwrap();
490        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
491        assert!(v.get("item").is_some());
492    }
493
494    #[test]
495    fn strip_ns_prefix_with_colon() {
496        assert_eq!(strip_ns_prefix("nfe:NFe"), "NFe");
497    }
498
499    #[test]
500    fn strip_ns_prefix_without_colon() {
501        assert_eq!(strip_ns_prefix("NFe"), "NFe");
502    }
503
504    #[test]
505    fn is_xmlns_attr_cases() {
506        assert!(is_xmlns_attr("xmlns"));
507        assert!(is_xmlns_attr("xmlns:nfe"));
508        assert!(!is_xmlns_attr("versao"));
509        assert!(!is_xmlns_attr("Id"));
510    }
511
512    #[test]
513    fn xml_to_json_basic() {
514        // Root NFe is unwrapped; infNFe at top level
515        let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe Id="NFe123"><ide><cUF>35</cUF></ide></infNFe></NFe>"#;
516        let json = xml_to_json(xml).unwrap();
517        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
518        assert!(v.get("infNFe").is_some(), "infNFe should be at top level");
519        assert!(v.get("NFe").is_none(), "NFe root should be unwrapped");
520    }
521
522    #[test]
523    fn xml_to_json_attributes_inline() {
524        // Root NFe is unwrapped; verify inline attributes on infNFe
525        let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00" Id="NFe123"><ide><cUF>35</cUF></ide></infNFe></NFe>"#;
526        let json = xml_to_json(xml).unwrap();
527        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
528        let inf_nfe = v.get("infNFe").expect("infNFe at top level");
529        // Attributes should be inline, not nested under "attributes"
530        assert_eq!(inf_nfe.get("versao").and_then(|v| v.as_str()), Some("4.00"));
531        assert_eq!(inf_nfe.get("Id").and_then(|v| v.as_str()), Some("NFe123"));
532        assert!(
533            inf_nfe.get("attributes").is_none(),
534            "should NOT have @attributes/attributes key"
535        );
536    }
537
538    #[test]
539    fn xml_to_json_cdata_in_qrcode() {
540        // Root NFe is unwrapped; infNFeSupl at top level
541        let xml = concat!(
542            r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe">"#,
543            r#"<infNFe versao="4.00" Id="NFe123"><ide><cUF>35</cUF></ide></infNFe>"#,
544            r#"<infNFeSupl>"#,
545            r#"<qrCode><![CDATA[http://example.com/nfce?p=123&x=456]]></qrCode>"#,
546            r#"<urlChave>http://example.com/nfce/consulta</urlChave>"#,
547            r#"</infNFeSupl>"#,
548            r#"</NFe>"#,
549        );
550        let json = xml_to_json(xml).unwrap();
551        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
552        let supl = v.get("infNFeSupl").expect("infNFeSupl at top level");
553        assert_eq!(
554            supl.get("qrCode").and_then(|v| v.as_str()),
555            Some("http://example.com/nfce?p=123&x=456"),
556            "CDATA content should be preserved without mangling"
557        );
558        assert_eq!(
559            supl.get("urlChave").and_then(|v| v.as_str()),
560            Some("http://example.com/nfce/consulta"),
561        );
562    }
563
564    #[test]
565    fn xml_to_json_xmlns_stripped() {
566        // Verify xmlns attributes are completely removed from output
567        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>"#;
568        let json = xml_to_json(xml).unwrap();
569        assert!(
570            !json.contains("xmlns"),
571            "xmlns must not appear in JSON output"
572        );
573        assert!(json.contains("versao"), "non-xmlns attributes must be kept");
574    }
575
576    // ── xml_to_value (equivalente a toStd) ───────────────────────────
577
578    #[test]
579    fn xml_to_value_navigable_fields() {
580        // Equivalent to PHP: $std = $standardize->toStd($xml);
581        // PHP returns the *contents* of the matched root element.
582        // For NFe, the root is unwrapped: $std->infNFe->ide->cUF
583        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>"#;
584        let value = xml_to_value(xml).unwrap();
585
586        // Root NFe is unwrapped — navigate directly to infNFe
587        assert_eq!(value["infNFe"]["ide"]["cUF"].as_str(), Some("35"));
588        assert_eq!(value["infNFe"]["ide"]["nNF"].as_str(), Some("12345"));
589        // Inline attributes on infNFe
590        assert_eq!(value["infNFe"]["versao"].as_str(), Some("4.00"));
591        assert_eq!(value["infNFe"]["Id"].as_str(), Some("NFe35..."));
592        // No xmlns
593        assert!(value.get("xmlns").is_none(), "xmlns must not appear");
594    }
595
596    #[test]
597    fn xml_to_value_ret_cons_sit_nfe() {
598        // Root retConsSitNFe is unwrapped; children are at top level
599        let xml = concat!(
600            r#"<retConsSitNFe versao="4.00">"#,
601            "<cStat>100</cStat>",
602            "<xMotivo>Autorizado o uso da NF-e</xMotivo>",
603            "<chNFe>35200612345678901234550010000000011000000019</chNFe>",
604            "</retConsSitNFe>"
605        );
606        let value = xml_to_value(xml).unwrap();
607
608        assert_eq!(value["cStat"].as_str(), Some("100"));
609        assert_eq!(value["xMotivo"].as_str(), Some("Autorizado o uso da NF-e"));
610        assert_eq!(
611            value["chNFe"].as_str(),
612            Some("35200612345678901234550010000000011000000019")
613        );
614        // Root attributes are merged inline
615        assert_eq!(value["versao"].as_str(), Some("4.00"));
616    }
617
618    #[test]
619    fn xml_to_value_empty_xml_fails() {
620        assert!(xml_to_value("").is_err());
621    }
622
623    #[test]
624    fn xml_to_value_non_nfe_fails() {
625        assert!(xml_to_value("<other/>").is_err());
626    }
627
628    #[test]
629    fn xml_to_value_cdata_preserved() {
630        // Root NFe is unwrapped; infNFeSupl at top level
631        let xml = concat!(
632            r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe">"#,
633            r#"<infNFeSupl>"#,
634            r#"<qrCode><![CDATA[http://example.com?a=1&b=2]]></qrCode>"#,
635            r#"</infNFeSupl>"#,
636            r#"</NFe>"#,
637        );
638        let value = xml_to_value(xml).unwrap();
639        assert_eq!(
640            value["infNFeSupl"]["qrCode"].as_str(),
641            Some("http://example.com?a=1&b=2")
642        );
643    }
644
645    // ── xml_to_map (equivalente a toArray) ───────────────────────────
646
647    #[test]
648    fn xml_to_map_returns_top_level_keys() {
649        // Root NFe is unwrapped; infNFe is at top level of the map
650        let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe><ide><cUF>35</cUF></ide></infNFe></NFe>"#;
651        let map = xml_to_map(xml).unwrap();
652
653        assert!(
654            map.contains_key("infNFe"),
655            "top-level map must contain 'infNFe' key (root NFe unwrapped)"
656        );
657        assert_eq!(map["infNFe"]["ide"]["cUF"].as_str(), Some("35"));
658    }
659
660    #[test]
661    fn xml_to_map_nfe_proc() {
662        // Root nfeProc is unwrapped; its children (versao, NFe, protNFe) are top-level
663        let xml = concat!(
664            r#"<nfeProc versao="4.00">"#,
665            r#"<NFe><infNFe><ide><cUF>31</cUF></ide></infNFe></NFe>"#,
666            r#"<protNFe><infProt><cStat>100</cStat></infProt></protNFe>"#,
667            r#"</nfeProc>"#
668        );
669        let map = xml_to_map(xml).unwrap();
670
671        assert_eq!(map["versao"].as_str(), Some("4.00"));
672        assert_eq!(map["NFe"]["infNFe"]["ide"]["cUF"].as_str(), Some("31"));
673        assert_eq!(map["protNFe"]["infProt"]["cStat"].as_str(), Some("100"));
674    }
675
676    #[test]
677    fn xml_to_map_empty_xml_fails() {
678        assert!(xml_to_map("").is_err());
679    }
680
681    #[test]
682    fn xml_to_map_non_nfe_fails() {
683        assert!(xml_to_map("<garbage/>").is_err());
684    }
685
686    #[test]
687    fn xml_to_map_repeated_elements() {
688        // Root NFe is unwrapped; det array at top level
689        let xml = concat!(
690            r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe">"#,
691            r#"<det nItem="1"><prod><cProd>001</cProd></prod></det>"#,
692            r#"<det nItem="2"><prod><cProd>002</cProd></prod></det>"#,
693            r#"</NFe>"#
694        );
695        let map = xml_to_map(xml).unwrap();
696        let det = &map["det"];
697        assert!(det.is_array(), "repeated elements must become an array");
698        let arr = det.as_array().expect("is array");
699        assert_eq!(arr.len(), 2);
700        assert_eq!(arr[0]["prod"]["cProd"].as_str(), Some("001"));
701        assert_eq!(arr[1]["prod"]["cProd"].as_str(), Some("002"));
702    }
703
704    #[test]
705    fn xml_to_value_and_json_produce_equivalent_output() {
706        // Ensure xml_to_value and xml_to_json produce the same data
707        let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00"><ide><cUF>35</cUF></ide></infNFe></NFe>"#;
708        let value = xml_to_value(xml).unwrap();
709        let json_str = xml_to_json(xml).unwrap();
710        let from_json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
711        assert_eq!(value, from_json);
712    }
713
714    // ── PHP parity tests ─────────────────────────────────────────────
715
716    #[test]
717    fn xml_to_value_matches_php_to_std_nfe() {
718        // PHP's Standardize::toStd() for NFe returns:
719        // {"infNFe": {"versao": "4.00", "Id": "NFe123", "ide": {"cUF": "35"}}}
720        // (no NFe wrapper, no xmlns)
721        let xml = r#"<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00" Id="NFe123"><ide><cUF>35</cUF></ide></infNFe></NFe>"#;
722        let value = xml_to_value(xml).unwrap();
723
724        // Must NOT have root wrapper
725        assert!(value.get("NFe").is_none(), "NFe wrapper must be removed");
726        // Must NOT have xmlns
727        assert!(value.get("xmlns").is_none(), "xmlns must be stripped");
728        // Children must be directly accessible
729        assert!(value.get("infNFe").is_some(), "infNFe must be at top level");
730        assert_eq!(value["infNFe"]["versao"].as_str(), Some("4.00"));
731        assert_eq!(value["infNFe"]["Id"].as_str(), Some("NFe123"));
732        assert_eq!(value["infNFe"]["ide"]["cUF"].as_str(), Some("35"));
733    }
734
735    #[test]
736    fn xml_to_value_matches_php_to_std_ret_envi_nfe() {
737        // PHP's toStd() for retEnviNFe returns: {"versao": "4.00", "cStat": "103", ...}
738        let xml = concat!(
739            r#"<retEnviNFe versao="4.00">"#,
740            "<tpAmb>2</tpAmb>",
741            "<cStat>103</cStat>",
742            "<xMotivo>Lote recebido com sucesso</xMotivo>",
743            "</retEnviNFe>"
744        );
745        let value = xml_to_value(xml).unwrap();
746
747        assert!(value.get("retEnviNFe").is_none(), "root must be unwrapped");
748        assert_eq!(value["versao"].as_str(), Some("4.00"));
749        assert_eq!(value["tpAmb"].as_str(), Some("2"));
750        assert_eq!(value["cStat"].as_str(), Some("103"));
751        assert_eq!(value["xMotivo"].as_str(), Some("Lote recebido com sucesso"));
752    }
753
754    #[test]
755    fn xml_to_value_matches_php_to_std_nfe_proc() {
756        // PHP's toStd() for nfeProc returns:
757        // {"versao": "4.00", "NFe": {...}, "protNFe": {...}}
758        let xml = concat!(
759            r#"<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">"#,
760            r#"<NFe><infNFe><ide><cUF>35</cUF></ide></infNFe></NFe>"#,
761            r#"<protNFe><infProt><cStat>100</cStat></infProt></protNFe>"#,
762            r#"</nfeProc>"#
763        );
764        let value = xml_to_value(xml).unwrap();
765
766        assert!(
767            value.get("nfeProc").is_none(),
768            "root nfeProc must be unwrapped"
769        );
770        assert!(value.get("xmlns").is_none(), "xmlns must not appear");
771        assert_eq!(value["versao"].as_str(), Some("4.00"));
772        assert!(value.get("NFe").is_some());
773        assert!(value.get("protNFe").is_some());
774        assert_eq!(value["protNFe"]["infProt"]["cStat"].as_str(), Some("100"));
775    }
776}