use serde_json::Value;
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use crate::xml::types::XmlElement;
const COMPOUND_VALUE_SEPARATOR: &str = "__";
const SANITIZED_REPLACEMENT: char = '_';
fn is_illegal_path_char(c: char) -> bool {
matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_ascii_control()
}
fn is_trailing_strip_char(c: char) -> bool {
matches!(c, '.' | ' ')
}
fn sanitize_path_segment(s: &str) -> Cow<'_, str> {
let trimmed = s.trim_end_matches(is_trailing_strip_char);
let needs_replacement = trimmed.chars().any(is_illegal_path_char);
let was_trimmed = trimmed.len() != s.len();
if !needs_replacement && !was_trimmed {
return Cow::Borrowed(s);
}
let mut out = String::with_capacity(trimmed.len());
for c in trimmed.chars() {
if is_illegal_path_char(c) {
out.push(SANITIZED_REPLACEMENT);
} else {
out.push(c);
}
}
if out.is_empty() {
out.push(SANITIZED_REPLACEMENT);
}
Cow::Owned(out)
}
fn create_short_hash(element: &XmlElement) -> String {
let stringified = serde_json::to_string(element).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(stringified.as_bytes());
let result = hasher.finalize();
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(8);
for b in result.iter().take(4) {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0xf) as usize] as char);
}
s
}
fn is_recursable_object(value: &Value) -> bool {
let Some(obj) = value.as_object() else {
return false;
};
obj.iter()
.any(|(k, _)| !k.starts_with('#') && !k.starts_with('@'))
}
fn value_as_string(value: &Value) -> Option<String> {
if let Some(s) = value.as_str() {
return Some(s.to_string());
}
value
.as_object()
.and_then(|obj| obj.get("#text"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn parse_candidates(spec: &str) -> Vec<Vec<&str>> {
spec.split(',')
.map(|candidate| {
candidate
.split('+')
.map(str::trim)
.filter(|f| !f.is_empty())
.collect::<Vec<&str>>()
})
.filter(|fields| !fields.is_empty())
.collect()
}
fn match_candidate_at_direct(element: &XmlElement, fields: &[&str]) -> Option<String> {
let obj = element.as_object()?;
let mut parts: Vec<String> = Vec::with_capacity(fields.len());
for field in fields {
let value = obj.get(*field).and_then(value_as_string)?;
if value.is_empty() {
return None;
}
parts.push(value);
}
if parts.is_empty() {
return None;
}
Some(parts.join(COMPOUND_VALUE_SEPARATOR))
}
fn find_id_in_subtree(element: &XmlElement, unique_id_elements: &str) -> Option<String> {
let candidates = parse_candidates(unique_id_elements);
if candidates.is_empty() {
return None;
}
for candidate in &candidates {
if let Some(id) = match_candidate_at_direct(element, candidate) {
return Some(id);
}
}
let obj = element.as_object()?;
for (_, child) in obj {
if !is_recursable_object(child) {
continue;
}
if let Some(found) = find_id_in_subtree(child, unique_id_elements) {
return Some(found);
}
}
None
}
pub fn parse_unique_id_element(element: &XmlElement, unique_id_elements: Option<&str>) -> String {
let raw = if let Some(ids) = unique_id_elements {
find_id_in_subtree(element, ids).unwrap_or_else(|| create_short_hash(element))
} else {
create_short_hash(element)
};
match sanitize_path_segment(&raw) {
Cow::Borrowed(_) => raw,
Cow::Owned(s) => s,
}
}
pub fn short_hash_for_element(element: &XmlElement) -> String {
create_short_hash(element)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn finds_direct_field() {
let el = json!({ "name": "Get_Info", "label": "Get Info" });
assert_eq!(parse_unique_id_element(&el, Some("name")), "Get_Info");
}
#[test]
fn finds_deeply_nested_field() {
let el = json!({
"value": { "elementReference": "accts.accounts" },
"connector": { "targetReference": "X" }
});
assert_eq!(
parse_unique_id_element(&el, Some("elementReference")),
"accts.accounts"
);
}
#[test]
fn finds_id_in_grandchild() {
let el = json!({
"wrapper": {
"inner": { "name": "NestedName" }
}
});
assert_eq!(parse_unique_id_element(&el, Some("name")), "NestedName");
}
#[test]
fn value_as_string_returns_none_for_non_string_non_text_objects() {
let el = json!({ "name": { "other": "xxx" } });
let id = parse_unique_id_element(&el, Some("name"));
assert_eq!(id.len(), 8);
}
#[test]
fn falls_back_to_hash_when_no_match_and_no_nested_object() {
let el = json!({ "a": "string", "b": "another" });
let id = parse_unique_id_element(&el, Some("name"));
assert_eq!(id.len(), 8);
}
#[test]
fn hash_fallback_when_unique_id_elements_is_none() {
let el = json!({ "a": "b" });
let id = parse_unique_id_element(&el, None);
assert_eq!(id.len(), 8);
}
#[test]
fn non_object_element_returns_hash() {
let el = json!("just-a-string");
let id = parse_unique_id_element(&el, Some("name"));
assert_eq!(id.len(), 8);
}
#[test]
fn finds_name_from_text_object() {
let el = json!({
"name": { "#text": "Get_Info" },
"label": { "#text": "Get Info" },
"actionName": { "#text": "GetFirstFromCollection" }
});
assert_eq!(parse_unique_id_element(&el, Some("name")), "Get_Info");
assert_eq!(
parse_unique_id_element(&el, Some("actionName")),
"GetFirstFromCollection"
);
}
#[test]
fn distinct_siblings_with_shared_first_text_leaf_get_distinct_hashes() {
let make_action_override = |i: u32| -> XmlElement {
json!({
"actionName": { "#text": "View" },
"comment": { "#text": format!("Action override {i}") },
"content": { "#text": format!("Sample_Page_{i:05}") },
"formFactor": { "#text": "Large" },
"skipRecordTypeSelect": { "#text": "false" },
"type": { "#text": "Flexipage" },
"pageOrSobjectType": { "#text": format!("Sample_Object_{i:03}__c") }
})
};
let ids = Some("fullName,name");
let mut seen = std::collections::HashSet::new();
for i in 1..=128 {
let id = parse_unique_id_element(&make_action_override(i), ids);
assert_eq!(id.len(), 8, "expected an 8-char short hash, got {id}");
assert!(
seen.insert(id.clone()),
"duplicate hash {id} for actionOverride {i} - distinct siblings collapsed"
);
}
}
#[test]
fn distinct_siblings_get_distinct_hashes_with_no_unique_id_config() {
let mut seen = std::collections::HashSet::new();
for i in 1..=64 {
let el = json!({
"actionName": { "#text": "View" },
"content": { "#text": format!("Page_{i}") }
});
let id = parse_unique_id_element(&el, None);
assert!(
seen.insert(id.clone()),
"duplicate hash {id} at index {i} with no unique-id config"
);
}
}
#[test]
fn text_leaf_wrappers_are_not_recursable() {
let leaf = json!({ "#text": "View" });
assert!(!is_recursable_object(&leaf));
let attrs_only = json!({ "@attr": "x", "#text": "y" });
assert!(!is_recursable_object(&attrs_only));
let real = json!({ "name": "x" });
assert!(is_recursable_object(&real));
let mixed = json!({ "@attr": "x", "name": "y" });
assert!(is_recursable_object(&mixed));
}
#[test]
fn compound_resolves_when_all_fields_present() {
let el = json!({
"actionName": { "#text": "Tab" },
"content": { "#text": "Home_Page_Default" },
"formFactor": { "#text": "Large" },
"pageOrSobjectType": { "#text": "standard-home" },
"type": { "#text": "Flexipage" },
"profile": { "#text": "Implementation_Lightning" }
});
let id =
parse_unique_id_element(&el, Some("actionName+pageOrSobjectType+formFactor+profile"));
assert_eq!(id, "Tab__standard-home__Large__Implementation_Lightning");
}
#[test]
fn compound_falls_through_when_one_field_missing() {
let el = json!({
"actionName": { "#text": "View" },
"content": { "#text": "LUX_Case_Release_Candidate_Copy" },
"formFactor": { "#text": "Large" },
"pageOrSobjectType": { "#text": "Case" },
"type": { "#text": "Flexipage" }
});
let spec = "actionName+pageOrSobjectType+formFactor+profile,actionName+pageOrSobjectType+formFactor,actionName";
assert_eq!(
parse_unique_id_element(&el, Some(spec)),
"View__Case__Large"
);
}
#[test]
fn compound_then_single_then_hash_fallback() {
let el = json!({
"actionName": { "#text": "View" }
});
let spec_all_compound =
"actionName+pageOrSobjectType+formFactor+profile,actionName+pageOrSobjectType";
let id = parse_unique_id_element(&el, Some(spec_all_compound));
assert_eq!(
id.len(),
8,
"no candidate should match → hash fallback, got {id}"
);
let spec_with_single_tail = "actionName+pageOrSobjectType+formFactor,actionName";
assert_eq!(
parse_unique_id_element(&el, Some(spec_with_single_tail)),
"View"
);
}
#[test]
fn compound_treats_empty_values_as_missing() {
let el = json!({
"actionName": { "#text": "View" },
"pageOrSobjectType": { "#text": "Account" },
"recordType": { "#text": "" } });
let spec = "actionName+pageOrSobjectType+recordType,actionName+pageOrSobjectType";
assert_eq!(
parse_unique_id_element(&el, Some(spec)),
"View__Account",
"empty <recordType> must be treated as missing"
);
}
#[test]
fn compound_disambiguates_siblings_that_share_outer_fields() {
let make = |profile: &str| {
json!({
"actionName": { "#text": "Tab" },
"content": { "#text": "Home_Page_Default" },
"formFactor": { "#text": "Large" },
"pageOrSobjectType": { "#text": "standard-home" },
"type": { "#text": "Flexipage" },
"profile": { "#text": profile }
})
};
let spec = "actionName+pageOrSobjectType+formFactor+profile";
let a = parse_unique_id_element(&make("Implementation_Lightning"), Some(spec));
let b = parse_unique_id_element(&make("Sales_Lightning"), Some(spec));
assert_ne!(a, b);
assert!(a.ends_with("Implementation_Lightning"));
assert!(b.ends_with("Sales_Lightning"));
}
#[test]
fn single_field_behaviour_is_unchanged() {
let el = json!({ "name": "Get_Info", "label": "Get Info" });
assert_eq!(parse_unique_id_element(&el, Some("name")), "Get_Info");
let nested = json!({
"wrapper": { "name": "NestedName" }
});
assert_eq!(parse_unique_id_element(&nested, Some("name")), "NestedName");
}
#[test]
fn malformed_spec_degrades_to_hash() {
let el = json!({ "foo": "bar" });
let id = parse_unique_id_element(&el, Some(",,+,, "));
assert_eq!(id.len(), 8, "all-empty candidates → hash fallback");
}
#[test]
fn sanitize_replaces_path_separators() {
assert_eq!(sanitize_path_segment("Foo/Bar"), "Foo_Bar");
assert_eq!(sanitize_path_segment("Foo\\Bar"), "Foo_Bar");
assert_eq!(
sanitize_path_segment("TrustFile Transaction Sync/Import Complete"),
"TrustFile Transaction Sync_Import Complete"
);
}
#[test]
fn sanitize_replaces_windows_reserved_chars() {
for c in [':', '*', '?', '"', '<', '>', '|'] {
let input = format!("a{c}b");
assert_eq!(sanitize_path_segment(&input), "a_b", "char={c}");
}
}
#[test]
fn sanitize_replaces_control_characters() {
assert_eq!(sanitize_path_segment("a\u{0}b"), "a_b");
assert_eq!(sanitize_path_segment("a\u{1f}b"), "a_b");
}
#[test]
fn sanitize_strips_trailing_dot_and_space() {
assert_eq!(sanitize_path_segment("Foo."), "Foo");
assert_eq!(sanitize_path_segment("Foo "), "Foo");
assert_eq!(sanitize_path_segment("Foo. ."), "Foo");
assert_eq!(sanitize_path_segment("Foo\t"), "Foo_");
}
#[test]
fn sanitize_passes_safe_inputs_through_unchanged() {
let cases = [
"Account",
"Account_Name__c",
"Sample_Object_005__c",
"Implementation - TrustFile Amazon",
"View",
"TrustFile Account Setup Complete",
"View__Account__Large__SalesProfile",
"Account.LogACall",
"Sample_Object_017__c.Sample_Record_Type_0123",
];
for case in cases {
match sanitize_path_segment(case) {
Cow::Borrowed(s) => assert_eq!(s, case, "unexpected mutation for {case:?}"),
Cow::Owned(s) => panic!("unexpected allocation for {case:?}: got {s:?}"),
}
}
}
#[test]
fn sanitize_replaces_illegal_chars_one_for_one() {
assert_eq!(sanitize_path_segment("///"), "___");
assert_eq!(sanitize_path_segment("/"), "_");
assert_eq!(sanitize_path_segment("a/b/c"), "a_b_c");
assert_eq!(sanitize_path_segment("a*b?c"), "a_b_c");
}
#[test]
fn sanitize_replacement_yields_underscore_when_input_collapses_to_empty() {
assert_eq!(sanitize_path_segment(". ."), "_");
assert_eq!(sanitize_path_segment(". "), "_");
assert_eq!(sanitize_path_segment("."), "_");
assert_eq!(sanitize_path_segment(" "), "_");
}
#[test]
fn sanitize_handles_empty_input() {
let out = sanitize_path_segment("");
assert!(matches!(out, Cow::Borrowed(s) if s.is_empty()));
}
#[test]
fn parse_unique_id_element_sanitizes_resolved_value() {
let el = json!({
"milestoneName": { "#text": "TrustFile Transaction Sync/Import Complete" }
});
let id = parse_unique_id_element(&el, Some("milestoneName"));
assert!(!id.contains('/'), "resolved id must not contain `/`: {id}");
assert_eq!(id, "TrustFile Transaction Sync_Import Complete");
}
#[test]
fn parse_unique_id_element_sanitizes_compound_values() {
let el = json!({
"actionName": { "#text": "View" },
"pageOrSobjectType": { "#text": "Sample/Object__c" },
"formFactor": { "#text": "Large" }
});
let id = parse_unique_id_element(&el, Some("actionName+pageOrSobjectType+formFactor"));
assert!(!id.contains('/'), "compound id must not contain `/`: {id}");
assert_eq!(id, "View__Sample_Object__c__Large");
}
#[test]
fn parse_unique_id_element_hash_fallback_is_unaffected_by_sanitizer() {
let el = json!({ "a": "b" });
let id = parse_unique_id_element(&el, Some("name"));
assert_eq!(id.len(), 8);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn nested_search_does_not_return_inner_hash() {
let a = json!({
"wrapper": { "leafA": "shared", "extraA": "different-A" },
"outerA": "A"
});
let b = json!({
"wrapper": { "leafA": "shared", "extraA": "different-A" },
"outerB": "B"
});
let id_a = parse_unique_id_element(&a, Some("name"));
let id_b = parse_unique_id_element(&b, Some("name"));
assert_ne!(id_a, id_b);
}
}