Skip to main content

config_disassembler/xml/
verify.rs

1//! Round-trip verification: disassemble + reassemble an XML file in an
2//! isolated temp directory and report whether the reconstructed file
3//! matches the original, ignoring sibling/attribute reordering.
4
5use crate::xml::handlers::{DisassembleXmlFileHandler, ReassembleXmlFileHandler};
6use crate::xml::parsers::parse_xml_from_str;
7use crate::xml::types::{DecomposeRule, MultiLevelRule, SidecarSpec, XmlElement};
8use serde_json::{Map, Value};
9use tokio::fs;
10
11/// Outcome of a round-trip verification.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RoundtripStatus {
14    /// Reconstructed file is byte-identical to the original.
15    Identical,
16    /// Reconstructed file differs only in sibling/attribute order; content
17    /// is semantically equal.
18    Reordered,
19    /// Reconstructed file lost or changed content. The `String` names the reason.
20    Drift(String),
21}
22
23/// Options forwarded to the underlying disassemble/reassemble calls.
24#[derive(Debug, Clone, Default)]
25pub struct VerifyOptions<'a> {
26    pub unique_id_elements: Option<&'a str>,
27    pub strategy: Option<&'a str>,
28    pub ignore_path: &'a str,
29    /// Extension (may itself contain dots, e.g. `"permissionset-meta.xml"`)
30    /// passed to the round-trip's `reassemble()` call — same parameter as
31    /// [`crate::xml::ReassembleXmlFileHandler::reassemble`]'s
32    /// `file_extension`. The disassembled directory is named after the
33    /// original filename with its last dot segment stripped (e.g.
34    /// `HR_Admin.permissionset-meta.xml` disassembles into `HR_Admin/`), so
35    /// reassembling with the wrong extension produces a reconstructed
36    /// filename that doesn't match the original. Defaults to whatever
37    /// suffix the original filename has beyond the disassembled directory's
38    /// name, which reproduces the original filename exactly.
39    pub file_extension: Option<&'a str>,
40    pub multi_level_rules: Option<&'a [MultiLevelRule]>,
41    pub decompose_rules: Option<&'a [DecomposeRule]>,
42    pub sidecar_specs: Option<&'a [SidecarSpec]>,
43}
44
45/// Disassemble and reassemble `file_path` inside an isolated temp
46/// directory, then compare the reconstructed XML against the original.
47/// The caller's file is never modified.
48pub async fn verify_roundtrip(
49    file_path: &str,
50    options: VerifyOptions<'_>,
51) -> Result<RoundtripStatus, Box<dyn std::error::Error + Send + Sync>> {
52    let original_content = fs::read_to_string(file_path).await?;
53    let original_parsed = parse_xml_from_str(&original_content, file_path);
54
55    let base_name = std::path::Path::new(file_path)
56        .file_name()
57        .and_then(|n| n.to_str())
58        .unwrap_or("input.xml")
59        .to_string();
60
61    let temp_dir = tempfile::tempdir()?;
62    let temp_copy = temp_dir.path().join(&base_name);
63    fs::copy(file_path, &temp_copy).await?;
64
65    DisassembleXmlFileHandler::new()
66        .disassemble(
67            temp_copy.to_string_lossy().as_ref(),
68            options.unique_id_elements,
69            options.strategy,
70            true,
71            true,
72            options.ignore_path,
73            "xml",
74            options.multi_level_rules,
75            options.decompose_rules,
76            options.sidecar_specs,
77            None,
78        )
79        .await?;
80
81    let disassembled_dir = find_only_subdirectory(temp_dir.path()).await?;
82    let Some(disassembled_dir) = disassembled_dir else {
83        return Ok(RoundtripStatus::Drift(
84            "missing in round-trip output".to_string(),
85        ));
86    };
87
88    let dir_base_name = disassembled_dir
89        .file_name()
90        .and_then(|n| n.to_str())
91        .unwrap_or("output");
92
93    // The disassembled dir's basename need not match the original filename:
94    // the crate strips the last dot segment off the file stem when naming
95    // it (e.g. `HR_Admin.permissionset-meta.xml` disassembles into
96    // `HR_Admin/`). Default the reassemble extension to whatever suffix the
97    // original filename has beyond that basename, so the reconstructed file
98    // matches the original filename unless the caller overrides it.
99    let file_extension: String = options
100        .file_extension
101        .map(str::to_string)
102        .unwrap_or_else(|| {
103            base_name
104                .strip_prefix(&format!("{dir_base_name}."))
105                .map(str::to_string)
106                .unwrap_or_else(|| "xml".to_string())
107        });
108
109    ReassembleXmlFileHandler::new()
110        .reassemble(
111            disassembled_dir.to_string_lossy().as_ref(),
112            Some(&file_extension),
113            true,
114            options.sidecar_specs,
115        )
116        .await?;
117
118    let reconstructed_path = disassembled_dir
119        .parent()
120        .unwrap_or(temp_dir.path())
121        .join(format!("{dir_base_name}.{file_extension}"));
122    let reconstructed_content = match fs::read_to_string(&reconstructed_path).await {
123        Ok(c) => c,
124        Err(_) => {
125            return Ok(RoundtripStatus::Drift(
126                "missing in round-trip output".to_string(),
127            ));
128        }
129    };
130
131    if original_content == reconstructed_content {
132        return Ok(RoundtripStatus::Identical);
133    }
134
135    let reconstructed_parsed = parse_xml_from_str(
136        &reconstructed_content,
137        &reconstructed_path.to_string_lossy(),
138    );
139    match (original_parsed, reconstructed_parsed) {
140        (Some(orig), Some(recon)) if canonicalize(&orig) == canonicalize(&recon) => {
141            Ok(RoundtripStatus::Reordered)
142        }
143        _ => Ok(RoundtripStatus::Drift("content drift".to_string())),
144    }
145}
146
147/// Returns the single directory entry directly under `dir`, if exactly one
148/// exists. `verify_roundtrip` copies only one file into an otherwise-empty
149/// temp dir before disassembling, so the disassembled tree is always the
150/// only subdirectory produced.
151async fn find_only_subdirectory(
152    dir: &std::path::Path,
153) -> Result<Option<std::path::PathBuf>, Box<dyn std::error::Error + Send + Sync>> {
154    let mut read_dir = fs::read_dir(dir).await?;
155    let mut found = None;
156    while let Some(entry) = read_dir.next_entry().await? {
157        if entry.file_type().await?.is_dir() {
158            found = Some(entry.path());
159        }
160    }
161    Ok(found)
162}
163
164/// Recursively normalize a parsed XML value so structurally-equal-but-reordered
165/// trees compare equal: object keys are sorted, and array elements are sorted
166/// by the canonical JSON string of each (already-canonicalized) element.
167fn canonicalize(value: &XmlElement) -> Value {
168    match value {
169        Value::Object(map) => {
170            let mut keys: Vec<&String> = map.keys().collect();
171            keys.sort();
172            let mut out = Map::new();
173            for key in keys {
174                out.insert(key.clone(), canonicalize(&map[key]));
175            }
176            Value::Object(out)
177        }
178        Value::Array(items) => {
179            let mut canonical: Vec<Value> = items.iter().map(canonicalize).collect();
180            canonical.sort_by(|a, b| {
181                serde_json::to_string(a)
182                    .unwrap_or_default()
183                    .cmp(&serde_json::to_string(b).unwrap_or_default())
184            });
185            Value::Array(canonical)
186        }
187        other => other.clone(),
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn canonicalize_ignores_object_key_order() {
197        let a = serde_json::json!({ "b": 1, "a": 2 });
198        let b = serde_json::json!({ "a": 2, "b": 1 });
199        assert_eq!(canonicalize(&a), canonicalize(&b));
200    }
201
202    #[test]
203    fn canonicalize_ignores_array_element_order() {
204        let a = serde_json::json!([{ "id": 1 }, { "id": 2 }]);
205        let b = serde_json::json!([{ "id": 2 }, { "id": 1 }]);
206        assert_eq!(canonicalize(&a), canonicalize(&b));
207    }
208
209    #[test]
210    fn canonicalize_distinguishes_different_content() {
211        let a = serde_json::json!({ "a": 1 });
212        let b = serde_json::json!({ "a": 2 });
213        assert_ne!(canonicalize(&a), canonicalize(&b));
214    }
215
216    #[test]
217    fn canonicalize_recurses_into_nested_arrays_and_objects() {
218        let a = serde_json::json!({ "items": [{ "x": [2, 1] }, { "x": [1, 2] }] });
219        let b = serde_json::json!({ "items": [{ "x": [1, 2] }, { "x": [2, 1] }] });
220        assert_eq!(canonicalize(&a), canonicalize(&b));
221    }
222
223    #[tokio::test]
224    async fn verify_roundtrip_identical_for_simple_xml() {
225        // The disassembler's own serialization (attribute/element formatting)
226        // need not match arbitrary hand-written input byte-for-byte even when
227        // no data is lost — that's exactly the "Reordered" case. To exercise
228        // a genuine `Identical` result, first normalize the input by running
229        // it through one disassemble/reassemble pass directly (not through
230        // `verify_roundtrip`, which never mutates its input); feeding that
231        // canonical output back in is deterministic and must round-trip
232        // byte-identical.
233        let tmp = tempfile::tempdir().unwrap();
234        let xml_path = tmp.path().join("Simple.xml");
235        tokio::fs::write(
236            &xml_path,
237            r#"<?xml version="1.0" encoding="UTF-8"?><Root xmlns="http://example.com"><Child><Name>hello</Name></Child></Root>"#,
238        )
239        .await
240        .unwrap();
241
242        DisassembleXmlFileHandler::new()
243            .disassemble(
244                xml_path.to_str().unwrap(),
245                None,
246                None,
247                true,
248                true,
249                "",
250                "xml",
251                None,
252                None,
253                None,
254                None,
255            )
256            .await
257            .unwrap();
258        ReassembleXmlFileHandler::new()
259            .reassemble(
260                tmp.path().join("Simple").to_str().unwrap(),
261                Some("xml"),
262                true,
263                None,
264            )
265            .await
266            .unwrap();
267
268        let status = verify_roundtrip(xml_path.to_str().unwrap(), VerifyOptions::default())
269            .await
270            .unwrap();
271        assert_eq!(status, RoundtripStatus::Identical);
272    }
273
274    #[tokio::test]
275    async fn verify_roundtrip_reordered_when_sibling_order_changes() {
276        let tmp = tempfile::tempdir().unwrap();
277        let xml_path = tmp.path().join("Multi.xml");
278        // Repeated same-tag children keyed by name: the unique-id strategy
279        // splits these into separate files and re-merges by filename order,
280        // which need not match the original document order.
281        tokio::fs::write(
282            &xml_path,
283            r#"<?xml version="1.0" encoding="UTF-8"?><Root xmlns="http://example.com"><child><name>zebra</name></child><child><name>apple</name></child></Root>"#,
284        )
285        .await
286        .unwrap();
287
288        let status = verify_roundtrip(
289            xml_path.to_str().unwrap(),
290            VerifyOptions {
291                unique_id_elements: Some("name"),
292                ..Default::default()
293            },
294        )
295        .await
296        .unwrap();
297        assert_eq!(status, RoundtripStatus::Reordered);
298    }
299
300    #[tokio::test]
301    async fn verify_roundtrip_drift_when_input_unparseable() {
302        let tmp = tempfile::tempdir().unwrap();
303        let xml_path = tmp.path().join("Bad.xml");
304        tokio::fs::write(&xml_path, "<<not xml").await.unwrap();
305
306        let status = verify_roundtrip(xml_path.to_str().unwrap(), VerifyOptions::default())
307            .await
308            .unwrap();
309        assert_eq!(
310            status,
311            RoundtripStatus::Drift("missing in round-trip output".to_string())
312        );
313    }
314
315    #[tokio::test]
316    async fn verify_roundtrip_handles_dotted_meta_filename() {
317        // Regression test: the disassembled directory for a stem like
318        // `HR_Admin.permissionset-meta` is named `HR_Admin` (the crate
319        // strips the last dot segment). Without a correct default
320        // `file_extension`, reassemble would write `HR_Admin.xml` — a
321        // filename that does NOT match the original
322        // `HR_Admin.permissionset-meta.xml` — and `verify_roundtrip` would
323        // look for the wrong reconstructed file, falsely reporting
324        // `Drift("missing in round-trip output")` for every dotted `-meta`
325        // filename (the common case for real Salesforce metadata).
326        let status = verify_roundtrip(
327            "fixtures/xml/general/HR_Admin.permissionset-meta.xml",
328            VerifyOptions {
329                unique_id_elements: Some(
330                    "application,apexClass,name,externalDataSource,flow,object,apexPage,recordType,tab,field",
331                ),
332                ..Default::default()
333            },
334        )
335        .await
336        .unwrap();
337        assert_eq!(status, RoundtripStatus::Identical);
338    }
339}