use std::collections::BTreeMap;
use super::parse::CtrNode;
const MAX_DIFFS: usize = 5;
#[derive(Debug)]
pub(super) enum CtrDiff {
TagMismatch { path: String, expected: String, actual: String },
AttrMissing { path: String, attr: String, expected_value: String },
AttrExtra { path: String, attr: String, actual_value: String },
AttrValueMismatch { path: String, attr: String, expected: String, actual: String },
TextMismatch { path: String, expected: String, actual: String },
TypeMismatch { path: String, expected_kind: &'static str, actual_kind: &'static str },
NodeMissing { path: String, expected_tag: String },
NodeExtra { path: String, actual_tag: String },
}
pub(super) struct DiffResult {
pub diffs: Vec<CtrDiff>,
pub total_count: usize,
}
pub(super) fn diff_trees(
expected: &[CtrNode],
actual: &[CtrNode],
parent_path: &str,
) -> DiffResult {
let mut diffs = Vec::new();
let mut total = 0;
diff_children(expected, actual, parent_path, &mut diffs, &mut total);
DiffResult { diffs, total_count: total }
}
fn diff_children(
expected: &[CtrNode],
actual: &[CtrNode],
parent_path: &str,
diffs: &mut Vec<CtrDiff>,
total: &mut usize,
) {
let min_len = expected.len().min(actual.len());
let expected_tags = tag_counts(expected);
let _actual_tags = tag_counts(actual);
for i in 0..min_len {
if diffs.len() >= MAX_DIFFS {
}
match (&expected[i], &actual[i]) {
(
CtrNode::Element { tag: et, attrs: ea, children: ec },
CtrNode::Element { tag: at, attrs: aa, children: ac },
) => {
if et != at {
*total += 1;
if diffs.len() < MAX_DIFFS {
let path = build_element_path(parent_path, et, ea, i, &expected_tags);
diffs.push(CtrDiff::TagMismatch { path, expected: et.clone(), actual: at.clone() });
}
continue;
}
let path = build_element_path(parent_path, et, ea, i, &expected_tags);
diff_attrs(ea, aa, &path, diffs, total);
diff_children(ec, ac, &path, diffs, total);
}
(CtrNode::Text(et), CtrNode::Text(at)) => {
if et != at {
*total += 1;
if diffs.len() < MAX_DIFFS {
diffs.push(CtrDiff::TextMismatch {
path: format_text_path(parent_path),
expected: et.clone(),
actual: at.clone(),
});
}
}
}
(CtrNode::Element { .. }, CtrNode::Text(_)) => {
*total += 1;
if diffs.len() < MAX_DIFFS {
diffs.push(CtrDiff::TypeMismatch {
path: format!("{parent_path} > [child {i}]"),
expected_kind: "Element",
actual_kind: "Text",
});
}
}
(CtrNode::Text(_), CtrNode::Element { .. }) => {
*total += 1;
if diffs.len() < MAX_DIFFS {
diffs.push(CtrDiff::TypeMismatch {
path: format!("{parent_path} > [child {i}]"),
expected_kind: "Text",
actual_kind: "Element",
});
}
}
}
}
for node in expected.iter().skip(min_len) {
*total += 1;
if diffs.len() < MAX_DIFFS {
let tag = match node {
CtrNode::Element { tag, .. } => tag.clone(),
CtrNode::Text(t) => format!("text(\"{}\")", truncate(t, 20)),
};
diffs.push(CtrDiff::NodeMissing { path: parent_path.to_string(), expected_tag: tag });
}
}
for node in actual.iter().skip(min_len) {
*total += 1;
if diffs.len() < MAX_DIFFS {
let tag = match node {
CtrNode::Element { tag, .. } => tag.clone(),
CtrNode::Text(t) => format!("text(\"{}\")", truncate(t, 20)),
};
diffs.push(CtrDiff::NodeExtra { path: parent_path.to_string(), actual_tag: tag });
}
}
}
fn diff_attrs(
expected: &BTreeMap<String, String>,
actual: &BTreeMap<String, String>,
path: &str,
diffs: &mut Vec<CtrDiff>,
total: &mut usize,
) {
for (key, eval) in expected {
match actual.get(key) {
None => {
*total += 1;
if diffs.len() < MAX_DIFFS {
diffs.push(CtrDiff::AttrMissing {
path: path.to_string(),
attr: key.clone(),
expected_value: eval.clone(),
});
}
}
Some(aval) if aval != eval => {
*total += 1;
if diffs.len() < MAX_DIFFS {
diffs.push(CtrDiff::AttrValueMismatch {
path: path.to_string(),
attr: key.clone(),
expected: eval.clone(),
actual: aval.clone(),
});
}
}
_ => {}
}
}
for (key, aval) in actual {
if !expected.contains_key(key) {
*total += 1;
if diffs.len() < MAX_DIFFS {
diffs.push(CtrDiff::AttrExtra {
path: path.to_string(),
attr: key.clone(),
actual_value: aval.clone(),
});
}
}
}
}
fn tag_counts(nodes: &[CtrNode]) -> BTreeMap<String, usize> {
let mut counts = BTreeMap::new();
for node in nodes {
if let CtrNode::Element { tag, .. } = node {
*counts.entry(tag.clone()).or_insert(0) += 1;
}
}
counts
}
fn build_element_path(
parent_path: &str,
tag: &str,
attrs: &BTreeMap<String, String>,
index: usize,
tag_counts: &BTreeMap<String, usize>,
) -> String {
let mut selector = tag.to_string();
if let Some(class) = attrs.get("class")
&& let Some(first_class) = class.split_whitespace().next()
{
selector.push('.');
selector.push_str(first_class);
}
if tag_counts.get(tag).copied().unwrap_or(0) > 1 {
selector.push_str(&format!(":nth-child({})", index + 1));
}
if parent_path.is_empty() { selector } else { format!("{parent_path} > {selector}") }
}
fn format_text_path(parent_path: &str) -> String {
if parent_path.is_empty() { "[text]".to_string() } else { format!("{parent_path} > [text]") }
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { s.to_string() } else { format!("{}...", &s[..s.floor_char_boundary(max)]) }
}
#[cfg(test)]
mod tests {
use super::super::parse::CtrNode;
use super::*;
fn elem(tag: &str, attrs: Vec<(&str, &str)>, children: Vec<CtrNode>) -> CtrNode {
let map: BTreeMap<String, String> =
attrs.into_iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
CtrNode::Element { tag: tag.to_string(), attrs: map, children }
}
fn text(s: &str) -> CtrNode {
CtrNode::Text(s.to_string())
}
#[test]
fn identical_trees_no_diffs() {
let a = vec![elem("div", vec![("class", "red")], vec![text("hello")])];
let b = vec![elem("div", vec![("class", "red")], vec![text("hello")])];
let result = diff_trees(&a, &b, "");
assert!(result.diffs.is_empty());
assert_eq!(result.total_count, 0);
}
#[test]
fn attr_order_no_diffs() {
let a = vec![elem("img", vec![("alt", "x"), ("src", "y")], vec![])];
let b = vec![elem("img", vec![("src", "y"), ("alt", "x")], vec![])];
let result = diff_trees(&a, &b, "");
assert!(result.diffs.is_empty());
}
#[test]
fn text_mismatch_detected() {
let a = vec![elem("p", vec![], vec![text("hello")])];
let b = vec![elem("p", vec![], vec![text("world")])];
let result = diff_trees(&a, &b, "");
assert_eq!(result.total_count, 1);
assert!(matches!(&result.diffs[0], CtrDiff::TextMismatch { expected, actual, .. }
if expected == "hello" && actual == "world"));
}
#[test]
fn attr_value_mismatch_detected() {
let a = vec![elem("span", vec![("style", "color:red")], vec![])];
let b = vec![elem("span", vec![("style", "color:blue")], vec![])];
let result = diff_trees(&a, &b, "");
assert_eq!(result.total_count, 1);
assert!(matches!(&result.diffs[0], CtrDiff::AttrValueMismatch { attr, .. } if attr == "style"));
}
#[test]
fn attr_missing_detected() {
let a = vec![elem("img", vec![("alt", "photo"), ("src", "x")], vec![])];
let b = vec![elem("img", vec![("src", "x")], vec![])];
let result = diff_trees(&a, &b, "");
assert_eq!(result.total_count, 1);
assert!(matches!(&result.diffs[0], CtrDiff::AttrMissing { attr, .. } if attr == "alt"));
}
#[test]
fn tag_mismatch_detected() {
let a = vec![elem("div", vec![], vec![])];
let b = vec![elem("span", vec![], vec![])];
let result = diff_trees(&a, &b, "");
assert_eq!(result.total_count, 1);
assert!(matches!(&result.diffs[0], CtrDiff::TagMismatch { expected, actual, .. }
if expected == "div" && actual == "span"));
}
#[test]
fn node_missing_detected() {
let a = vec![elem("div", vec![], vec![]), elem("span", vec![], vec![])];
let b = vec![elem("div", vec![], vec![])];
let result = diff_trees(&a, &b, "");
assert_eq!(result.total_count, 1);
assert!(
matches!(&result.diffs[0], CtrDiff::NodeMissing { expected_tag, .. } if expected_tag == "span")
);
}
#[test]
fn nested_diff_path() {
let a = vec![elem(
"div",
vec![("class", "grid")],
vec![elem("a", vec![], vec![elem("span", vec![], vec![text("hello")])])],
)];
let b = vec![elem(
"div",
vec![("class", "grid")],
vec![elem("a", vec![], vec![elem("span", vec![], vec![text("world")])])],
)];
let result = diff_trees(&a, &b, "");
assert_eq!(result.total_count, 1);
match &result.diffs[0] {
CtrDiff::TextMismatch { path, .. } => {
assert!(path.contains("div.grid"), "path should contain div.grid: {path}");
assert!(path.contains("span"), "path should contain span: {path}");
}
other => panic!("expected TextMismatch, got: {other:?}"),
}
}
#[test]
fn diff_caps_at_five() {
let make = |prefix: &str| -> Vec<CtrNode> {
(0..8).map(|i| elem("p", vec![], vec![text(&format!("{prefix}{i}"))])).collect()
};
let a = make("a");
let b = make("b");
let result = diff_trees(&a, &b, "");
assert_eq!(result.diffs.len(), MAX_DIFFS);
assert_eq!(result.total_count, 8);
}
}