Skip to main content

fiscal_core/
xml_utils.rs

1//! Low-level XML building primitives used throughout the crate.
2//!
3//! These utilities are deliberately simple and allocation-efficient: they work
4//! on `&str` slices and return owned `String`s, with no external XML library
5//! dependency.
6
7/// Escape special XML characters in text content and attribute values,
8/// replacing `&`, `<`, `>`, `"`, and `'` with their XML entity equivalents.
9///
10/// # Examples
11///
12/// ```
13/// use fiscal_core::xml_utils::escape_xml;
14/// assert_eq!(escape_xml("Tom & Jerry <cats>"), "Tom &amp; Jerry &lt;cats&gt;");
15/// ```
16pub fn escape_xml(s: &str) -> String {
17    let mut result = String::with_capacity(s.len());
18    for ch in s.chars() {
19        match ch {
20            '&' => result.push_str("&amp;"),
21            '<' => result.push_str("&lt;"),
22            '>' => result.push_str("&gt;"),
23            '"' => result.push_str("&quot;"),
24            '\'' => result.push_str("&apos;"),
25            c => result.push(c),
26        }
27    }
28    result
29}
30
31/// Extract the text content of the first occurrence of a simple XML tag in a
32/// raw XML string.
33///
34/// Searches for `<tag_name>…</tag_name>` and returns the inner text.  Does not
35/// handle namespaced tags, nested tags of the same name, or CDATA sections.
36///
37/// Returns `None` if the tag is absent.
38///
39/// # Examples
40///
41/// ```
42/// use fiscal_core::xml_utils::extract_xml_tag_value;
43/// let xml = "<root><cStat>100</cStat></root>";
44/// assert_eq!(extract_xml_tag_value(xml, "cStat"), Some("100".to_string()));
45/// assert_eq!(extract_xml_tag_value(xml, "missing"), None);
46/// ```
47pub fn extract_xml_tag_value(xml: &str, tag_name: &str) -> Option<String> {
48    let open = format!("<{tag_name}>");
49    let close = format!("</{tag_name}>");
50    let start = xml.find(&open)? + open.len();
51    let end = xml[start..].find(&close)? + start;
52    Some(xml[start..end].to_string())
53}
54
55/// Build an XML tag with optional attributes and children.
56///
57/// If children is a string, it is escaped. If children is an array
58/// of pre-built strings, they are concatenated as-is.
59pub fn tag(name: &str, attrs: &[(&str, &str)], children: TagContent<'_>) -> String {
60    use std::fmt::Write as _;
61    let attr_str: String = attrs.iter().fold(String::new(), |mut s, (k, v)| {
62        let _ = write!(s, " {k}=\"{}\"", escape_xml(v));
63        s
64    });
65
66    match children {
67        TagContent::None => format!("<{name}{attr_str}></{name}>"),
68        TagContent::Text(text) => {
69            format!("<{name}{attr_str}>{}</{name}>", escape_xml(text))
70        }
71        TagContent::Children(kids) => {
72            let inner: String = kids.into_iter().collect();
73            format!("<{name}{attr_str}>{inner}</{name}>")
74        }
75    }
76}
77
78/// Content variants for the [`tag`] builder function.
79///
80/// Use [`TagContent::None`] for self-closing elements, [`TagContent::Text`]
81/// for text nodes (automatically XML-escaped), and [`TagContent::Children`]
82/// for pre-built child element strings.
83#[non_exhaustive]
84pub enum TagContent<'a> {
85    /// Empty element: `<name></name>`.
86    None,
87    /// Text content (will be XML-escaped): `<name>text</name>`.
88    Text(&'a str),
89    /// Pre-built child elements concatenated verbatim: `<name><a/><b/></name>`.
90    Children(Vec<String>),
91}
92
93impl<'a> From<&'a str> for TagContent<'a> {
94    fn from(s: &'a str) -> Self {
95        TagContent::Text(s)
96    }
97}
98
99impl From<Vec<String>> for TagContent<'_> {
100    fn from(v: Vec<String>) -> Self {
101        TagContent::Children(v)
102    }
103}
104
105impl From<String> for TagContent<'_> {
106    fn from(s: String) -> Self {
107        TagContent::Text(Box::leak(s.into_boxed_str()))
108    }
109}
110
111/// Pretty-print an XML string by adding indentation.
112///
113/// This is a lightweight formatter that does not parse XML semantically --
114/// it works by splitting on `<` / `>` boundaries and inserting newlines and
115/// indentation. Suitable for debugging/display purposes. Equivalent to the
116/// PHP `FakePretty::prettyPrint` formatting behaviour (via DOMDocument::formatOutput).
117///
118/// # Examples
119///
120/// ```
121/// use fiscal_core::xml_utils::pretty_print_xml;
122/// let compact = "<root><child>text</child></root>";
123/// let pretty = pretty_print_xml(compact);
124/// assert!(pretty.contains("  <child>"));
125/// ```
126pub fn pretty_print_xml(xml: &str) -> String {
127    // Tokenise into tags and text segments
128    let mut tokens: Vec<XmlToken> = Vec::new();
129    let mut pos = 0;
130    let bytes = xml.as_bytes();
131
132    while pos < bytes.len() {
133        if bytes[pos] == b'<' {
134            // Find end of tag
135            let end = xml[pos..]
136                .find('>')
137                .map(|i| pos + i + 1)
138                .unwrap_or(bytes.len());
139            tokens.push(XmlToken::Tag(xml[pos..end].to_string()));
140            pos = end;
141        } else {
142            // Text until next '<'
143            let end = xml[pos..].find('<').map(|i| pos + i).unwrap_or(bytes.len());
144            let text = &xml[pos..end];
145            if !text.trim().is_empty() {
146                tokens.push(XmlToken::Text(text.trim().to_string()));
147            }
148            pos = end;
149        }
150    }
151
152    // Now render with indentation
153    let indent = "  ";
154    let mut result = String::with_capacity(xml.len() * 2);
155    let mut depth: usize = 0;
156
157    let mut i = 0;
158    while i < tokens.len() {
159        match &tokens[i] {
160            XmlToken::Tag(t) if t.starts_with("<?") => {
161                // XML declaration
162                result.push_str(t);
163                result.push('\n');
164            }
165            XmlToken::Tag(t) if t.starts_with("</") => {
166                // Closing tag
167                depth = depth.saturating_sub(1);
168                for _ in 0..depth {
169                    result.push_str(indent);
170                }
171                result.push_str(t);
172                result.push('\n');
173            }
174            XmlToken::Tag(t) if t.ends_with("/>") => {
175                // Self-closing tag
176                for _ in 0..depth {
177                    result.push_str(indent);
178                }
179                result.push_str(t);
180                result.push('\n');
181            }
182            XmlToken::Tag(t) => {
183                // Opening tag -- check if next token is Text followed by closing tag
184                if i + 2 < tokens.len() {
185                    if let (XmlToken::Text(text), XmlToken::Tag(close)) =
186                        (&tokens[i + 1], &tokens[i + 2])
187                    {
188                        if close.starts_with("</") {
189                            // Inline text element: <tag>text</tag>
190                            for _ in 0..depth {
191                                result.push_str(indent);
192                            }
193                            result.push_str(t);
194                            result.push_str(text);
195                            result.push_str(close);
196                            result.push('\n');
197                            i += 3;
198                            continue;
199                        }
200                    }
201                }
202                for _ in 0..depth {
203                    result.push_str(indent);
204                }
205                result.push_str(t);
206                result.push('\n');
207                depth += 1;
208            }
209            XmlToken::Text(t) => {
210                // Standalone text (unusual)
211                for _ in 0..depth {
212                    result.push_str(indent);
213                }
214                result.push_str(t);
215                result.push('\n');
216            }
217        }
218        i += 1;
219    }
220
221    // Remove trailing newline
222    while result.ends_with('\n') {
223        result.pop();
224    }
225    result
226}
227
228/// Internal token type for XML pretty-printing.
229enum XmlToken {
230    Tag(String),
231    Text(String),
232}
233
234/// Replace characters that are valid in XML but rejected by SEFAZ.
235///
236/// This is a **SEFAZ-level** sanitisation function, distinct from [`escape_xml`].
237/// While `escape_xml` performs standard XML entity encoding, this function
238/// mirrors the PHP `Strings::replaceUnacceptableCharacters` from `sped-common`:
239///
240/// 1. Remove `<` and `>`.
241/// 2. Replace `&` with ` & ` (space-padded).
242/// 3. Remove single quotes (`'`) and double quotes (`"`).
243/// 4. Collapse multiple consecutive whitespace characters into a single space.
244/// 5. Encode the remaining `&` as `&amp;`.
245/// 6. Remove carriage return (`\r`), tab (`\t`), and line feed (`\n`).
246/// 7. Collapse multiple whitespace again (from normalize step).
247/// 8. Remove ASCII control characters (`0x00`–`0x1F`, `0x7F`), except space.
248/// 9. Trim leading and trailing whitespace.
249///
250/// The function is designed to be called on user-provided field values
251/// (e.g. `xJust`, `xCorrecao`, `xPag`) before they are placed into the
252/// NF-e XML, so that the SEFAZ web-service will not reject the document
253/// because of forbidden characters.
254///
255/// # Examples
256///
257/// ```
258/// use fiscal_core::xml_utils::replace_unacceptable_characters;
259/// assert_eq!(
260///     replace_unacceptable_characters("Tom & Jerry <cats>"),
261///     "Tom &amp; Jerry cats"
262/// );
263/// assert_eq!(
264///     replace_unacceptable_characters("  hello   world  "),
265///     "hello world"
266/// );
267/// ```
268pub fn replace_unacceptable_characters(input: &str) -> String {
269    if input.is_empty() {
270        return String::new();
271    }
272
273    // Step 1: Remove < and >
274    let s = input.replace(['<', '>'], "");
275
276    // Step 2: Replace & with " & " (space-padded)
277    let s = s.replace('&', " & ");
278
279    // Step 3-4: Remove single quotes and double quotes
280    let s = s.replace(['\'', '"'], "");
281
282    // Step 5: Collapse multiple whitespace into single space
283    let s = collapse_whitespace(&s);
284
285    // Step 6: Encode & as &amp; (the only entity that can remain after steps 1-4)
286    let s = s.replace('&', "&amp;");
287
288    // Step 7: Remove \r, \t, \n (normalize)
289    let s = s.replace(['\r', '\t', '\n'], "");
290
291    // Step 8: Collapse multiple whitespace again (normalize)
292    let s = collapse_whitespace(&s);
293
294    // Step 9: Remove control characters (0x00-0x1F except space 0x20, and 0x7F)
295    let s: String = s
296        .chars()
297        .filter(|&c| !c.is_ascii_control() || c == ' ')
298        .collect();
299
300    // Step 10: Trim
301    s.trim().to_string()
302}
303
304/// Collapse runs of whitespace characters into a single ASCII space.
305///
306/// Equivalent to the PHP `preg_replace('/(?:\s\s+)/', ' ', …)` pattern used
307/// throughout `sped-common`.
308fn collapse_whitespace(s: &str) -> String {
309    let mut result = String::with_capacity(s.len());
310    let mut prev_ws = false;
311    for ch in s.chars() {
312        if ch.is_whitespace() {
313            if !prev_ws {
314                result.push(' ');
315            }
316            prev_ws = true;
317        } else {
318            result.push(ch);
319            prev_ws = false;
320        }
321    }
322    result
323}
324
325/// Validate an NF-e XML string by checking for the presence of required tags.
326///
327/// This is a lightweight structural validator that checks for mandatory tags
328/// in the NF-e/NFC-e XML. It does **not** perform full XSD schema validation
329/// (which would require shipping XSD files and a full XML schema parser), but
330/// covers the most common errors that would cause SEFAZ rejection.
331///
332/// Validated items:
333/// - Required root structure (`<NFe>`, `<infNFe>`)
334/// - Required `<ide>` fields (cUF, cNF, natOp, mod, serie, nNF, dhEmi, tpNF, etc.)
335/// - Required `<emit>` fields (CNPJ/CPF, xNome, enderEmit, IE, CRT)
336/// - Required `<det>` with at least one item
337/// - Required `<total>` / `<ICMSTot>`
338/// - Required `<transp>` and `<pag>`
339/// - Access key format (44 digits)
340///
341/// # Errors
342///
343/// Returns [`FiscalError::XmlParsing`] with a description of all missing tags.
344///
345/// # Examples
346///
347/// ```
348/// use fiscal_core::xml_utils::validate_xml;
349/// let xml = "<NFe><infNFe>...</infNFe></NFe>";
350/// // Will return an error listing all missing required tags
351/// assert!(validate_xml(xml).is_err());
352/// ```
353pub fn validate_xml(xml: &str) -> Result<(), crate::FiscalError> {
354    let mut errors: Vec<String> = Vec::new();
355
356    // Check root structure
357    let required_structure = [
358        ("NFe", "Elemento raiz <NFe> ausente"),
359        ("infNFe", "Elemento <infNFe> ausente"),
360    ];
361    for (tag_name, msg) in &required_structure {
362        if !xml.contains(&format!("<{tag_name}")) {
363            errors.push(msg.to_string());
364        }
365    }
366
367    // Check IDE required tags
368    let ide_tags = [
369        "cUF", "cNF", "natOp", "mod", "serie", "nNF", "dhEmi", "tpNF", "idDest", "cMunFG", "tpImp",
370        "tpEmis", "cDV", "tpAmb", "finNFe", "indFinal", "indPres", "procEmi", "verProc",
371    ];
372    for tag_name in &ide_tags {
373        if extract_xml_tag_value(xml, tag_name).is_none() {
374            errors.push(format!("Tag obrigatória <{tag_name}> ausente em <ide>"));
375        }
376    }
377
378    // Check emit required tags
379    let emit_required = ["xNome", "IE", "CRT"];
380    for tag_name in &emit_required {
381        if extract_xml_tag_value(xml, tag_name).is_none() {
382            errors.push(format!("Tag obrigatória <{tag_name}> ausente em <emit>"));
383        }
384    }
385    // CNPJ or CPF must be present
386    if extract_xml_tag_value(xml, "CNPJ").is_none() && extract_xml_tag_value(xml, "CPF").is_none() {
387        errors.push("Tag <CNPJ> ou <CPF> ausente em <emit>".to_string());
388    }
389
390    // Check required blocks
391    let required_blocks = [
392        ("enderEmit", "Bloco <enderEmit> ausente"),
393        ("det ", "Nenhum item <det> encontrado"),
394        ("total", "Bloco <total> ausente"),
395        ("ICMSTot", "Bloco <ICMSTot> ausente"),
396        ("transp", "Bloco <transp> ausente"),
397        ("pag", "Bloco <pag> ausente"),
398    ];
399    for (fragment, msg) in &required_blocks {
400        if !xml.contains(&format!("<{fragment}")) {
401            errors.push(msg.to_string());
402        }
403    }
404
405    // Validate access key format (44 digits) from infNFe Id attribute
406    if let Some(id_start) = xml.find("Id=\"NFe") {
407        let after_id = &xml[id_start + 7..];
408        if let Some(quote_end) = after_id.find('"') {
409            let key = &after_id[..quote_end];
410            if key.len() != 44 || !key.chars().all(|c| c.is_ascii_digit()) {
411                errors.push(format!(
412                    "Chave de acesso inválida: esperado 44 dígitos, encontrado '{key}'"
413                ));
414            }
415        }
416    }
417
418    if errors.is_empty() {
419        Ok(())
420    } else {
421        Err(crate::FiscalError::XmlParsing(errors.join("; ")))
422    }
423}
424
425/// Remove characters that are invalid in XML 1.0 documents.
426///
427/// Per the XML 1.0 specification (Section 2.2), the only valid characters are:
428///
429/// - `#x9` (tab), `#xA` (line feed), `#xD` (carriage return)
430/// - `#x20`–`#xD7FF`
431/// - `#xE000`–`#xFFFD`
432/// - `#x10000`–`#x10FFFF`
433///
434/// All other characters (control characters `\x00`–`\x08`, `\x0B`–`\x0C`,
435/// `\x0E`–`\x1F`, surrogates `\xD800`–`\xDFFF`, `\xFFFE`–`\xFFFF`) are
436/// stripped from the output.
437///
438/// This mirrors the character-level cleaning portion of the PHP
439/// `Strings::normalize()` function in `sped-common`.
440///
441/// # Examples
442///
443/// ```
444/// use fiscal_core::xml_utils::remove_invalid_xml_chars;
445/// assert_eq!(remove_invalid_xml_chars("hello\x00world"), "helloworld");
446/// assert_eq!(remove_invalid_xml_chars("tab\there"), "tab\there");
447/// assert_eq!(remove_invalid_xml_chars("line\nfeed"), "line\nfeed");
448/// ```
449pub fn remove_invalid_xml_chars(input: &str) -> String {
450    let mut result = String::with_capacity(input.len());
451    for ch in input.chars() {
452        if is_valid_xml_char(ch) {
453            result.push(ch);
454        }
455    }
456    result
457}
458
459/// Check whether a character is valid in XML 1.0 documents.
460///
461/// Valid characters per the XML 1.0 spec:
462/// `#x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]`
463fn is_valid_xml_char(ch: char) -> bool {
464    matches!(ch,
465        '\u{09}' | '\u{0A}' | '\u{0D}' |
466        '\u{20}'..='\u{D7FF}' |
467        '\u{E000}'..='\u{FFFD}' |
468        '\u{10000}'..='\u{10FFFF}'
469    )
470}
471
472/// Clean an XML string by removing namespace artifacts, collapsing inter-tag
473/// whitespace, and optionally stripping the `<?xml … ?>` declaration.
474///
475/// This is a direct port of the PHP `Strings::clearXmlString()` from
476/// `sped-common`. It performs the following transformations:
477///
478/// 1. Removes the `xmlns:default="http://www.w3.org/2000/09/xmldsig#"` attribute.
479/// 2. Removes the `standalone="no"` attribute.
480/// 3. Removes `default:` namespace prefixes and `:default` suffixes.
481/// 4. Strips `\n`, `\r`, and `\t` characters.
482/// 5. Collapses whitespace between adjacent XML tags (`> <` becomes `><`).
483/// 6. If `remove_encoding_tag` is `true`, removes the `<?xml … ?>` declaration.
484///
485/// # Examples
486///
487/// ```
488/// use fiscal_core::xml_utils::clear_xml_string;
489///
490/// let xml = "<root>\n  <child>text</child>\n</root>";
491/// assert_eq!(clear_xml_string(xml, false), "<root><child>text</child></root>");
492///
493/// let xml2 = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root><a>1</a></root>";
494/// assert_eq!(clear_xml_string(xml2, true), "<root><a>1</a></root>");
495/// ```
496pub fn clear_xml_string(input: &str, remove_encoding_tag: bool) -> String {
497    // Remove namespace artifacts and control whitespace (matches PHP $aFind array)
498    let mut result = input.to_string();
499
500    let removals = [
501        "xmlns:default=\"http://www.w3.org/2000/09/xmldsig#\"",
502        " standalone=\"no\"",
503        "default:",
504        ":default",
505        "\n",
506        "\r",
507        "\t",
508    ];
509    for pattern in &removals {
510        result = result.replace(pattern, "");
511    }
512
513    // Collapse whitespace between tags: >   < becomes ><
514    // This replicates: preg_replace('/(\>)\s*(\<)/m', '$1$2', $retXml)
515    let mut collapsed = String::with_capacity(result.len());
516    let mut chars = result.chars().peekable();
517    while let Some(ch) = chars.next() {
518        collapsed.push(ch);
519        if ch == '>' {
520            // Skip whitespace until we hit '<' or a non-whitespace char
521            let mut ws_buf = String::new();
522            while let Some(&next) = chars.peek() {
523                if next.is_ascii_whitespace() {
524                    ws_buf.push(next);
525                    chars.next();
526                } else {
527                    break;
528                }
529            }
530            // If the next char after whitespace is '<', drop the whitespace
531            // Otherwise, keep it
532            if let Some(&next) = chars.peek() {
533                if next != '<' {
534                    collapsed.push_str(&ws_buf);
535                }
536            } else {
537                // End of string; preserve trailing whitespace
538                collapsed.push_str(&ws_buf);
539            }
540        }
541    }
542    result = collapsed;
543
544    // Optionally remove <?xml ... ?> declaration
545    if remove_encoding_tag {
546        result = delete_all_between(&result, "<?xml", "?>");
547    }
548
549    result
550}
551
552/// Remove the first occurrence of text delimited by `beginning` and `end`
553/// (inclusive of the delimiters).
554///
555/// Port of PHP `Strings::deleteAllBetween()`.
556fn delete_all_between(input: &str, beginning: &str, end: &str) -> String {
557    let begin_pos = match input.find(beginning) {
558        Some(p) => p,
559        None => return input.to_string(),
560    };
561    let after_begin = begin_pos + beginning.len();
562    let end_pos = match input[after_begin..].find(end) {
563        Some(p) => after_begin + p + end.len(),
564        None => return input.to_string(),
565    };
566    let mut result = String::with_capacity(input.len() - (end_pos - begin_pos));
567    result.push_str(&input[..begin_pos]);
568    result.push_str(&input[end_pos..]);
569    result
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[test]
577    fn pretty_print_simple_xml() {
578        let compact = "<root><child>text</child></root>";
579        let pretty = pretty_print_xml(compact);
580        assert!(pretty.contains("<root>"));
581        assert!(pretty.contains("  <child>text</child>"));
582        assert!(pretty.contains("</root>"));
583    }
584
585    #[test]
586    fn pretty_print_nested_xml() {
587        let compact = "<a><b><c>val</c></b></a>";
588        let pretty = pretty_print_xml(compact);
589        let lines: Vec<&str> = pretty.lines().collect();
590        assert_eq!(lines[0], "<a>");
591        assert_eq!(lines[1], "  <b>");
592        assert_eq!(lines[2], "    <c>val</c>");
593        assert_eq!(lines[3], "  </b>");
594        assert_eq!(lines[4], "</a>");
595    }
596
597    #[test]
598    fn pretty_print_with_declaration() {
599        let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root><a>1</a></root>";
600        let pretty = pretty_print_xml(xml);
601        assert!(pretty.starts_with("<?xml"));
602        assert!(pretty.contains("  <a>1</a>"));
603    }
604
605    #[test]
606    fn pretty_print_empty_input() {
607        let pretty = pretty_print_xml("");
608        assert_eq!(pretty, "");
609    }
610
611    #[test]
612    fn validate_xml_valid_nfe() {
613        let xml = concat!(
614            r#"<NFe><infNFe versao="4.00" Id="NFe41260304123456000190550010000001231123456780">"#,
615            "<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
616            "<mod>55</mod><serie>1</serie><nNF>123</nNF>",
617            "<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
618            "<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
619            "<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
620            "<tpAmb>2</tpAmb><finNFe>1</finNFe><indFinal>1</indFinal>",
621            "<indPres>1</indPres><procEmi>0</procEmi><verProc>1.0</verProc></ide>",
622            "<emit><CNPJ>04123456000190</CNPJ><xNome>Test</xNome>",
623            "<enderEmit><xLgr>Rua</xLgr></enderEmit>",
624            "<IE>9012345678</IE><CRT>3</CRT></emit>",
625            "<det nItem=\"1\"><prod><cProd>001</cProd></prod></det>",
626            "<total><ICMSTot><vNF>150.00</vNF></ICMSTot></total>",
627            "<transp><modFrete>9</modFrete></transp>",
628            "<pag><detPag><tPag>01</tPag><vPag>150.00</vPag></detPag></pag>",
629            "</infNFe></NFe>",
630        );
631        assert!(validate_xml(xml).is_ok());
632    }
633
634    #[test]
635    fn validate_xml_missing_tags() {
636        let xml = "<root><something>val</something></root>";
637        let err = validate_xml(xml).unwrap_err();
638        let msg = err.to_string();
639        assert!(msg.contains("NFe"));
640        assert!(msg.contains("infNFe"));
641    }
642
643    #[test]
644    fn validate_xml_invalid_access_key() {
645        let xml = concat!(
646            r#"<NFe><infNFe versao="4.00" Id="NFe123">"#,
647            "<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
648            "<mod>55</mod><serie>1</serie><nNF>123</nNF>",
649            "<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
650            "<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
651            "<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
652            "<tpAmb>2</tpAmb><finNFe>1</finNFe><indFinal>1</indFinal>",
653            "<indPres>1</indPres><procEmi>0</procEmi><verProc>1.0</verProc></ide>",
654            "<emit><CNPJ>04123456000190</CNPJ><xNome>Test</xNome>",
655            "<enderEmit><xLgr>Rua</xLgr></enderEmit>",
656            "<IE>9012345678</IE><CRT>3</CRT></emit>",
657            "<det nItem=\"1\"><prod><cProd>001</cProd></prod></det>",
658            "<total><ICMSTot><vNF>150.00</vNF></ICMSTot></total>",
659            "<transp><modFrete>9</modFrete></transp>",
660            "<pag><detPag><tPag>01</tPag><vPag>150.00</vPag></detPag></pag>",
661            "</infNFe></NFe>",
662        );
663        let err = validate_xml(xml).unwrap_err();
664        let msg = err.to_string();
665        assert!(msg.contains("Chave de acesso"));
666    }
667
668    // ── remove_invalid_xml_chars tests ──────────────────────────────────
669
670    #[test]
671    fn remove_invalid_xml_chars_preserves_valid_text() {
672        assert_eq!(remove_invalid_xml_chars("Hello, World!"), "Hello, World!");
673    }
674
675    #[test]
676    fn remove_invalid_xml_chars_preserves_tab_lf_cr() {
677        // \x09 (tab), \x0A (line feed), \x0D (carriage return) are valid
678        assert_eq!(
679            remove_invalid_xml_chars("a\x09b\x0Ac\x0Dd"),
680            "a\x09b\x0Ac\x0Dd"
681        );
682    }
683
684    #[test]
685    fn remove_invalid_xml_chars_strips_null_and_low_controls() {
686        // \x00 through \x08 are invalid
687        assert_eq!(
688            remove_invalid_xml_chars("\x00\x01\x02\x03\x04\x05\x06\x07\x08hello"),
689            "hello"
690        );
691    }
692
693    #[test]
694    fn remove_invalid_xml_chars_strips_0b_0c() {
695        // \x0B (vertical tab) and \x0C (form feed) are invalid
696        assert_eq!(remove_invalid_xml_chars("a\x0Bb\x0Cc"), "abc");
697    }
698
699    #[test]
700    fn remove_invalid_xml_chars_strips_0e_to_1f() {
701        // \x0E through \x1F are invalid
702        let mut input = String::from("ok");
703        for byte in 0x0Eu8..=0x1F {
704            input.push(byte as char);
705        }
706        input.push_str("end");
707        assert_eq!(remove_invalid_xml_chars(&input), "okend");
708    }
709
710    #[test]
711    fn remove_invalid_xml_chars_strips_del() {
712        // DEL (\x7F) is invalid — it falls outside the valid range
713        // (it's > \x1F but not in \x20..=\xD7FF since \x7F is a control char,
714        //  however by codepoint it IS in \x20..=\xD7FF so XML 1.0 actually
715        //  allows it as a valid character).
716        // Wait — XML 1.0 valid range includes #x20-#xD7FF, and \x7F = U+007F
717        // is within that range. So DEL is technically valid in XML 1.0.
718        // Our implementation follows the spec exactly.
719        assert_eq!(remove_invalid_xml_chars("a\x7Fb"), "a\x7Fb");
720    }
721
722    #[test]
723    fn remove_invalid_xml_chars_strips_fffe_ffff() {
724        // U+FFFE and U+FFFF are invalid
725        let input = format!("a{}b{}c", '\u{FFFE}', '\u{FFFF}');
726        assert_eq!(remove_invalid_xml_chars(&input), "abc");
727    }
728
729    #[test]
730    fn remove_invalid_xml_chars_preserves_bmp_and_supplementary() {
731        // Valid BMP characters (accented, CJK, etc.)
732        assert_eq!(
733            remove_invalid_xml_chars("café résumé 日本語"),
734            "café résumé 日本語"
735        );
736        // Valid supplementary plane characters (emoji, etc.)
737        let input = "hello \u{1F600} world"; // U+1F600 is valid (in #x10000-#x10FFFF)
738        assert_eq!(remove_invalid_xml_chars(input), input);
739    }
740
741    #[test]
742    fn remove_invalid_xml_chars_preserves_private_use_area() {
743        // U+E000-U+FFFD is valid
744        let input = "a\u{E000}b\u{FFFD}c";
745        assert_eq!(remove_invalid_xml_chars(input), input);
746    }
747
748    #[test]
749    fn remove_invalid_xml_chars_empty_string() {
750        assert_eq!(remove_invalid_xml_chars(""), "");
751    }
752
753    #[test]
754    fn remove_invalid_xml_chars_all_invalid() {
755        assert_eq!(remove_invalid_xml_chars("\x00\x01\x02\x03"), "");
756    }
757
758    #[test]
759    fn remove_invalid_xml_chars_mixed_xml_content() {
760        let input = "<tag>val\x00ue with \x0Bcontrol\x1F chars</tag>";
761        assert_eq!(
762            remove_invalid_xml_chars(input),
763            "<tag>value with control chars</tag>"
764        );
765    }
766
767    // ── clear_xml_string tests ──────────────────────────────────────────
768
769    #[test]
770    fn clear_xml_string_removes_whitespace_between_tags() {
771        let xml = "<root>\n  <child>text</child>\n</root>";
772        assert_eq!(
773            clear_xml_string(xml, false),
774            "<root><child>text</child></root>"
775        );
776    }
777
778    #[test]
779    fn clear_xml_string_removes_tabs_cr_lf() {
780        let xml = "<a>\t<b>\r\n<c>val</c>\n</b>\n</a>";
781        assert_eq!(clear_xml_string(xml, false), "<a><b><c>val</c></b></a>");
782    }
783
784    #[test]
785    fn clear_xml_string_removes_default_namespace() {
786        // Note: removing the xmlns attribute leaves a trailing space before '>',
787        // matching PHP str_replace behaviour exactly.
788        let xml = "<Signature xmlns:default=\"http://www.w3.org/2000/09/xmldsig#\"><default:SignedInfo>data</default:SignedInfo></Signature>";
789        assert_eq!(
790            clear_xml_string(xml, false),
791            "<Signature ><SignedInfo>data</SignedInfo></Signature>"
792        );
793    }
794
795    #[test]
796    fn clear_xml_string_removes_standalone_no() {
797        let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?><root/>";
798        assert_eq!(
799            clear_xml_string(xml, false),
800            "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>"
801        );
802    }
803
804    #[test]
805    fn clear_xml_string_removes_encoding_tag() {
806        let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root><a>1</a></root>";
807        assert_eq!(clear_xml_string(xml, true), "<root><a>1</a></root>");
808    }
809
810    #[test]
811    fn clear_xml_string_preserves_without_encoding_tag() {
812        let xml = "<?xml version=\"1.0\"?><root><a>1</a></root>";
813        assert_eq!(
814            clear_xml_string(xml, false),
815            "<?xml version=\"1.0\"?><root><a>1</a></root>"
816        );
817    }
818
819    #[test]
820    fn clear_xml_string_no_encoding_tag_present() {
821        let xml = "<root><a>1</a></root>";
822        assert_eq!(clear_xml_string(xml, true), "<root><a>1</a></root>");
823    }
824
825    #[test]
826    fn clear_xml_string_empty_input() {
827        assert_eq!(clear_xml_string("", false), "");
828        assert_eq!(clear_xml_string("", true), "");
829    }
830
831    #[test]
832    fn clear_xml_string_preserves_text_content_spaces() {
833        // Spaces inside text content (not between tags) should be preserved
834        let xml = "<tag>hello world</tag>";
835        assert_eq!(clear_xml_string(xml, false), "<tag>hello world</tag>");
836    }
837
838    #[test]
839    fn clear_xml_string_collapses_multiple_spaces_between_tags() {
840        let xml = "<a>   <b>text</b>   </a>";
841        assert_eq!(clear_xml_string(xml, false), "<a><b>text</b></a>");
842    }
843
844    #[test]
845    fn clear_xml_string_removes_colon_default_suffix() {
846        let xml = "<Signature:default><data/></Signature:default>";
847        assert_eq!(
848            clear_xml_string(xml, false),
849            "<Signature><data/></Signature>"
850        );
851    }
852
853    // ----- replace_unacceptable_characters tests -----
854
855    #[test]
856    fn replace_unacceptable_empty() {
857        assert_eq!(replace_unacceptable_characters(""), "");
858    }
859
860    #[test]
861    fn replace_unacceptable_plain_text() {
862        assert_eq!(
863            replace_unacceptable_characters("Venda de mercadorias"),
864            "Venda de mercadorias"
865        );
866    }
867
868    #[test]
869    fn replace_unacceptable_removes_angle_brackets() {
870        assert_eq!(replace_unacceptable_characters("foo<bar>baz"), "foobarbaz");
871    }
872
873    #[test]
874    fn replace_unacceptable_ampersand_encoding() {
875        assert_eq!(replace_unacceptable_characters("A&B"), "A &amp; B");
876    }
877
878    #[test]
879    fn replace_unacceptable_removes_quotes() {
880        assert_eq!(
881            replace_unacceptable_characters(r#"It's a "test""#),
882            "Its a test"
883        );
884    }
885
886    #[test]
887    fn replace_unacceptable_collapses_whitespace() {
888        assert_eq!(
889            replace_unacceptable_characters("hello    world"),
890            "hello world"
891        );
892    }
893
894    #[test]
895    fn replace_unacceptable_trims() {
896        assert_eq!(replace_unacceptable_characters("  hello  "), "hello");
897    }
898
899    #[test]
900    fn replace_unacceptable_removes_control_chars() {
901        assert_eq!(
902            replace_unacceptable_characters("abc\x00\x01\x02def"),
903            "abcdef"
904        );
905    }
906
907    #[test]
908    fn replace_unacceptable_removes_cr_lf_tab() {
909        assert_eq!(
910            replace_unacceptable_characters("line1\r\n\tline2"),
911            "line1 line2"
912        );
913    }
914
915    #[test]
916    fn replace_unacceptable_combined() {
917        assert_eq!(
918            replace_unacceptable_characters(
919                "  Cancelamento <por>  erro & \"duplicidade\"  na emissão\t\n  "
920            ),
921            "Cancelamento por erro &amp; duplicidade na emissão"
922        );
923    }
924
925    #[test]
926    fn replace_unacceptable_ampersand_already_spaced() {
927        assert_eq!(replace_unacceptable_characters("A & B"), "A &amp; B");
928    }
929
930    #[test]
931    fn replace_unacceptable_multiple_ampersands() {
932        assert_eq!(
933            replace_unacceptable_characters("A&B&C"),
934            "A &amp; B &amp; C"
935        );
936    }
937
938    #[test]
939    fn replace_unacceptable_preserves_accented_chars() {
940        assert_eq!(
941            replace_unacceptable_characters("São Paulo — café"),
942            "São Paulo — café"
943        );
944    }
945
946    #[test]
947    fn replace_unacceptable_only_special_chars() {
948        assert_eq!(replace_unacceptable_characters("<>\"'"), "");
949    }
950
951    #[test]
952    fn replace_unacceptable_del_char() {
953        assert_eq!(replace_unacceptable_characters("abc\x7Fdef"), "abcdef");
954    }
955
956    // ── TagContent::from impls ─────────────────────────────────────
957
958    #[test]
959    fn tag_content_from_string() {
960        let content: TagContent = String::from("hello").into();
961        match content {
962            TagContent::Text(t) => assert_eq!(t, "hello"),
963            _ => panic!("expected Text"),
964        }
965    }
966
967    #[test]
968    fn tag_content_from_vec_string() {
969        let content: TagContent = vec!["<a/>".to_string(), "<b/>".to_string()].into();
970        match content {
971            TagContent::Children(kids) => assert_eq!(kids.len(), 2),
972            _ => panic!("expected Children"),
973        }
974    }
975
976    // ── pretty_print_xml self-closing ──────────────────────────────
977
978    #[test]
979    fn pretty_print_self_closing_tag() {
980        let xml = "<root><empty/></root>";
981        let pretty = pretty_print_xml(xml);
982        assert!(pretty.contains("  <empty/>"));
983    }
984
985    #[test]
986    fn pretty_print_standalone_text() {
987        // This is unusual XML but the formatter should handle it
988        let xml = "<root><a><b>text</b></a></root>";
989        let pretty = pretty_print_xml(xml);
990        assert!(pretty.contains("    <b>text</b>"));
991    }
992
993    // ── clear_xml_string preserves trailing ws after non-tag ───────
994
995    #[test]
996    fn clear_xml_string_non_tag_after_whitespace() {
997        // After '>' if the next non-whitespace is NOT '<', preserve whitespace
998        let xml = "<a>text after close</a>";
999        let result = clear_xml_string(xml, false);
1000        assert_eq!(result, "<a>text after close</a>");
1001    }
1002
1003    // ── delete_all_between ────────────────────────────────────────
1004
1005    #[test]
1006    fn delete_all_between_no_match() {
1007        let result = delete_all_between("hello world", "<?xml", "?>");
1008        assert_eq!(result, "hello world");
1009    }
1010
1011    #[test]
1012    fn delete_all_between_no_end_match() {
1013        let result = delete_all_between("<?xml version start", "<?xml", "?>");
1014        assert_eq!(result, "<?xml version start");
1015    }
1016}