1use mtplatx_doc_core::{Block, Document, Inline};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16#[serde(tag = "op", rename_all = "snake_case")]
17pub enum Operation {
18 Insert {
19 index: usize,
20 block: Block,
21 },
22 Delete {
23 index: usize,
24 },
25 Replace {
26 index: usize,
27 block: Block,
28 },
29 Move {
30 from: usize,
31 to: usize,
32 },
33 UpdateMetadata {
35 from: serde_json::Value,
36 to: serde_json::Value,
37 },
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
42pub struct Patch {
43 pub operations: Vec<Operation>,
44}
45
46impl Patch {
47 pub fn new() -> Self {
48 Self::default()
49 }
50 pub fn push(&mut self, op: Operation) {
51 self.operations.push(op);
52 }
53 pub fn len(&self) -> usize {
54 self.operations.len()
55 }
56 pub fn is_empty(&self) -> bool {
57 self.operations.is_empty()
58 }
59
60 pub fn edit_distance(&self) -> usize {
62 self.operations
63 .iter()
64 .filter(|op| {
65 !matches!(
66 op,
67 Operation::UpdateMetadata { .. } | Operation::Move { .. }
68 )
69 })
70 .count()
71 }
72}
73
74pub fn diff(old: &Document, new: &Document) -> Patch {
76 let mut patch = Patch::new();
77
78 if old.metadata != new.metadata {
79 let from = serde_json::to_value(&old.metadata).unwrap_or_default();
80 let to = serde_json::to_value(&new.metadata).unwrap_or_default();
81 if from != to {
82 patch.push(Operation::UpdateMetadata { from, to });
83 }
84 }
85
86 diff_blocks(&old.blocks, &new.blocks, &mut patch);
87 patch
88}
89
90fn diff_blocks(old: &[Block], new: &[Block], patch: &mut Patch) {
91 let n = old.len();
93 let m = new.len();
94 let mut table = vec![vec![0usize; m + 1]; n + 1];
95 for i in 0..n {
96 for j in 0..m {
97 table[i + 1][j + 1] = if old[i] == new[j] {
98 table[i][j] + 1
99 } else {
100 table[i + 1][j].max(table[i][j + 1])
101 };
102 }
103 }
104 let mut i = n;
108 let mut j = m;
109 let mut ops: Vec<Operation> = Vec::new();
110 while i > 0 && j > 0 {
111 if old[i - 1] == new[j - 1] {
112 i -= 1;
113 j -= 1;
114 } else if table[i - 1][j] >= table[i][j - 1] {
115 ops.push(Operation::Delete { index: i - 1 });
116 i -= 1;
117 } else {
118 ops.push(Operation::Insert {
119 index: j - 1,
120 block: new[j - 1].clone(),
121 });
122 j -= 1;
123 }
124 }
125 while i > 0 {
126 ops.push(Operation::Delete { index: i - 1 });
127 i -= 1;
128 }
129 while j > 0 {
130 ops.push(Operation::Insert {
131 index: j - 1,
132 block: new[j - 1].clone(),
133 });
134 j -= 1;
135 }
136 ops.reverse();
137 let coalesced = coalesce(ops);
139 for op in coalesced {
140 patch.push(op);
141 }
142}
143
144fn coalesce(ops: Vec<Operation>) -> Vec<Operation> {
145 let mut out: Vec<Operation> = Vec::with_capacity(ops.len());
146 let mut k = 0usize;
147 while k < ops.len() {
148 let mut advanced = false;
149 if k + 1 < ops.len() {
150 if let (Operation::Delete { index: di }, Operation::Insert { index: ii, block }) =
151 (&ops[k], &ops[k + 1])
152 {
153 if di == ii {
154 out.push(Operation::Replace {
155 index: *di,
156 block: block.clone(),
157 });
158 k += 2;
159 advanced = true;
160 }
161 }
162 if !advanced {
163 if let (Operation::Insert { index: ii, block }, Operation::Delete { index: di }) =
164 (&ops[k], &ops[k + 1])
165 {
166 if di == ii {
167 out.push(Operation::Replace {
168 index: *di,
169 block: block.clone(),
170 });
171 k += 2;
172 advanced = true;
173 }
174 }
175 }
176 }
177 if !advanced {
178 out.push(ops[k].clone());
179 k += 1;
180 }
181 }
182 out
183}
184
185pub fn apply(doc: Document, patch: &Patch) -> Result<Document, DiffError> {
190 let mut doc = doc;
191 let mut sorted: Vec<&Operation> = patch.operations.iter().collect();
193 sorted.sort_by_key(|op| match op {
194 Operation::Insert { index, .. } => (*index, 0u8),
195 Operation::Replace { index, .. } => (*index, 1u8),
196 Operation::Delete { index } => (*index, 2u8),
197 Operation::Move { from, .. } => (*from, 3u8),
198 Operation::UpdateMetadata { .. } => (usize::MAX, 4u8),
199 });
200 for op in sorted {
201 match op {
202 Operation::Insert { index, block } => {
203 let idx = (*index).min(doc.blocks.len());
204 doc.blocks.insert(idx, block.clone());
205 }
206 Operation::Replace { index, block } => {
207 if *index >= doc.blocks.len() {
208 return Err(DiffError::IndexOutOfRange { index: *index });
209 }
210 doc.blocks[*index] = block.clone();
211 }
212 Operation::Delete { index } => {
213 if *index >= doc.blocks.len() {
214 return Err(DiffError::IndexOutOfRange { index: *index });
215 }
216 doc.blocks.remove(*index);
217 }
218 Operation::Move { from, to } => {
219 if *from >= doc.blocks.len() {
220 return Err(DiffError::IndexOutOfRange { index: *from });
221 }
222 let b = doc.blocks.remove(*from);
223 let to = (*to).min(doc.blocks.len());
224 doc.blocks.insert(to, b);
225 }
226 Operation::UpdateMetadata { from: _, to } => {
227 doc.metadata = serde_json::from_value(to.clone())
228 .map_err(|e| DiffError::InvalidMetadata(e.to_string()))?;
229 }
230 }
231 }
232 Ok(doc)
233}
234
235#[derive(Debug, Clone, Default)]
237pub struct DiffStats {
238 pub inserts: usize,
239 pub deletes: usize,
240 pub replaces: usize,
241 pub moves: usize,
242 pub metadata: usize,
243}
244
245impl From<&Patch> for DiffStats {
246 fn from(patch: &Patch) -> Self {
247 let mut s = Self::default();
248 for op in &patch.operations {
249 match op {
250 Operation::Insert { .. } => s.inserts += 1,
251 Operation::Delete { .. } => s.deletes += 1,
252 Operation::Replace { .. } => s.replaces += 1,
253 Operation::Move { .. } => s.moves += 1,
254 Operation::UpdateMetadata { .. } => s.metadata += 1,
255 }
256 }
257 s
258 }
259}
260
261#[derive(Debug, thiserror::Error)]
262pub enum DiffError {
263 #[error("index {index} out of range")]
264 IndexOutOfRange { index: usize },
265 #[error("invalid metadata in patch: {0}")]
266 InvalidMetadata(String),
267}
268
269pub fn inlines_equal_ignoring_ws(a: &[Inline], b: &[Inline]) -> bool {
273 fn collect(inlines: &[Inline], out: &mut String) {
274 for i in inlines {
275 match i {
276 Inline::Text(t) => {
277 out.push_str(t.value.trim());
278 out.push(' ');
279 }
280 Inline::Code(s) => {
281 out.push_str(s);
282 out.push(' ');
283 }
284 Inline::Math(_) => {}
285 Inline::Link(l) => collect(&l.content, out),
286 Inline::Image(_) => {}
287 Inline::Bold(c) | Inline::Italic(c) | Inline::Underline(c) | Inline::Strike(c) => {
288 collect(c, out)
289 }
290 Inline::Raw(_) => {}
291 }
292 }
293 }
294 let mut sa = String::new();
295 let mut sb = String::new();
296 collect(a, &mut sa);
297 collect(b, &mut sb);
298 sa == sb
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use mtplatx_doc_core::{Inline, Paragraph};
305
306 fn p(s: &str) -> Block {
307 Block::Paragraph(Paragraph {
308 content: vec![Inline::from(s)],
309 })
310 }
311
312 #[test]
313 fn identical_docs_yield_empty_patch() {
314 let mut a = Document::new();
315 a.push(p("a"));
316 let patch = diff(&a, &a);
317 assert!(patch.is_empty());
318 }
319
320 #[test]
321 fn insert_at_end() {
322 let mut a = Document::new();
323 a.push(p("a"));
324 let mut b = Document::new();
325 b.push(p("a"));
326 b.push(p("b"));
327 let patch = diff(&a, &b);
328 assert!(matches!(
329 patch.operations.last(),
330 Some(Operation::Insert { .. })
331 ));
332 }
333
334 #[test]
335 fn delete_middle() {
336 let mut a = Document::new();
337 a.push(p("a"));
338 a.push(p("b"));
339 a.push(p("c"));
340 let mut b = Document::new();
341 b.push(p("a"));
342 b.push(p("c"));
343 let patch = diff(&a, &b);
344 let stats = DiffStats::from(&patch);
345 assert_eq!(stats.deletes, 1);
346 }
347
348 #[test]
349 fn replace_is_coalesced() {
350 let mut a = Document::new();
351 a.push(p("a"));
352 let mut b = Document::new();
353 b.push(p("z"));
354 let patch = diff(&a, &b);
355 assert_eq!(patch.len(), 1);
356 assert!(matches!(patch.operations[0], Operation::Replace { .. }));
357 }
358
359 #[test]
360 fn apply_roundtrips_diff() {
361 let mut a = Document::new();
362 a.push(p("a"));
363 a.push(p("b"));
364 let mut b = Document::new();
365 b.push(p("a"));
366 b.push(p("c"));
367 b.push(p("b"));
368 let patch = diff(&a, &b);
369 let applied = apply(a.clone(), &patch).unwrap();
370 assert_eq!(applied.metadata, b.metadata);
371 assert_eq!(applied.blocks, b.blocks);
372 }
373
374 #[test]
375 fn metadata_diff_emitted() {
376 let mut a = Document::new();
377 a.metadata.title = Some("A".into());
378 let mut b = Document::new();
379 b.metadata.title = Some("B".into());
380 let patch = diff(&a, &b);
381 assert!(patch
382 .operations
383 .iter()
384 .any(|op| matches!(op, Operation::UpdateMetadata { .. })));
385 }
386}