Skip to main content

config_disassembler/xml/parsers/
parse_xml.rs

1//! Parse XML file from path into XmlElement structure.
2
3use std::sync::LazyLock;
4
5use regex::Regex;
6use serde_json::Value;
7use tokio::fs;
8
9use crate::xml::parsers::parse_xml_cdata;
10use crate::xml::parsers::{mark_compact_elements, strip_whitespace_text_nodes_in_place};
11use crate::xml::types::XmlElement;
12
13/// Parses an XML file from a path.
14pub async fn parse_xml(file_path: &str) -> Option<XmlElement> {
15    let content = match fs::read_to_string(file_path).await {
16        Ok(c) => c,
17        Err(e) => {
18            log::error!(
19                "{} was unable to be parsed and will not be processed. Confirm formatting and try again.",
20                file_path
21            );
22            log::debug!("Parse error: {}", e);
23            return None;
24        }
25    };
26    parse_xml_from_str(&content, file_path)
27}
28
29/// Parses XML from a string. The file_path is used for error logging only.
30/// Uses custom parser that preserves CDATA sections (output as #cdata key).
31pub fn parse_xml_from_str(content: &str, file_path: &str) -> Option<XmlElement> {
32    let mut parsed: Value = match parse_xml_cdata::parse_xml_with_cdata(content) {
33        Ok(v) => v,
34        Err(e) => {
35            log::error!(
36                "{} was unable to be parsed and will not be processed. Confirm formatting and try again.",
37                file_path
38            );
39            log::debug!("Parse error: {}", e);
40            return None;
41        }
42    };
43
44    // Mark single-element "compact" wrappers (no whitespace around their sole child --
45    // e.g. Flow's `<connector><targetReference>X</targetReference></connector>`) before
46    // stripping whitespace-only text nodes below, which would otherwise erase the
47    // distinction between "never had whitespace" and "had it, now removed". Walk the
48    // document root's own children rather than `parsed` itself: the root wrapper always
49    // has exactly one key (the root element), which would otherwise always qualify.
50    //
51    // `parse_xml_with_cdata` always returns `Value::Object` (a populated root or an
52    // empty map -- see its two return sites), so `as_object_mut` is infallible here.
53    let root_children = parsed
54        .as_object_mut()
55        .expect("parse_xml_with_cdata always returns an object");
56    for value in root_children.values_mut() {
57        mark_compact_elements(value);
58    }
59
60    strip_whitespace_text_nodes_in_place(&mut parsed);
61    Some(parsed)
62}
63
64// Compiled once per process. These used to be built with `Regex::new` on
65// every call; `extract_xml_declaration_from_raw` runs once per shard on
66// reassemble (via `parse_to_xml_object`), so a large component compiled
67// thousands of regexes -- profiling sf-decomposer's workload showed regex
68// compilation as the single largest share of reassemble CPU.
69static XMLNS_RE: LazyLock<Regex> =
70    LazyLock::new(|| Regex::new(r#"xmlns="([^"]*)""#).expect("valid regex"));
71static DECLARATION_RE: LazyLock<Regex> =
72    LazyLock::new(|| Regex::new(r#"<\?xml\s+([^?]+)\?>"#).expect("valid regex"));
73static VERSION_RE: LazyLock<Regex> =
74    LazyLock::new(|| Regex::new(r#"version="([^"]*)""#).expect("valid regex"));
75static ENCODING_RE: LazyLock<Regex> =
76    LazyLock::new(|| Regex::new(r#"encoding="([^"]*)""#).expect("valid regex"));
77static STANDALONE_RE: LazyLock<Regex> =
78    LazyLock::new(|| Regex::new(r#"standalone="([^"]*)""#).expect("valid regex"));
79
80/// Extract xmlns attribute from raw XML (quickxml_to_serde drops it).
81/// Returns Some(value) if found, None otherwise.
82pub fn extract_xmlns_from_raw(xml_content: &str) -> Option<String> {
83    XMLNS_RE.captures(xml_content).map(|c| c[1].to_string())
84}
85
86/// Extract XML declaration from raw XML (quickxml_to_serde drops it).
87/// Returns a Value object like {"@version": "1.0", "@encoding": "UTF-8", "@standalone": "yes"}
88/// for use in build_xml_string. None if no declaration found.
89pub fn extract_xml_declaration_from_raw(xml_content: &str) -> Option<XmlElement> {
90    let decl_content = DECLARATION_RE.captures(xml_content)?.get(1)?.as_str();
91    let mut decl = serde_json::Map::new();
92    {
93        let cap = VERSION_RE.captures(decl_content)?;
94        decl.insert("@version".to_string(), Value::String(cap[1].to_string()));
95    }
96    if let Some(cap) = ENCODING_RE.captures(decl_content) {
97        decl.insert("@encoding".to_string(), Value::String(cap[1].to_string()));
98    }
99    if let Some(cap) = STANDALONE_RE.captures(decl_content) {
100        decl.insert("@standalone".to_string(), Value::String(cap[1].to_string()));
101    }
102    Some(Value::Object(decl))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn extract_xmlns_from_raw_finds_namespace() {
111        let xml = r#"<root xmlns="http://soap.sforce.com/2006/04/metadata"><a/></root>"#;
112        assert_eq!(
113            extract_xmlns_from_raw(xml),
114            Some("http://soap.sforce.com/2006/04/metadata".to_string())
115        );
116    }
117
118    #[test]
119    fn extract_xmlns_from_raw_returns_none_when_absent() {
120        let xml = r#"<root><a/></root>"#;
121        assert_eq!(extract_xmlns_from_raw(xml), None);
122    }
123
124    #[test]
125    fn extract_xml_declaration_from_raw_parses_version_and_encoding() {
126        let xml = r#"<?xml version="1.0" encoding="UTF-8"?><root/>"#;
127        let decl = extract_xml_declaration_from_raw(xml).unwrap();
128        let obj = decl.as_object().unwrap();
129        assert_eq!(obj.get("@version").and_then(|v| v.as_str()), Some("1.0"));
130        assert_eq!(obj.get("@encoding").and_then(|v| v.as_str()), Some("UTF-8"));
131    }
132
133    #[test]
134    fn extract_xml_declaration_from_raw_parses_standalone() {
135        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><root/>"#;
136        let decl = extract_xml_declaration_from_raw(xml).unwrap();
137        let obj = decl.as_object().unwrap();
138        assert_eq!(obj.get("@standalone").and_then(|v| v.as_str()), Some("yes"));
139    }
140
141    #[test]
142    fn extract_xml_declaration_from_raw_returns_none_without_declaration() {
143        let xml = r#"<root/>"#;
144        assert!(extract_xml_declaration_from_raw(xml).is_none());
145    }
146
147    #[test]
148    fn extract_xml_declaration_from_raw_returns_none_when_version_missing() {
149        let xml = r#"<?xml encoding="UTF-8"?><root/>"#;
150        assert!(extract_xml_declaration_from_raw(xml).is_none());
151    }
152
153    #[test]
154    fn extract_xml_declaration_from_raw_version_only_no_encoding_no_standalone() {
155        // Exercises the false branches of both the encoding and standalone
156        // `if let Some(cap)` checks inside extract_xml_declaration_from_raw.
157        let xml = r#"<?xml version="1.0"?><root/>"#;
158        let decl = extract_xml_declaration_from_raw(xml).unwrap();
159        let obj = decl.as_object().unwrap();
160        assert_eq!(obj.get("@version").and_then(|v| v.as_str()), Some("1.0"));
161        assert!(
162            obj.get("@encoding").is_none(),
163            "encoding must be absent when not in source"
164        );
165        assert!(
166            obj.get("@standalone").is_none(),
167            "standalone must be absent when not in source"
168        );
169    }
170
171    #[test]
172    fn parse_xml_from_str_invalid_xml_returns_none() {
173        let result = parse_xml_from_str("<<", "test.xml");
174        assert!(result.is_none());
175    }
176
177    #[tokio::test]
178    async fn parse_xml_missing_file_returns_none() {
179        let result = parse_xml("/nonexistent/path/file.xml").await;
180        assert!(result.is_none());
181    }
182}