use crate::xml::handlers::{DisassembleXmlFileHandler, ReassembleXmlFileHandler};
use crate::xml::parsers::parse_xml_from_str;
use crate::xml::types::{DecomposeRule, MultiLevelRule, SidecarSpec, XmlElement};
use serde_json::{Map, Value};
use tokio::fs;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RoundtripStatus {
Identical,
Reordered,
Drift(String),
}
#[derive(Debug, Clone, Default)]
pub struct VerifyOptions<'a> {
pub unique_id_elements: Option<&'a str>,
pub strategy: Option<&'a str>,
pub ignore_path: &'a str,
pub file_extension: Option<&'a str>,
pub multi_level_rules: Option<&'a [MultiLevelRule]>,
pub decompose_rules: Option<&'a [DecomposeRule]>,
pub sidecar_specs: Option<&'a [SidecarSpec]>,
}
pub async fn verify_roundtrip(
file_path: &str,
options: VerifyOptions<'_>,
) -> Result<RoundtripStatus, Box<dyn std::error::Error + Send + Sync>> {
let original_content = fs::read_to_string(file_path).await?;
let original_parsed = parse_xml_from_str(&original_content, file_path);
let base_name = std::path::Path::new(file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("input.xml")
.to_string();
let temp_dir = tempfile::tempdir()?;
let temp_copy = temp_dir.path().join(&base_name);
fs::copy(file_path, &temp_copy).await?;
DisassembleXmlFileHandler::new()
.disassemble(
temp_copy.to_string_lossy().as_ref(),
options.unique_id_elements,
options.strategy,
true,
true,
options.ignore_path,
"xml",
options.multi_level_rules,
options.decompose_rules,
options.sidecar_specs,
)
.await?;
let disassembled_dir = find_only_subdirectory(temp_dir.path()).await?;
let Some(disassembled_dir) = disassembled_dir else {
return Ok(RoundtripStatus::Drift(
"missing in round-trip output".to_string(),
));
};
let dir_base_name = disassembled_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("output");
let file_extension: String = options
.file_extension
.map(str::to_string)
.unwrap_or_else(|| {
base_name
.strip_prefix(&format!("{dir_base_name}."))
.map(str::to_string)
.unwrap_or_else(|| "xml".to_string())
});
ReassembleXmlFileHandler::new()
.reassemble(
disassembled_dir.to_string_lossy().as_ref(),
Some(&file_extension),
true,
options.sidecar_specs,
)
.await?;
let reconstructed_path = disassembled_dir
.parent()
.unwrap_or(temp_dir.path())
.join(format!("{dir_base_name}.{file_extension}"));
let reconstructed_content = match fs::read_to_string(&reconstructed_path).await {
Ok(c) => c,
Err(_) => {
return Ok(RoundtripStatus::Drift(
"missing in round-trip output".to_string(),
));
}
};
if original_content == reconstructed_content {
return Ok(RoundtripStatus::Identical);
}
let reconstructed_parsed = parse_xml_from_str(
&reconstructed_content,
&reconstructed_path.to_string_lossy(),
);
match (original_parsed, reconstructed_parsed) {
(Some(orig), Some(recon)) if canonicalize(&orig) == canonicalize(&recon) => {
Ok(RoundtripStatus::Reordered)
}
_ => Ok(RoundtripStatus::Drift("content drift".to_string())),
}
}
async fn find_only_subdirectory(
dir: &std::path::Path,
) -> Result<Option<std::path::PathBuf>, Box<dyn std::error::Error + Send + Sync>> {
let mut read_dir = fs::read_dir(dir).await?;
let mut found = None;
while let Some(entry) = read_dir.next_entry().await? {
if entry.file_type().await?.is_dir() {
found = Some(entry.path());
}
}
Ok(found)
}
fn canonicalize(value: &XmlElement) -> Value {
match value {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut out = Map::new();
for key in keys {
out.insert(key.clone(), canonicalize(&map[key]));
}
Value::Object(out)
}
Value::Array(items) => {
let mut canonical: Vec<Value> = items.iter().map(canonicalize).collect();
canonical.sort_by(|a, b| {
serde_json::to_string(a)
.unwrap_or_default()
.cmp(&serde_json::to_string(b).unwrap_or_default())
});
Value::Array(canonical)
}
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonicalize_ignores_object_key_order() {
let a = serde_json::json!({ "b": 1, "a": 2 });
let b = serde_json::json!({ "a": 2, "b": 1 });
assert_eq!(canonicalize(&a), canonicalize(&b));
}
#[test]
fn canonicalize_ignores_array_element_order() {
let a = serde_json::json!([{ "id": 1 }, { "id": 2 }]);
let b = serde_json::json!([{ "id": 2 }, { "id": 1 }]);
assert_eq!(canonicalize(&a), canonicalize(&b));
}
#[test]
fn canonicalize_distinguishes_different_content() {
let a = serde_json::json!({ "a": 1 });
let b = serde_json::json!({ "a": 2 });
assert_ne!(canonicalize(&a), canonicalize(&b));
}
#[test]
fn canonicalize_recurses_into_nested_arrays_and_objects() {
let a = serde_json::json!({ "items": [{ "x": [2, 1] }, { "x": [1, 2] }] });
let b = serde_json::json!({ "items": [{ "x": [1, 2] }, { "x": [2, 1] }] });
assert_eq!(canonicalize(&a), canonicalize(&b));
}
#[tokio::test]
async fn verify_roundtrip_identical_for_simple_xml() {
let tmp = tempfile::tempdir().unwrap();
let xml_path = tmp.path().join("Simple.xml");
tokio::fs::write(
&xml_path,
r#"<?xml version="1.0" encoding="UTF-8"?><Root xmlns="http://example.com"><Child><Name>hello</Name></Child></Root>"#,
)
.await
.unwrap();
DisassembleXmlFileHandler::new()
.disassemble(
xml_path.to_str().unwrap(),
None,
None,
true,
true,
"",
"xml",
None,
None,
None,
)
.await
.unwrap();
ReassembleXmlFileHandler::new()
.reassemble(
tmp.path().join("Simple").to_str().unwrap(),
Some("xml"),
true,
None,
)
.await
.unwrap();
let status = verify_roundtrip(xml_path.to_str().unwrap(), VerifyOptions::default())
.await
.unwrap();
assert_eq!(status, RoundtripStatus::Identical);
}
#[tokio::test]
async fn verify_roundtrip_reordered_when_sibling_order_changes() {
let tmp = tempfile::tempdir().unwrap();
let xml_path = tmp.path().join("Multi.xml");
tokio::fs::write(
&xml_path,
r#"<?xml version="1.0" encoding="UTF-8"?><Root xmlns="http://example.com"><child><name>zebra</name></child><child><name>apple</name></child></Root>"#,
)
.await
.unwrap();
let status = verify_roundtrip(
xml_path.to_str().unwrap(),
VerifyOptions {
unique_id_elements: Some("name"),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(status, RoundtripStatus::Reordered);
}
#[tokio::test]
async fn verify_roundtrip_drift_when_input_unparseable() {
let tmp = tempfile::tempdir().unwrap();
let xml_path = tmp.path().join("Bad.xml");
tokio::fs::write(&xml_path, "<<not xml").await.unwrap();
let status = verify_roundtrip(xml_path.to_str().unwrap(), VerifyOptions::default())
.await
.unwrap();
assert_eq!(
status,
RoundtripStatus::Drift("missing in round-trip output".to_string())
);
}
#[tokio::test]
async fn verify_roundtrip_handles_dotted_meta_filename() {
let status = verify_roundtrip(
"fixtures/xml/general/HR_Admin.permissionset-meta.xml",
VerifyOptions {
unique_id_elements: Some(
"application,apexClass,name,externalDataSource,flow,object,apexPage,recordType,tab,field",
),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(status, RoundtripStatus::Identical);
}
}