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    pub multi_level_rules: Option<&'a [MultiLevelRule]>,
30    pub decompose_rules: Option<&'a [DecomposeRule]>,
31    pub sidecar_specs: Option<&'a [SidecarSpec]>,
32}
33
34/// Disassemble and reassemble `file_path` inside an isolated temp
35/// directory, then compare the reconstructed XML against the original.
36/// The caller's file is never modified.
37pub async fn verify_roundtrip(
38    file_path: &str,
39    options: VerifyOptions<'_>,
40) -> Result<RoundtripStatus, Box<dyn std::error::Error + Send + Sync>> {
41    let original_content = fs::read_to_string(file_path).await?;
42    let original_parsed = parse_xml_from_str(&original_content, file_path);
43
44    let base_name = std::path::Path::new(file_path)
45        .file_name()
46        .and_then(|n| n.to_str())
47        .unwrap_or("input.xml")
48        .to_string();
49
50    let temp_dir = tempfile::tempdir()?;
51    let temp_copy = temp_dir.path().join(&base_name);
52    fs::copy(file_path, &temp_copy).await?;
53
54    DisassembleXmlFileHandler::new()
55        .disassemble(
56            temp_copy.to_string_lossy().as_ref(),
57            options.unique_id_elements,
58            options.strategy,
59            true,
60            true,
61            options.ignore_path,
62            "xml",
63            options.multi_level_rules,
64            options.decompose_rules,
65            options.sidecar_specs,
66        )
67        .await?;
68
69    let disassembled_dir = find_only_subdirectory(temp_dir.path()).await?;
70    let Some(disassembled_dir) = disassembled_dir else {
71        return Ok(RoundtripStatus::Drift(
72            "missing in round-trip output".to_string(),
73        ));
74    };
75
76    ReassembleXmlFileHandler::new()
77        .reassemble(
78            disassembled_dir.to_string_lossy().as_ref(),
79            Some("xml"),
80            true,
81            options.sidecar_specs,
82        )
83        .await?;
84
85    let reconstructed_path = temp_dir.path().join(&base_name);
86    let reconstructed_content = match fs::read_to_string(&reconstructed_path).await {
87        Ok(c) => c,
88        Err(_) => {
89            return Ok(RoundtripStatus::Drift(
90                "missing in round-trip output".to_string(),
91            ));
92        }
93    };
94
95    if original_content == reconstructed_content {
96        return Ok(RoundtripStatus::Identical);
97    }
98
99    let reconstructed_parsed = parse_xml_from_str(&reconstructed_content, &base_name);
100    match (original_parsed, reconstructed_parsed) {
101        (Some(orig), Some(recon)) if canonicalize(&orig) == canonicalize(&recon) => {
102            Ok(RoundtripStatus::Reordered)
103        }
104        _ => Ok(RoundtripStatus::Drift("content drift".to_string())),
105    }
106}
107
108/// Returns the single directory entry directly under `dir`, if exactly one
109/// exists. `verify_roundtrip` copies only one file into an otherwise-empty
110/// temp dir before disassembling, so the disassembled tree is always the
111/// only subdirectory produced.
112async fn find_only_subdirectory(
113    dir: &std::path::Path,
114) -> Result<Option<std::path::PathBuf>, Box<dyn std::error::Error + Send + Sync>> {
115    let mut read_dir = fs::read_dir(dir).await?;
116    let mut found = None;
117    while let Some(entry) = read_dir.next_entry().await? {
118        if entry.file_type().await?.is_dir() {
119            found = Some(entry.path());
120        }
121    }
122    Ok(found)
123}
124
125/// Recursively normalize a parsed XML value so structurally-equal-but-reordered
126/// trees compare equal: object keys are sorted, and array elements are sorted
127/// by the canonical JSON string of each (already-canonicalized) element.
128fn canonicalize(value: &XmlElement) -> Value {
129    match value {
130        Value::Object(map) => {
131            let mut keys: Vec<&String> = map.keys().collect();
132            keys.sort();
133            let mut out = Map::new();
134            for key in keys {
135                out.insert(key.clone(), canonicalize(&map[key]));
136            }
137            Value::Object(out)
138        }
139        Value::Array(items) => {
140            let mut canonical: Vec<Value> = items.iter().map(canonicalize).collect();
141            canonical.sort_by(|a, b| {
142                serde_json::to_string(a)
143                    .unwrap_or_default()
144                    .cmp(&serde_json::to_string(b).unwrap_or_default())
145            });
146            Value::Array(canonical)
147        }
148        other => other.clone(),
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn canonicalize_ignores_object_key_order() {
158        let a = serde_json::json!({ "b": 1, "a": 2 });
159        let b = serde_json::json!({ "a": 2, "b": 1 });
160        assert_eq!(canonicalize(&a), canonicalize(&b));
161    }
162
163    #[test]
164    fn canonicalize_ignores_array_element_order() {
165        let a = serde_json::json!([{ "id": 1 }, { "id": 2 }]);
166        let b = serde_json::json!([{ "id": 2 }, { "id": 1 }]);
167        assert_eq!(canonicalize(&a), canonicalize(&b));
168    }
169
170    #[test]
171    fn canonicalize_distinguishes_different_content() {
172        let a = serde_json::json!({ "a": 1 });
173        let b = serde_json::json!({ "a": 2 });
174        assert_ne!(canonicalize(&a), canonicalize(&b));
175    }
176
177    #[test]
178    fn canonicalize_recurses_into_nested_arrays_and_objects() {
179        let a = serde_json::json!({ "items": [{ "x": [2, 1] }, { "x": [1, 2] }] });
180        let b = serde_json::json!({ "items": [{ "x": [1, 2] }, { "x": [2, 1] }] });
181        assert_eq!(canonicalize(&a), canonicalize(&b));
182    }
183
184    #[tokio::test]
185    async fn verify_roundtrip_identical_for_simple_xml() {
186        // The disassembler's own serialization (attribute/element formatting)
187        // need not match arbitrary hand-written input byte-for-byte even when
188        // no data is lost — that's exactly the "Reordered" case. To exercise
189        // a genuine `Identical` result, first normalize the input by running
190        // it through one disassemble/reassemble pass directly (not through
191        // `verify_roundtrip`, which never mutates its input); feeding that
192        // canonical output back in is deterministic and must round-trip
193        // byte-identical.
194        let tmp = tempfile::tempdir().unwrap();
195        let xml_path = tmp.path().join("Simple.xml");
196        tokio::fs::write(
197            &xml_path,
198            r#"<?xml version="1.0" encoding="UTF-8"?><Root xmlns="http://example.com"><Child><Name>hello</Name></Child></Root>"#,
199        )
200        .await
201        .unwrap();
202
203        DisassembleXmlFileHandler::new()
204            .disassemble(
205                xml_path.to_str().unwrap(),
206                None,
207                None,
208                true,
209                true,
210                "",
211                "xml",
212                None,
213                None,
214                None,
215            )
216            .await
217            .unwrap();
218        ReassembleXmlFileHandler::new()
219            .reassemble(
220                tmp.path().join("Simple").to_str().unwrap(),
221                Some("xml"),
222                true,
223                None,
224            )
225            .await
226            .unwrap();
227
228        let status = verify_roundtrip(xml_path.to_str().unwrap(), VerifyOptions::default())
229            .await
230            .unwrap();
231        assert_eq!(status, RoundtripStatus::Identical);
232    }
233
234    #[tokio::test]
235    async fn verify_roundtrip_reordered_when_sibling_order_changes() {
236        let tmp = tempfile::tempdir().unwrap();
237        let xml_path = tmp.path().join("Multi.xml");
238        // Repeated same-tag children keyed by name: the unique-id strategy
239        // splits these into separate files and re-merges by filename order,
240        // which need not match the original document order.
241        tokio::fs::write(
242            &xml_path,
243            r#"<?xml version="1.0" encoding="UTF-8"?><Root xmlns="http://example.com"><child><name>zebra</name></child><child><name>apple</name></child></Root>"#,
244        )
245        .await
246        .unwrap();
247
248        let status = verify_roundtrip(
249            xml_path.to_str().unwrap(),
250            VerifyOptions {
251                unique_id_elements: Some("name"),
252                ..Default::default()
253            },
254        )
255        .await
256        .unwrap();
257        assert_eq!(status, RoundtripStatus::Reordered);
258    }
259
260    #[tokio::test]
261    async fn verify_roundtrip_drift_when_input_unparseable() {
262        let tmp = tempfile::tempdir().unwrap();
263        let xml_path = tmp.path().join("Bad.xml");
264        tokio::fs::write(&xml_path, "<<not xml").await.unwrap();
265
266        let status = verify_roundtrip(xml_path.to_str().unwrap(), VerifyOptions::default())
267            .await
268            .unwrap();
269        assert_eq!(
270            status,
271            RoundtripStatus::Drift("missing in round-trip output".to_string())
272        );
273    }
274}