seam-skeleton 0.5.38

HTML skeleton extraction pipeline for SeamJS CLI
Documentation
/* src/cli/skeleton/src/ctr_check/diff.rs */

// Positional tree diff for CTR equivalence checking.
// Both sides should have identical structure (CTR guarantees this),
// so we walk child lists by index rather than using LCS alignment.

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,
}

/// Diff two CtrNode trees. Returns at most MAX_DIFFS detailed diffs,
/// plus a total_count of all differences found.
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());

	// Build tag frequency maps for nth-child disambiguation
	let expected_tags = tag_counts(expected);
	let _actual_tags = tag_counts(actual);

	for i in 0..min_len {
		if diffs.len() >= MAX_DIFFS {
			// Continue counting but stop collecting
		}
		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",
					});
				}
			}
		}
	}

	// Extra nodes in expected (missing from actual)
	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 });
		}
	}

	// Extra nodes in actual (not in expected)
	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,
) {
	// Missing or mismatched attrs
	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(),
					});
				}
			}
			_ => {}
		}
	}

	// Extra attrs
	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(),
				});
			}
		}
	}
}

/// Count how many times each tag appears at the same level.
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
}

/// Build a CSS-selector-style path for an element.
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();

	// Add first class token for readability
	if let Some(class) = attrs.get("class")
		&& let Some(first_class) = class.split_whitespace().next()
	{
		selector.push('.');
		selector.push_str(first_class);
	}

	// Add :nth-child(N) when there are multiple siblings with same tag
	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() {
		// BTreeMap auto-sorts, so insertion order doesn't matter
		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() {
		// Create trees with 8 text mismatches
		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);
	}
}