use regex::Regex;
use serde_json::Value;
use similar::{ChangeTag, TextDiff};
pub fn remove_ids(value: &Value) -> Value {
match value {
Value::Array(arr) => Value::Array(arr.iter().map(remove_ids).collect()),
Value::Object(obj) => {
let mut new_obj = serde_json::Map::new();
for (key, val) in obj.iter() {
if key != "id" && key != "element_id" {
new_obj.insert(key.clone(), remove_ids(val));
}
}
Value::Object(new_obj)
}
_ => value.clone(),
}
}
pub fn preprocess_tree(json_string: &str) -> Result<String, String> {
let tree: Value = serde_json::from_str(json_string)
.map_err(|e| format!("Failed to parse UI tree JSON: {e}"))?;
let cleaned_tree = remove_ids(&tree);
serde_json::to_string_pretty(&cleaned_tree)
.map_err(|e| format!("Failed to serialize cleaned tree: {e}"))
}
pub fn remove_ids_and_bounds_from_compact_yaml(yaml_str: &str) -> String {
let id_re = Regex::new(r" #[\w\-]+").unwrap();
let result = id_re.replace_all(yaml_str, "");
let bounds_re = Regex::new(r"bounds: \[[^\]]+\],?\s*").unwrap();
bounds_re.replace_all(&result, "").to_string()
}
pub fn simple_ui_tree_diff(
old_tree_str: &str,
new_tree_str: &str,
) -> Result<Option<String>, String> {
let is_yaml = old_tree_str.trim_start().starts_with("- [");
let (old_processed, new_processed) = if is_yaml {
(
remove_ids_and_bounds_from_compact_yaml(old_tree_str),
remove_ids_and_bounds_from_compact_yaml(new_tree_str),
)
} else {
(
preprocess_tree(old_tree_str)?,
preprocess_tree(new_tree_str)?,
)
};
let diff = TextDiff::from_lines(&old_processed, &new_processed);
let mut changed_lines = Vec::new();
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Delete => {
changed_lines.push(format!("- {}", change.value().trim_end()));
}
ChangeTag::Insert => {
changed_lines.push(format!("+ {}", change.value().trim_end()));
}
ChangeTag::Equal => {
}
}
}
if changed_lines.is_empty() {
Ok(None)
} else {
Ok(Some(changed_lines.join("\n")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_remove_ids() {
let input = json!({
"role": "Button",
"name": "Submit",
"id": "12345",
"element_id": "abc-def",
"children": [
{
"role": "Text",
"id": "67890",
"value": "Click me"
}
]
});
let expected = json!({
"role": "Button",
"name": "Submit",
"children": [
{
"role": "Text",
"value": "Click me"
}
]
});
assert_eq!(remove_ids(&input), expected);
}
#[test]
fn test_preprocess_tree() {
let input = r#"{"role":"Button","id":"123","name":"Test"}"#;
let result = preprocess_tree(input).unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert!(parsed.get("id").is_none());
assert_eq!(parsed.get("role").unwrap(), "Button");
assert_eq!(parsed.get("name").unwrap(), "Test");
}
#[test]
fn test_simple_ui_tree_diff_no_changes() {
let tree1 = r#"{"role":"Window","name":"Test","id":"123"}"#;
let tree2 = r#"{"role":"Window","name":"Test","id":"456"}"#;
let diff = simple_ui_tree_diff(tree1, tree2).unwrap();
assert!(diff.is_none()); }
#[test]
fn test_simple_ui_tree_diff_with_changes() {
let tree1 = r#"{"role":"Window","name":"Test1","id":"123"}"#;
let tree2 = r#"{"role":"Window","name":"Test2","id":"456"}"#;
let diff = simple_ui_tree_diff(tree1, tree2).unwrap();
assert!(diff.is_some());
let diff_text = diff.unwrap();
assert!(diff_text.contains("Test1"));
assert!(diff_text.contains("Test2"));
}
#[test]
fn test_remove_ids_and_bounds_from_compact_yaml() {
let input =
"- [Button] Submit #id123 (bounds: [10,20,100,30], focusable)\n - [Text] Label #id456";
let expected = "- [Button] Submit (focusable)\n - [Text] Label";
let result = remove_ids_and_bounds_from_compact_yaml(input);
assert_eq!(result, expected);
}
#[test]
fn test_remove_bounds_only() {
let input = "- [Group] Comment (bounds: [26,472,617,367], focusable)";
let expected = "- [Group] Comment (focusable)";
let result = remove_ids_and_bounds_from_compact_yaml(input);
assert_eq!(result, expected);
}
#[test]
fn test_simple_ui_tree_diff_yaml_no_changes() {
let tree1 = "- [Window] Test #id123\n - [Button] Click #id456";
let tree2 = "- [Window] Test #id789\n - [Button] Click #id000";
let diff = simple_ui_tree_diff(tree1, tree2).unwrap();
assert!(diff.is_none()); }
#[test]
fn test_simple_ui_tree_diff_yaml_bounds_change_no_diff() {
let tree1 = "- [Group] Comment from flappy-goose (bounds: [26,472,617,367], focusable)\n - [Button] Reply (bounds: [128,961,82,34], focusable)";
let tree2 = "- [Group] Comment from flappy-goose (bounds: [26,472,617,485], focusable)\n - [Button] Reply (bounds: [128,1079,82,34], focusable)";
let diff = simple_ui_tree_diff(tree1, tree2).unwrap();
assert!(
diff.is_none(),
"Bounds-only changes should not produce a diff"
);
}
#[test]
fn test_simple_ui_tree_diff_yaml_with_changes() {
let tree1 = "- [Window] Test1 #id123\n - [Button] Click #id456";
let tree2 = "- [Window] Test2 #id789\n - [Button] Submit #id000";
let diff = simple_ui_tree_diff(tree1, tree2).unwrap();
assert!(diff.is_some());
let diff_text = diff.unwrap();
assert!(diff_text.contains("Test1"));
assert!(diff_text.contains("Test2"));
assert!(diff_text.contains("Click"));
assert!(diff_text.contains("Submit"));
}
}