Skip to main content

config_disassembler/xml/parsers/
parse_xml.rs

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