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    let attr_str: String = attrs
61        .iter()
62        .map(|(k, v)| format!(" {k}=\"{}\"", escape_xml(v)))
63        .collect();
64
65    match children {
66        TagContent::None => format!("<{name}{attr_str}></{name}>"),
67        TagContent::Text(text) => {
68            format!("<{name}{attr_str}>{}</{name}>", escape_xml(text))
69        }
70        TagContent::Children(kids) => {
71            let inner: String = kids.into_iter().collect();
72            format!("<{name}{attr_str}>{inner}</{name}>")
73        }
74    }
75}
76
77/// Content variants for the [`tag`] builder function.
78///
79/// Use [`TagContent::None`] for self-closing elements, [`TagContent::Text`]
80/// for text nodes (automatically XML-escaped), and [`TagContent::Children`]
81/// for pre-built child element strings.
82#[non_exhaustive]
83pub enum TagContent<'a> {
84    /// Empty element: `<name></name>`.
85    None,
86    /// Text content (will be XML-escaped): `<name>text</name>`.
87    Text(&'a str),
88    /// Pre-built child elements concatenated verbatim: `<name><a/><b/></name>`.
89    Children(Vec<String>),
90}
91
92impl<'a> From<&'a str> for TagContent<'a> {
93    fn from(s: &'a str) -> Self {
94        TagContent::Text(s)
95    }
96}
97
98impl From<Vec<String>> for TagContent<'_> {
99    fn from(v: Vec<String>) -> Self {
100        TagContent::Children(v)
101    }
102}
103
104impl From<String> for TagContent<'_> {
105    fn from(s: String) -> Self {
106        TagContent::Text(Box::leak(s.into_boxed_str()))
107    }
108}