1use crate::diff::DiffOp;
14use crate::resource::ContentBlock;
15use similar::{ChangeTag, TextDiff};
16use std::collections::BTreeMap;
17
18pub type ContentBlockIdIndex = BTreeMap<String, String>;
22
23#[derive(Debug, Clone)]
24pub struct ContentBlockDiff {
25 pub name: String,
26 pub op: DiffOp<ContentBlock>,
27 pub text_diff: Option<TextDiffSummary>,
28 pub orphan: bool,
30}
31
32#[derive(Debug, Clone)]
33pub struct TextDiffSummary {
34 pub additions: usize,
35 pub deletions: usize,
36}
37
38impl ContentBlockDiff {
39 pub fn has_changes(&self) -> bool {
40 self.op.is_change() || self.orphan
41 }
42
43 pub fn is_orphan(&self) -> bool {
44 self.orphan
45 }
46
47 pub fn orphan(name: impl Into<String>) -> Self {
51 Self {
52 name: name.into(),
53 op: DiffOp::Unchanged,
54 text_diff: None,
55 orphan: true,
56 }
57 }
58}
59
60pub fn diff(
63 local: Option<&ContentBlock>,
64 remote: Option<&ContentBlock>,
65) -> Option<ContentBlockDiff> {
66 match (local, remote) {
67 (None, None) => None,
68 (Some(l), None) => Some(ContentBlockDiff {
69 name: l.name.clone(),
70 op: DiffOp::Added(l.clone()),
71 text_diff: None,
72 orphan: false,
73 }),
74 (None, Some(r)) => Some(ContentBlockDiff::orphan(&r.name)),
75 (Some(l), Some(r)) => {
76 if syncable_eq(l, r) {
77 Some(ContentBlockDiff {
78 name: l.name.clone(),
79 op: DiffOp::Unchanged,
80 text_diff: None,
81 orphan: false,
82 })
83 } else {
84 let text_diff = if l.content != r.content {
85 Some(compute_text_diff(&r.content, &l.content))
86 } else {
87 None
88 };
89 Some(ContentBlockDiff {
90 name: l.name.clone(),
91 op: DiffOp::Modified {
92 from: r.clone(),
93 to: l.clone(),
94 },
95 text_diff,
96 orphan: false,
97 })
98 }
99 }
100 }
101}
102
103fn syncable_eq(a: &ContentBlock, b: &ContentBlock) -> bool {
111 a.name == b.name
112 && desc_eq(&a.description, &b.description)
113 && a.content == b.content
114 && tags_eq_unordered(&a.tags, &b.tags)
115}
116
117fn desc_eq(a: &Option<String>, b: &Option<String>) -> bool {
130 a.as_deref().unwrap_or("") == b.as_deref().unwrap_or("")
131}
132
133fn tags_eq_unordered(a: &[String], b: &[String]) -> bool {
138 if a.len() != b.len() {
139 return false;
140 }
141 let mut a: Vec<&str> = a.iter().map(String::as_str).collect();
142 let mut b: Vec<&str> = b.iter().map(String::as_str).collect();
143 a.sort_unstable();
144 b.sort_unstable();
145 a == b
146}
147
148fn compute_text_diff(from: &str, to: &str) -> TextDiffSummary {
149 let diff = TextDiff::from_lines(from, to);
150 let mut additions = 0;
151 let mut deletions = 0;
152 for change in diff.iter_all_changes() {
153 match change.tag() {
154 ChangeTag::Insert => additions += 1,
155 ChangeTag::Delete => deletions += 1,
156 ChangeTag::Equal => {}
157 }
158 }
159 TextDiffSummary {
160 additions,
161 deletions,
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use crate::resource::ContentBlockState;
169
170 fn cb(name: &str, body: &str) -> ContentBlock {
171 ContentBlock {
172 name: name.into(),
173 description: Some(format!("{name} desc")),
174 content: body.into(),
175 tags: vec!["tag".into()],
176 state: ContentBlockState::Active,
177 }
178 }
179
180 #[test]
181 fn both_absent_returns_none() {
182 assert!(diff(None, None).is_none());
183 }
184
185 #[test]
186 fn local_only_is_added() {
187 let l = cb("promo", "Hello");
188 let d = diff(Some(&l), None).unwrap();
189 assert!(matches!(d.op, DiffOp::Added(_)));
190 assert!(!d.orphan);
191 assert!(d.has_changes());
192 }
193
194 #[test]
195 fn remote_only_is_orphan_not_removed() {
196 let r = cb("legacy", "old body");
197 let d = diff(None, Some(&r)).unwrap();
198 assert!(matches!(d.op, DiffOp::Unchanged));
199 assert!(d.orphan);
200 assert!(d.is_orphan());
201 assert!(d.has_changes());
202 assert!(d.text_diff.is_none());
203 }
204
205 #[test]
206 fn equal_blocks_are_unchanged() {
207 let l = cb("same", "body\n");
208 let r = cb("same", "body\n");
209 let d = diff(Some(&l), Some(&r)).unwrap();
210 assert!(matches!(d.op, DiffOp::Unchanged));
211 assert!(!d.orphan);
212 assert!(!d.has_changes());
213 assert!(d.text_diff.is_none());
214 }
215
216 #[test]
217 fn body_difference_is_modified_with_text_diff() {
218 let l = cb("body_drift", "line a\nline b\nline c\n");
219 let r = cb("body_drift", "line a\nold b\nline c\n");
220 let d = diff(Some(&l), Some(&r)).unwrap();
221 assert!(matches!(d.op, DiffOp::Modified { .. }));
222 let td = d.text_diff.expect("text diff present for body changes");
223 assert_eq!(td.additions, 1);
224 assert_eq!(td.deletions, 1);
225 }
226
227 #[test]
228 fn description_only_change_is_modified_without_text_diff() {
229 let mut l = cb("desc_drift", "same body\n");
230 let mut r = cb("desc_drift", "same body\n");
231 l.description = Some("new".into());
232 r.description = Some("old".into());
233 let d = diff(Some(&l), Some(&r)).unwrap();
234 assert!(matches!(d.op, DiffOp::Modified { .. }));
235 assert!(d.text_diff.is_none());
237 }
238
239 #[test]
240 fn tags_change_is_modified_without_text_diff() {
241 let mut l = cb("tag_drift", "body\n");
242 let mut r = cb("tag_drift", "body\n");
243 l.tags = vec!["a".into(), "b".into()];
244 r.tags = vec!["a".into()];
245 let d = diff(Some(&l), Some(&r)).unwrap();
246 assert!(matches!(d.op, DiffOp::Modified { .. }));
247 assert!(d.text_diff.is_none());
248 }
249
250 #[test]
251 fn tag_reorder_alone_is_not_drift() {
252 let mut l = cb("tag_reorder", "body\n");
257 let mut r = cb("tag_reorder", "body\n");
258 l.tags = vec!["alpha".into(), "beta".into(), "gamma".into()];
259 r.tags = vec!["gamma".into(), "alpha".into(), "beta".into()];
260 let d = diff(Some(&l), Some(&r)).unwrap();
261 assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
262 assert!(!d.has_changes());
263 }
264
265 #[test]
266 fn tag_multiset_difference_with_same_length_is_drift() {
267 let mut l = cb("tag_set", "body\n");
270 let mut r = cb("tag_set", "body\n");
271 l.tags = vec!["a".into(), "b".into()];
272 r.tags = vec!["a".into(), "c".into()];
273 let d = diff(Some(&l), Some(&r)).unwrap();
274 assert!(matches!(d.op, DiffOp::Modified { .. }));
275 }
276
277 #[test]
278 fn state_difference_alone_is_not_drift() {
279 let mut l = cb("state", "body\n");
280 let r = cb("state", "body\n");
281 l.state = ContentBlockState::Draft;
282 let d = diff(Some(&l), Some(&r)).unwrap();
283 assert!(matches!(d.op, DiffOp::Unchanged));
284 assert!(!d.has_changes());
285 }
286
287 #[test]
288 fn empty_local_description_equals_missing_remote_description() {
289 let mut l = cb("desc_empty_local", "body\n");
296 let mut r = cb("desc_empty_local", "body\n");
297 l.description = Some(String::new());
298 r.description = None;
299 let d = diff(Some(&l), Some(&r)).unwrap();
300 assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
301 assert!(!d.has_changes());
302 }
303
304 #[test]
305 fn missing_local_description_equals_empty_remote_description() {
306 let mut l = cb("desc_empty_remote", "body\n");
311 let mut r = cb("desc_empty_remote", "body\n");
312 l.description = None;
313 r.description = Some(String::new());
314 let d = diff(Some(&l), Some(&r)).unwrap();
315 assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
316 assert!(!d.has_changes());
317 }
318
319 #[test]
320 fn real_description_difference_is_still_modified() {
321 let mut l = cb("desc_real", "body\n");
326 let mut r = cb("desc_real", "body\n");
327 l.description = Some("new".into());
328 r.description = Some("old".into());
329 let d = diff(Some(&l), Some(&r)).unwrap();
330 assert!(matches!(d.op, DiffOp::Modified { .. }));
331 }
332
333 #[test]
334 fn destructive_count_is_never_set_on_content_blocks() {
335 let r = cb("ghost", "x");
336 let orphan = diff(None, Some(&r)).unwrap();
337 assert!(!orphan.op.is_destructive());
338
339 let l2 = cb("changed", "new");
340 let r2 = cb("changed", "old");
341 let modified = diff(Some(&l2), Some(&r2)).unwrap();
342 assert!(!modified.op.is_destructive());
343 }
344}