1use crate::diff::{compute_text_diff, opt_str_eq, tags_eq_unordered, DiffOp, TextDiffSummary};
14use crate::resource::ContentBlock;
15use std::collections::BTreeMap;
16
17pub type ContentBlockIdIndex = BTreeMap<String, String>;
21
22#[derive(Debug, Clone)]
23pub struct ContentBlockDiff {
24 pub name: String,
25 pub op: DiffOp<ContentBlock>,
26 pub text_diff: Option<TextDiffSummary>,
27 pub orphan: bool,
29}
30
31impl ContentBlockDiff {
32 pub fn has_changes(&self) -> bool {
33 self.op.is_change() || self.orphan
34 }
35
36 pub fn is_orphan(&self) -> bool {
37 self.orphan
38 }
39
40 pub fn orphan(name: impl Into<String>) -> Self {
44 Self {
45 name: name.into(),
46 op: DiffOp::Unchanged,
47 text_diff: None,
48 orphan: true,
49 }
50 }
51}
52
53pub fn diff(
56 local: Option<&ContentBlock>,
57 remote: Option<&ContentBlock>,
58) -> Option<ContentBlockDiff> {
59 match (local, remote) {
60 (None, None) => None,
61 (Some(l), None) => Some(ContentBlockDiff {
62 name: l.name.clone(),
63 op: DiffOp::Added(l.clone()),
64 text_diff: None,
65 orphan: false,
66 }),
67 (None, Some(r)) => Some(ContentBlockDiff::orphan(&r.name)),
68 (Some(l), Some(r)) => {
69 if syncable_eq(l, r) {
70 Some(ContentBlockDiff {
71 name: l.name.clone(),
72 op: DiffOp::Unchanged,
73 text_diff: None,
74 orphan: false,
75 })
76 } else {
77 let text_diff = if l.content != r.content {
78 Some(compute_text_diff(&r.content, &l.content))
79 } else {
80 None
81 };
82 Some(ContentBlockDiff {
83 name: l.name.clone(),
84 op: DiffOp::Modified {
85 from: r.clone(),
86 to: l.clone(),
87 },
88 text_diff,
89 orphan: false,
90 })
91 }
92 }
93 }
94}
95
96fn syncable_eq(a: &ContentBlock, b: &ContentBlock) -> bool {
104 a.name == b.name
105 && opt_str_eq(&a.description, &b.description)
106 && a.content == b.content
107 && tags_eq_unordered(&a.tags, &b.tags)
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use crate::resource::ContentBlockState;
114
115 fn cb(name: &str, body: &str) -> ContentBlock {
116 ContentBlock {
117 name: name.into(),
118 description: Some(format!("{name} desc")),
119 content: body.into(),
120 tags: vec!["tag".into()],
121 state: ContentBlockState::Active,
122 }
123 }
124
125 #[test]
126 fn both_absent_returns_none() {
127 assert!(diff(None, None).is_none());
128 }
129
130 #[test]
131 fn local_only_is_added() {
132 let l = cb("promo", "Hello");
133 let d = diff(Some(&l), None).unwrap();
134 assert!(matches!(d.op, DiffOp::Added(_)));
135 assert!(!d.orphan);
136 assert!(d.has_changes());
137 }
138
139 #[test]
140 fn remote_only_is_orphan_not_removed() {
141 let r = cb("legacy", "old body");
142 let d = diff(None, Some(&r)).unwrap();
143 assert!(matches!(d.op, DiffOp::Unchanged));
144 assert!(d.orphan);
145 assert!(d.is_orphan());
146 assert!(d.has_changes());
147 assert!(d.text_diff.is_none());
148 }
149
150 #[test]
151 fn equal_blocks_are_unchanged() {
152 let l = cb("same", "body\n");
153 let r = cb("same", "body\n");
154 let d = diff(Some(&l), Some(&r)).unwrap();
155 assert!(matches!(d.op, DiffOp::Unchanged));
156 assert!(!d.orphan);
157 assert!(!d.has_changes());
158 assert!(d.text_diff.is_none());
159 }
160
161 #[test]
162 fn body_difference_is_modified_with_text_diff() {
163 let l = cb("body_drift", "line a\nline b\nline c\n");
164 let r = cb("body_drift", "line a\nold b\nline c\n");
165 let d = diff(Some(&l), Some(&r)).unwrap();
166 assert!(matches!(d.op, DiffOp::Modified { .. }));
167 let td = d.text_diff.expect("text diff present for body changes");
168 assert_eq!(td.additions, 1);
169 assert_eq!(td.deletions, 1);
170 }
171
172 #[test]
173 fn description_only_change_is_modified_without_text_diff() {
174 let mut l = cb("desc_drift", "same body\n");
175 let mut r = cb("desc_drift", "same body\n");
176 l.description = Some("new".into());
177 r.description = Some("old".into());
178 let d = diff(Some(&l), Some(&r)).unwrap();
179 assert!(matches!(d.op, DiffOp::Modified { .. }));
180 assert!(d.text_diff.is_none());
182 }
183
184 #[test]
185 fn tags_change_is_modified_without_text_diff() {
186 let mut l = cb("tag_drift", "body\n");
187 let mut r = cb("tag_drift", "body\n");
188 l.tags = vec!["a".into(), "b".into()];
189 r.tags = vec!["a".into()];
190 let d = diff(Some(&l), Some(&r)).unwrap();
191 assert!(matches!(d.op, DiffOp::Modified { .. }));
192 assert!(d.text_diff.is_none());
193 }
194
195 #[test]
196 fn tag_reorder_alone_is_not_drift() {
197 let mut l = cb("tag_reorder", "body\n");
202 let mut r = cb("tag_reorder", "body\n");
203 l.tags = vec!["alpha".into(), "beta".into(), "gamma".into()];
204 r.tags = vec!["gamma".into(), "alpha".into(), "beta".into()];
205 let d = diff(Some(&l), Some(&r)).unwrap();
206 assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
207 assert!(!d.has_changes());
208 }
209
210 #[test]
211 fn tag_multiset_difference_with_same_length_is_drift() {
212 let mut l = cb("tag_set", "body\n");
215 let mut r = cb("tag_set", "body\n");
216 l.tags = vec!["a".into(), "b".into()];
217 r.tags = vec!["a".into(), "c".into()];
218 let d = diff(Some(&l), Some(&r)).unwrap();
219 assert!(matches!(d.op, DiffOp::Modified { .. }));
220 }
221
222 #[test]
223 fn state_difference_alone_is_not_drift() {
224 let mut l = cb("state", "body\n");
225 let r = cb("state", "body\n");
226 l.state = ContentBlockState::Draft;
227 let d = diff(Some(&l), Some(&r)).unwrap();
228 assert!(matches!(d.op, DiffOp::Unchanged));
229 assert!(!d.has_changes());
230 }
231
232 #[test]
233 fn empty_local_description_equals_missing_remote_description() {
234 let mut l = cb("desc_empty_local", "body\n");
241 let mut r = cb("desc_empty_local", "body\n");
242 l.description = Some(String::new());
243 r.description = None;
244 let d = diff(Some(&l), Some(&r)).unwrap();
245 assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
246 assert!(!d.has_changes());
247 }
248
249 #[test]
250 fn missing_local_description_equals_empty_remote_description() {
251 let mut l = cb("desc_empty_remote", "body\n");
256 let mut r = cb("desc_empty_remote", "body\n");
257 l.description = None;
258 r.description = Some(String::new());
259 let d = diff(Some(&l), Some(&r)).unwrap();
260 assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
261 assert!(!d.has_changes());
262 }
263
264 #[test]
265 fn real_description_difference_is_still_modified() {
266 let mut l = cb("desc_real", "body\n");
271 let mut r = cb("desc_real", "body\n");
272 l.description = Some("new".into());
273 r.description = Some("old".into());
274 let d = diff(Some(&l), Some(&r)).unwrap();
275 assert!(matches!(d.op, DiffOp::Modified { .. }));
276 }
277
278 #[test]
279 fn destructive_count_is_never_set_on_content_blocks() {
280 let r = cb("ghost", "x");
281 let orphan = diff(None, Some(&r)).unwrap();
282 assert!(!orphan.op.is_destructive());
283
284 let l2 = cb("changed", "new");
285 let r2 = cb("changed", "old");
286 let modified = diff(Some(&l2), Some(&r2)).unwrap();
287 assert!(!modified.op.is_destructive());
288 }
289}