use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Block {
pub anchor: Option<String>,
pub text: String,
}
impl Block {
pub fn text(text: impl Into<String>) -> Self {
Self {
anchor: None,
text: text.into(),
}
}
pub fn anchored(anchor: impl Into<String>, text: impl Into<String>) -> Self {
Self {
anchor: Some(anchor.into()),
text: text.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockKey {
Anchored(String),
Content(u64),
}
impl BlockKey {
pub fn as_string(&self) -> String {
match self {
BlockKey::Anchored(a) => format!("a:{a}"),
BlockKey::Content(h) => format!("c:{h:016x}"),
}
}
}
fn normalize(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn content_hash(text: &str) -> u64 {
let mut h = DefaultHasher::new();
normalize(text).hash(&mut h);
h.finish()
}
pub fn block_key(b: &Block) -> BlockKey {
match &b.anchor {
Some(a) => BlockKey::Anchored(a.clone()),
None => BlockKey::Content(content_hash(&b.text)),
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Match {
Same { old: usize },
Edited { old: usize, similarity: f32 },
Inserted,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Alignment {
pub new_matches: Vec<Match>,
pub removed: Vec<usize>,
}
pub fn similarity(a: &str, b: &str) -> f32 {
let aw: Vec<&str> = a.split_whitespace().collect();
let bw: Vec<&str> = b.split_whitespace().collect();
if aw.is_empty() && bw.is_empty() {
return 1.0;
}
let lcs = lcs_len(&aw, &bw);
(2 * lcs) as f32 / (aw.len() + bw.len()) as f32
}
fn lcs_len(a: &[&str], b: &[&str]) -> usize {
let mut dp = vec![0usize; b.len() + 1];
for &x in a {
let mut prev = 0; for (j, &y) in b.iter().enumerate() {
let cur = dp[j + 1];
dp[j + 1] = if x == y {
prev + 1
} else {
dp[j + 1].max(dp[j])
};
prev = cur;
}
}
dp[b.len()]
}
pub const EDIT_THRESHOLD: f32 = 0.5;
pub fn align(old: &[Block], new: &[Block]) -> Alignment {
let old_keys: Vec<BlockKey> = old.iter().map(block_key).collect();
let new_keys: Vec<BlockKey> = new.iter().map(block_key).collect();
let mut old_used = vec![false; old.len()];
let mut new_matches: Vec<Option<Match>> = vec![None; new.len()];
for (ni, nk) in new_keys.iter().enumerate() {
if let Some(oi) = (0..old.len()).find(|&oi| !old_used[oi] && &old_keys[oi] == nk) {
old_used[oi] = true;
new_matches[ni] = Some(Match::Same { old: oi });
}
}
for (ni, slot) in new_matches.iter_mut().enumerate() {
if slot.is_some() {
continue;
}
let mut best: Option<(usize, f32)> = None;
for (oi, used) in old_used.iter().enumerate() {
if *used {
continue;
}
let sim = similarity(&new[ni].text, &old[oi].text);
let better = match best {
None => true,
Some((bi, bs)) => {
sim > bs
|| (sim == bs
&& (oi as isize - ni as isize).abs()
< (bi as isize - ni as isize).abs())
}
};
if better {
best = Some((oi, sim));
}
}
match best {
Some((oi, sim)) if sim >= EDIT_THRESHOLD => {
old_used[oi] = true;
*slot = Some(Match::Edited {
old: oi,
similarity: sim,
});
}
_ => *slot = Some(Match::Inserted),
}
}
let removed = (0..old.len()).filter(|&oi| !old_used[oi]).collect();
Alignment {
new_matches: new_matches.into_iter().map(|m| m.unwrap()).collect(),
removed,
}
}
pub fn assign_stable_keys(old: &[Block], new: &[Block]) -> Vec<String> {
let old_keys: Vec<String> = old.iter().map(|b| block_key(b).as_string()).collect();
let alignment = align(old, new);
alignment
.new_matches
.iter()
.enumerate()
.map(|(ni, m)| match m {
Match::Same { old } | Match::Edited { old, .. } => old_keys[*old].clone(),
Match::Inserted => block_key(&new[ni]).as_string(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_key_survives_reflow_but_not_edit() {
let a = Block::text("the quick brown fox");
let b = Block::text("the quick\n brown fox\n"); let c = Block::text("the quick red fox"); assert_eq!(block_key(&a), block_key(&b), "reflow keeps content key");
assert_ne!(block_key(&a), block_key(&c), "edit changes content key");
}
#[test]
fn anchored_key_survives_full_rewrite() {
let a = Block::anchored("item-1", "original body");
let b = Block::anchored("item-1", "completely different prose now");
assert_eq!(block_key(&a), block_key(&b), "anchor survives rewrite");
}
#[test]
fn pure_reorder_is_all_same_no_removed() {
let old = [
Block::text("alpha"),
Block::text("beta"),
Block::text("gamma"),
];
let new = [
Block::text("gamma"),
Block::text("alpha"),
Block::text("beta"),
];
let al = align(&old, &new);
assert!(
al.new_matches
.iter()
.all(|m| matches!(m, Match::Same { .. }))
);
assert!(al.removed.is_empty());
}
#[test]
fn small_edit_is_detected_as_edited_not_insert_remove() {
let old = [Block::text("the quick brown fox jumps over the lazy dog")];
let new = [Block::text("the quick brown fox jumps over the sleepy dog")];
let al = align(&old, &new);
match al.new_matches[0] {
Match::Edited { old: 0, similarity } => assert!(similarity > 0.5),
ref other => panic!("expected Edited, got {other:?}"),
}
assert!(al.removed.is_empty(), "edited block is not a removal");
}
#[test]
fn genuine_insert_and_remove() {
let old = [Block::text("keep me"), Block::text("delete me entirely")];
let new = [
Block::text("keep me"),
Block::text("brand new unrelated content here"),
];
let al = align(&old, &new);
assert!(matches!(al.new_matches[0], Match::Same { old: 0 }));
assert!(
matches!(al.new_matches[1], Match::Inserted),
"dissimilar block must be Inserted, got {:?}",
al.new_matches[1]
);
assert_eq!(al.removed, vec![1]);
}
#[test]
fn assign_stable_keys_flows_identity_through_edit() {
let old = [
Block::text("first paragraph stays the same"),
Block::text("second paragraph will be tweaked a little"),
];
let new = [
Block::text("second paragraph will be tweaked a bit"),
Block::text("first paragraph stays the same"),
];
let old_keys: Vec<String> = old.iter().map(|b| block_key(b).as_string()).collect();
let new_keys = assign_stable_keys(&old, &new);
assert_eq!(new_keys[0], old_keys[1], "edited block keeps its identity");
assert_eq!(new_keys[1], old_keys[0]);
}
#[test]
fn anchored_blocks_align_by_id_through_rewrite() {
let old = [
Block::anchored("h1", "Heading one body"),
Block::anchored("h2", "Heading two body"),
];
let new = [
Block::anchored("h2", "Heading two REWRITTEN body"),
Block::anchored("h1", "Heading one body"),
];
let al = align(&old, &new);
assert!(matches!(al.new_matches[0], Match::Same { old: 1 }));
assert!(matches!(al.new_matches[1], Match::Same { old: 0 }));
assert!(al.removed.is_empty());
}
}