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 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
45pub 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 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
147async 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
164fn 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 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 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 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}