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 )
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 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
146async 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
163fn 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 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 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 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}