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