1use 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#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RoundtripStatus {
14 Identical,
16 Reordered,
19 Drift(String),
21}
22
23#[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
34pub 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
108async 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
125fn 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 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 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}