use crate::diff::{compute_text_diff, opt_str_eq, tags_eq_unordered, DiffOp, TextDiffSummary};
use crate::resource::ContentBlock;
use std::collections::BTreeMap;
pub type ContentBlockIdIndex = BTreeMap<String, String>;
#[derive(Debug, Clone)]
pub struct ContentBlockDiff {
pub name: String,
pub op: DiffOp<ContentBlock>,
pub text_diff: Option<TextDiffSummary>,
pub orphan: bool,
}
impl ContentBlockDiff {
pub fn has_changes(&self) -> bool {
self.op.is_change() || self.orphan
}
pub fn is_orphan(&self) -> bool {
self.orphan
}
pub fn orphan(name: impl Into<String>) -> Self {
Self {
name: name.into(),
op: DiffOp::Unchanged,
text_diff: None,
orphan: true,
}
}
}
pub fn diff(
local: Option<&ContentBlock>,
remote: Option<&ContentBlock>,
) -> Option<ContentBlockDiff> {
match (local, remote) {
(None, None) => None,
(Some(l), None) => Some(ContentBlockDiff {
name: l.name.clone(),
op: DiffOp::Added(l.clone()),
text_diff: None,
orphan: false,
}),
(None, Some(r)) => Some(ContentBlockDiff::orphan(&r.name)),
(Some(l), Some(r)) => {
if syncable_eq(l, r) {
Some(ContentBlockDiff {
name: l.name.clone(),
op: DiffOp::Unchanged,
text_diff: None,
orphan: false,
})
} else {
let text_diff = if l.content != r.content {
Some(compute_text_diff(&r.content, &l.content))
} else {
None
};
Some(ContentBlockDiff {
name: l.name.clone(),
op: DiffOp::Modified {
from: r.clone(),
to: l.clone(),
},
text_diff,
orphan: false,
})
}
}
}
}
fn syncable_eq(a: &ContentBlock, b: &ContentBlock) -> bool {
a.name == b.name
&& opt_str_eq(&a.description, &b.description)
&& a.content == b.content
&& tags_eq_unordered(&a.tags, &b.tags)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resource::ContentBlockState;
fn cb(name: &str, body: &str) -> ContentBlock {
ContentBlock {
name: name.into(),
description: Some(format!("{name} desc")),
content: body.into(),
tags: vec!["tag".into()],
state: ContentBlockState::Active,
}
}
#[test]
fn both_absent_returns_none() {
assert!(diff(None, None).is_none());
}
#[test]
fn local_only_is_added() {
let l = cb("promo", "Hello");
let d = diff(Some(&l), None).unwrap();
assert!(matches!(d.op, DiffOp::Added(_)));
assert!(!d.orphan);
assert!(d.has_changes());
}
#[test]
fn remote_only_is_orphan_not_removed() {
let r = cb("legacy", "old body");
let d = diff(None, Some(&r)).unwrap();
assert!(matches!(d.op, DiffOp::Unchanged));
assert!(d.orphan);
assert!(d.is_orphan());
assert!(d.has_changes());
assert!(d.text_diff.is_none());
}
#[test]
fn equal_blocks_are_unchanged() {
let l = cb("same", "body\n");
let r = cb("same", "body\n");
let d = diff(Some(&l), Some(&r)).unwrap();
assert!(matches!(d.op, DiffOp::Unchanged));
assert!(!d.orphan);
assert!(!d.has_changes());
assert!(d.text_diff.is_none());
}
#[test]
fn body_difference_is_modified_with_text_diff() {
let l = cb("body_drift", "line a\nline b\nline c\n");
let r = cb("body_drift", "line a\nold b\nline c\n");
let d = diff(Some(&l), Some(&r)).unwrap();
assert!(matches!(d.op, DiffOp::Modified { .. }));
let td = d.text_diff.expect("text diff present for body changes");
assert_eq!(td.additions, 1);
assert_eq!(td.deletions, 1);
}
#[test]
fn description_only_change_is_modified_without_text_diff() {
let mut l = cb("desc_drift", "same body\n");
let mut r = cb("desc_drift", "same body\n");
l.description = Some("new".into());
r.description = Some("old".into());
let d = diff(Some(&l), Some(&r)).unwrap();
assert!(matches!(d.op, DiffOp::Modified { .. }));
assert!(d.text_diff.is_none());
}
#[test]
fn tags_change_is_modified_without_text_diff() {
let mut l = cb("tag_drift", "body\n");
let mut r = cb("tag_drift", "body\n");
l.tags = vec!["a".into(), "b".into()];
r.tags = vec!["a".into()];
let d = diff(Some(&l), Some(&r)).unwrap();
assert!(matches!(d.op, DiffOp::Modified { .. }));
assert!(d.text_diff.is_none());
}
#[test]
fn tag_reorder_alone_is_not_drift() {
let mut l = cb("tag_reorder", "body\n");
let mut r = cb("tag_reorder", "body\n");
l.tags = vec!["alpha".into(), "beta".into(), "gamma".into()];
r.tags = vec!["gamma".into(), "alpha".into(), "beta".into()];
let d = diff(Some(&l), Some(&r)).unwrap();
assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
assert!(!d.has_changes());
}
#[test]
fn tag_multiset_difference_with_same_length_is_drift() {
let mut l = cb("tag_set", "body\n");
let mut r = cb("tag_set", "body\n");
l.tags = vec!["a".into(), "b".into()];
r.tags = vec!["a".into(), "c".into()];
let d = diff(Some(&l), Some(&r)).unwrap();
assert!(matches!(d.op, DiffOp::Modified { .. }));
}
#[test]
fn state_difference_alone_is_not_drift() {
let mut l = cb("state", "body\n");
let r = cb("state", "body\n");
l.state = ContentBlockState::Draft;
let d = diff(Some(&l), Some(&r)).unwrap();
assert!(matches!(d.op, DiffOp::Unchanged));
assert!(!d.has_changes());
}
#[test]
fn empty_local_description_equals_missing_remote_description() {
let mut l = cb("desc_empty_local", "body\n");
let mut r = cb("desc_empty_local", "body\n");
l.description = Some(String::new());
r.description = None;
let d = diff(Some(&l), Some(&r)).unwrap();
assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
assert!(!d.has_changes());
}
#[test]
fn missing_local_description_equals_empty_remote_description() {
let mut l = cb("desc_empty_remote", "body\n");
let mut r = cb("desc_empty_remote", "body\n");
l.description = None;
r.description = Some(String::new());
let d = diff(Some(&l), Some(&r)).unwrap();
assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
assert!(!d.has_changes());
}
#[test]
fn real_description_difference_is_still_modified() {
let mut l = cb("desc_real", "body\n");
let mut r = cb("desc_real", "body\n");
l.description = Some("new".into());
r.description = Some("old".into());
let d = diff(Some(&l), Some(&r)).unwrap();
assert!(matches!(d.op, DiffOp::Modified { .. }));
}
#[test]
fn destructive_count_is_never_set_on_content_blocks() {
let r = cb("ghost", "x");
let orphan = diff(None, Some(&r)).unwrap();
assert!(!orphan.op.is_destructive());
let l2 = cb("changed", "new");
let r2 = cb("changed", "old");
let modified = diff(Some(&l2), Some(&r2)).unwrap();
assert!(!modified.op.is_destructive());
}
}