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 & Jerry <cats>");
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("&"),
21 '<' => result.push_str("<"),
22 '>' => result.push_str(">"),
23 '"' => result.push_str("""),
24 '\'' => result.push_str("'"),
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}