Skip to main content

miwa/
diff.rs

1//! Block alignment and character-level diff used by `miwa site --diff`.
2
3use std::collections::HashMap;
4use std::ops::Range;
5
6/// Minimum similarity for pairing a deleted block with an added block.
7pub const SIMILARITY_THRESHOLD: f64 = 0.45;
8/// Maximum number of equal blocks absorbed between changes.
9pub const ABSORB_EQUAL_MAX: usize = 2;
10/// Maximum combined chars for a character-level diff of a block pair.
11pub const CHAR_DIFF_MAX_CHARS: usize = 4_000;
12/// Maximum product of deleted and added block counts for pairing.
13pub const PAIRING_MAX_PRODUCT: usize = 10_000;
14/// Maximum number of blocks per document before falling back.
15pub const DOC_MAX_BLOCKS: usize = 20_000;
16/// Maximum edit distance accepted by Myers diff.
17pub const MYERS_MAX_EDITS: usize = 2_000;
18
19/// One edit-script operation over index ranges.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Op {
22    Equal { a: Range<usize>, b: Range<usize> },
23    Delete { a: Range<usize> },
24    Insert { b: Range<usize> },
25}
26
27/// Myers diff. Returns None when the edit distance exceeds `max_edits`.
28pub fn diff_ops<T: Eq>(a: &[T], b: &[T], max_edits: usize) -> Option<Vec<Op>> {
29    let (n, m) = (a.len(), b.len());
30    // v[k + offset] = furthest x reached on diagonal k (k = x - y).
31    let offset = max_edits as isize + 1;
32    let mut v = vec![0usize; 2 * max_edits + 3];
33    // trace[d] holds v for diagonals -d..=d (step 2) after round d; index (k + d) / 2.
34    let mut trace: Vec<Vec<usize>> = Vec::new();
35    let idx = |k: isize| (k + offset) as usize;
36    let mut found = false;
37    for d in 0..=max_edits as isize {
38        let mut k = -d;
39        while k <= d {
40            let mut x = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) {
41                v[idx(k + 1)]
42            } else {
43                v[idx(k - 1)] + 1
44            };
45            let mut y = (x as isize - k) as usize;
46            // Follow the diagonal while elements match.
47            while x < n && y < m && a[x] == b[y] {
48                x += 1;
49                y += 1;
50            }
51            v[idx(k)] = x;
52            if x >= n && y >= m {
53                found = true;
54            }
55            k += 2;
56        }
57        trace.push((-d..=d).step_by(2).map(|k| v[idx(k)]).collect());
58        if found {
59            break;
60        }
61    }
62    if !found {
63        return None;
64    }
65
66    // Walk back from (n, m), collecting elementary ops in reverse.
67    let mut rev: Vec<Op> = Vec::new();
68    let (mut x, mut y) = (n, m);
69    for d in (0..trace.len() as isize).rev() {
70        let k = x as isize - y as isize;
71        if d == 0 {
72            if x > 0 {
73                rev.push(Op::Equal { a: 0..x, b: 0..y });
74            }
75            break;
76        }
77        let prev = &trace[(d - 1) as usize];
78        let at = |kk: isize| prev[((kk + d - 1) / 2) as usize];
79        let prev_k = if k == -d || (k != d && at(k - 1) < at(k + 1)) {
80            k + 1
81        } else {
82            k - 1
83        };
84        let px = at(prev_k);
85        let py = (px as isize - prev_k) as usize;
86        // Position right after the single insert/delete step.
87        let (mx, my) = if prev_k == k + 1 {
88            (px, py + 1)
89        } else {
90            (px + 1, py)
91        };
92        if x > mx {
93            rev.push(Op::Equal { a: mx..x, b: my..y });
94        }
95        if prev_k == k + 1 {
96            rev.push(Op::Insert { b: py..my });
97        } else {
98            rev.push(Op::Delete { a: px..mx });
99        }
100        x = px;
101        y = py;
102    }
103
104    // Reverse and merge adjacent ops of the same kind.
105    let mut ops: Vec<Op> = Vec::new();
106    for op in rev.into_iter().rev() {
107        match (ops.last_mut(), &op) {
108            (Some(Op::Equal { a, b }), Op::Equal { a: na, b: nb }) => {
109                a.end = na.end;
110                b.end = nb.end;
111            }
112            (Some(Op::Delete { a }), Op::Delete { a: na }) => a.end = na.end,
113            (Some(Op::Insert { b }), Op::Insert { b: nb }) => b.end = nb.end,
114            _ => ops.push(op),
115        }
116    }
117    Some(ops)
118}
119
120/// Multiset character overlap: 2 * common / (len_a + len_b). Both empty -> 1.0.
121pub fn similarity(a: &str, b: &str) -> f64 {
122    let mut counts: HashMap<char, usize> = HashMap::new();
123    let mut len_a = 0usize;
124    for c in a.chars() {
125        *counts.entry(c).or_insert(0) += 1;
126        len_a += 1;
127    }
128    let mut len_b = 0usize;
129    let mut common = 0usize;
130    for c in b.chars() {
131        len_b += 1;
132        if let Some(n) = counts.get_mut(&c) {
133            if *n > 0 {
134                *n -= 1;
135                common += 1;
136            }
137        }
138    }
139    if len_a + len_b == 0 {
140        return 1.0;
141    }
142    2.0 * common as f64 / (len_a + len_b) as f64
143}
144
145/// Kind of a rendered block.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
147pub enum BlockKind {
148    Paragraph,
149    Heading,
150    Code,
151    Table,
152    ListItem,
153    Quote,
154    Rule,
155    Footnote,
156    Html,
157    Other,
158}
159
160/// A block with its comparison key and similarity text.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct BlockKey {
163    pub kind: BlockKind,
164    /// Comparison key (unprefixed HTML + image hashes).
165    pub key: String,
166    /// Plain text used for similarity.
167    pub text: String,
168}
169
170/// Row classification in the side-by-side view.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum RowKind {
173    Same,
174    Changed,
175    Added,
176    Deleted,
177}
178
179/// One aligned row; indices point into the left / right block sequences.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct Row {
182    pub kind: RowKind,
183    pub left: Option<usize>,
184    pub right: Option<usize>,
185}
186
187/// Aligns two block sequences per FR-117 (limits included).
188pub fn align_blocks(a: &[BlockKey], b: &[BlockKey]) -> Vec<Row> {
189    if a.len() + b.len() > DOC_MAX_BLOCKS {
190        return all_deleted_then_added(0..a.len(), 0..b.len());
191    }
192    let ka: Vec<&str> = a.iter().map(|x| x.key.as_str()).collect();
193    let kb: Vec<&str> = b.iter().map(|x| x.key.as_str()).collect();
194    let Some(ops) = diff_ops(&ka, &kb, MYERS_MAX_EDITS) else {
195        return all_deleted_then_added(0..a.len(), 0..b.len());
196    };
197
198    let mut rows = Vec::new();
199    // Pending replacement region (consecutive Delete / Insert ops).
200    let mut region: Option<(Range<usize>, Range<usize>)> = None;
201    for op in ops {
202        match op {
203            Op::Equal { a: ra, b: rb } => {
204                if let Some((da, ib)) = region.take() {
205                    pair_region(a, b, da, ib, &mut rows);
206                }
207                for (i, j) in ra.zip(rb) {
208                    rows.push(Row {
209                        kind: RowKind::Same,
210                        left: Some(i),
211                        right: Some(j),
212                    });
213                }
214            }
215            Op::Delete { a: ra } => {
216                let r = region.get_or_insert((ra.start..ra.start, 0..0));
217                r.0.end = ra.end;
218            }
219            Op::Insert { b: rb } => {
220                let r = region.get_or_insert((0..0, rb.start..rb.start));
221                if r.1.is_empty() && r.1.start == 0 {
222                    r.1.start = rb.start;
223                }
224                r.1.end = rb.end;
225            }
226        }
227    }
228    if let Some((da, ib)) = region.take() {
229        pair_region(a, b, da, ib, &mut rows);
230    }
231    rows
232}
233
234/// All left blocks as Deleted, then all right blocks as Added.
235fn all_deleted_then_added(da: Range<usize>, ib: Range<usize>) -> Vec<Row> {
236    let deleted = da.map(|i| Row {
237        kind: RowKind::Deleted,
238        left: Some(i),
239        right: None,
240    });
241    let added = ib.map(|j| Row {
242        kind: RowKind::Added,
243        left: None,
244        right: Some(j),
245    });
246    deleted.chain(added).collect()
247}
248
249/// Character counts and total length of a text, computed once per block.
250fn char_counts(text: &str) -> (HashMap<char, usize>, usize) {
251    let mut counts = HashMap::new();
252    let mut len = 0;
253    for c in text.chars() {
254        *counts.entry(c).or_insert(0) += 1;
255        len += 1;
256    }
257    (counts, len)
258}
259
260/// Similarity of two blocks from precomputed counts; empty texts compare by block kind.
261fn block_similarity(
262    l: &BlockKey,
263    lc: &(HashMap<char, usize>, usize),
264    r: &BlockKey,
265    rc: &(HashMap<char, usize>, usize),
266) -> f64 {
267    if lc.1 == 0 && rc.1 == 0 {
268        return if l.kind == r.kind { 1.0 } else { 0.0 };
269    }
270    // Iterate the smaller map; the multiset overlap is the sum of per-char minima.
271    let (small, large) = if lc.0.len() <= rc.0.len() {
272        (&lc.0, &rc.0)
273    } else {
274        (&rc.0, &lc.0)
275    };
276    let common: usize = small
277        .iter()
278        .map(|(c, n)| large.get(c).map_or(0, |m| *n.min(m)))
279        .sum();
280    2.0 * common as f64 / (lc.1 + rc.1) as f64
281}
282
283/// Pairs blocks of one replacement region greedily, keeping order.
284fn pair_region(
285    a: &[BlockKey],
286    b: &[BlockKey],
287    da: Range<usize>,
288    ib: Range<usize>,
289    rows: &mut Vec<Row>,
290) {
291    if da.len() * ib.len() > PAIRING_MAX_PRODUCT {
292        rows.extend(all_deleted_then_added(da, ib));
293        return;
294    }
295    let (na, nb) = (da.len(), ib.len());
296    let ca: Vec<_> = a[da.clone()].iter().map(|x| char_counts(&x.text)).collect();
297    let cb: Vec<_> = b[ib.clone()].iter().map(|x| char_counts(&x.text)).collect();
298    // sim[i * nb + j]: each pair is computed exactly once (na * nb is bounded).
299    let mut sim = vec![0.0f64; na * nb];
300    for i in 0..na {
301        for j in 0..nb {
302            sim[i * nb + j] = block_similarity(&a[da.start + i], &ca[i], &b[ib.start + j], &cb[j]);
303        }
304    }
305    // Suffix maxima (0.0 past the end): right_max[i][j] = max sim[i][j..],
306    // down_max[i][j] = max sim[i..][j]; both are (na + 1) x (nb + 1).
307    let w = nb + 1;
308    let mut right_max = vec![0.0f64; (na + 1) * w];
309    let mut down_max = vec![0.0f64; (na + 1) * w];
310    for i in (0..na).rev() {
311        for j in (0..nb).rev() {
312            let v = sim[i * nb + j];
313            right_max[i * w + j] = v.max(right_max[i * w + j + 1]);
314            down_max[i * w + j] = v.max(down_max[(i + 1) * w + j]);
315        }
316    }
317    let (mut i, mut j) = (0, 0);
318    while i < na && j < nb {
319        let cur = sim[i * nb + j];
320        // Best partner for the current left among remaining rights, and vice versa.
321        let best_r = right_max[i * w + j + 1];
322        let best_l = down_max[(i + 1) * w + j];
323        if cur >= SIMILARITY_THRESHOLD && cur >= best_r && cur >= best_l {
324            rows.push(Row {
325                kind: RowKind::Changed,
326                left: Some(da.start + i),
327                right: Some(ib.start + j),
328            });
329            i += 1;
330            j += 1;
331        } else if best_r > best_l {
332            rows.push(Row {
333                kind: RowKind::Added,
334                left: None,
335                right: Some(ib.start + j),
336            });
337            j += 1;
338        } else {
339            rows.push(Row {
340                kind: RowKind::Deleted,
341                left: Some(da.start + i),
342                right: None,
343            });
344            i += 1;
345        }
346    }
347    rows.extend(all_deleted_then_added(
348        da.start + i..da.end,
349        ib.start + j..ib.end,
350    ));
351}
352
353/// Character-level marks per FR-117: `Some((changed_in_a, changed_in_b))` with one bool per char,
354/// or None when the pair exceeds CHAR_DIFF_MAX_CHARS or MYERS_MAX_EDITS (whole block changed, no highlight).
355pub fn char_marks(a: &[char], b: &[char]) -> Option<(Vec<bool>, Vec<bool>)> {
356    if a.len() + b.len() > CHAR_DIFF_MAX_CHARS {
357        return None;
358    }
359    let ops = diff_ops(a, b, MYERS_MAX_EDITS)?;
360    let mut ma = vec![false; a.len()];
361    let mut mb = vec![false; b.len()];
362    for (idx, op) in ops.iter().enumerate() {
363        match op {
364            Op::Delete { a: r } => ma[r.clone()].fill(true),
365            Op::Insert { b: r } => mb[r.clone()].fill(true),
366            // Short equal runs between two changes are absorbed into the change.
367            Op::Equal { a: ra, b: rb }
368                if idx > 0 && idx + 1 < ops.len() && ra.len() <= ABSORB_EQUAL_MAX =>
369            {
370                ma[ra.clone()].fill(true);
371                mb[rb.clone()].fill(true);
372            }
373            Op::Equal { .. } => {}
374        }
375    }
376    Some((ma, mb))
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    fn rebuild<T: Clone + Eq + std::fmt::Debug>(a: &[T], b: &[T], ops: &[Op]) {
384        let mut from_a = Vec::new();
385        let mut from_b = Vec::new();
386        for op in ops {
387            match op {
388                Op::Equal { a: ra, b: rb } => {
389                    assert_eq!(a[ra.clone()], b[rb.clone()]);
390                    from_a.extend_from_slice(&a[ra.clone()]);
391                    from_b.extend_from_slice(&b[rb.clone()]);
392                }
393                Op::Delete { a: ra } => from_a.extend_from_slice(&a[ra.clone()]),
394                Op::Insert { b: rb } => from_b.extend_from_slice(&b[rb.clone()]),
395            }
396        }
397        assert_eq!(from_a, a);
398        assert_eq!(from_b, b);
399    }
400
401    fn has_equal(ops: &[Op]) -> bool {
402        ops.iter().any(|o| matches!(o, Op::Equal { .. }))
403    }
404    fn has_delete(ops: &[Op]) -> bool {
405        ops.iter().any(|o| matches!(o, Op::Delete { .. }))
406    }
407    fn has_insert(ops: &[Op]) -> bool {
408        ops.iter().any(|o| matches!(o, Op::Insert { .. }))
409    }
410
411    // ------------------------------------------------------------ diff_ops
412
413    // [FR-117]
414    #[test]
415    fn diff_opsは両方空なら空の編集列を返す() {
416        let ops = diff_ops::<i32>(&[], &[], 10).unwrap();
417        assert!(!has_delete(&ops) && !has_insert(&ops));
418        rebuild::<i32>(&[], &[], &ops);
419    }
420
421    // [FR-117]
422    #[test]
423    fn diff_opsは同一列ならequalだけを返す() {
424        let a = [1, 2, 3];
425        let ops = diff_ops(&a, &a, 10).unwrap();
426        assert!(has_equal(&ops));
427        assert!(!has_delete(&ops) && !has_insert(&ops));
428        rebuild(&a, &a, &ops);
429    }
430
431    // [FR-117]
432    #[test]
433    fn diff_opsは全置換なら削除と挿入だけでequalを含まない() {
434        let (a, b) = ([1, 2, 3], [4, 5, 6]);
435        let ops = diff_ops(&a, &b, 100).unwrap();
436        assert!(!has_equal(&ops));
437        assert!(has_delete(&ops) && has_insert(&ops));
438        rebuild(&a, &b, &ops);
439    }
440
441    // [FR-117]
442    #[test]
443    fn diff_opsは挿入のみなら削除を含まない() {
444        let (a, b) = ([1, 3], [1, 2, 3]);
445        let ops = diff_ops(&a, &b, 100).unwrap();
446        assert!(has_insert(&ops) && !has_delete(&ops));
447        rebuild(&a, &b, &ops);
448    }
449
450    // [FR-117]
451    #[test]
452    fn diff_opsは削除のみなら挿入を含まない() {
453        let (a, b) = ([1, 2, 3], [1, 3]);
454        let ops = diff_ops(&a, &b, 100).unwrap();
455        assert!(has_delete(&ops) && !has_insert(&ops));
456        rebuild(&a, &b, &ops);
457    }
458
459    // [FR-117]
460    #[test]
461    fn diff_opsは編集距離がmax_editsを超えるとnoneで等しければsome() {
462        let (a, b) = ([1, 2, 3], [4, 5, 6]);
463        assert!(diff_ops(&a, &b, 5).is_none());
464        let ops = diff_ops(&a, &b, 6).expect("distance 6 is within max_edits 6");
465        rebuild(&a, &b, &ops);
466    }
467
468    // [FR-117]
469    #[test]
470    fn diff_opsは混在した変更でも両列を復元できる() {
471        let a: Vec<char> = "kitten sitting".chars().collect();
472        let b: Vec<char> = "sitting kitten".chars().collect();
473        let ops = diff_ops(&a, &b, MYERS_MAX_EDITS).unwrap();
474        rebuild(&a, &b, &ops);
475    }
476
477    // ---------------------------------------------------------- similarity
478
479    // [FR-117]
480    #[test]
481    fn similarityは両方空なら1() {
482        assert_eq!(similarity("", ""), 1.0);
483    }
484
485    // [FR-117]
486    #[test]
487    fn similarityは片側だけ空なら0() {
488        assert_eq!(similarity("", "abc"), 0.0);
489        assert_eq!(similarity("abc", ""), 0.0);
490    }
491
492    // [FR-117]
493    #[test]
494    fn similarityは同一文字列なら1で共通が無ければ0() {
495        assert_eq!(similarity("abc", "abc"), 1.0);
496        assert_eq!(similarity("abc", "xyz"), 0.0);
497    }
498
499    // [FR-117]
500    #[test]
501    fn similarityは文字の多重集合の重なりで定まる() {
502        // a: {a:2, b:1}, b: {a:1, b:2} -> common 2 -> 2*2/6
503        let v = similarity("aab", "abb");
504        assert!((v - 4.0 / 6.0).abs() < 1e-9, "{v}");
505        // order does not matter
506        assert_eq!(similarity("abcd", "dcba"), 1.0);
507        let w = similarity("abcd", "abxy");
508        assert!((w - 0.5).abs() < 1e-9, "{w}");
509    }
510
511    // -------------------------------------------------------- align_blocks
512
513    fn bk(kind: BlockKind, key: &str, text: &str) -> BlockKey {
514        BlockKey {
515            kind,
516            key: key.to_string(),
517            text: text.to_string(),
518        }
519    }
520
521    fn para(key: &str, text: &str) -> BlockKey {
522        bk(BlockKind::Paragraph, key, text)
523    }
524
525    fn count(rows: &[Row], kind: RowKind) -> usize {
526        rows.iter().filter(|r| r.kind == kind).count()
527    }
528
529    /// 20 distinct chars, optionally with the first `keep` shared and the rest replaced.
530    fn text20(keep: usize, other: bool) -> String {
531        let base: Vec<char> = ('a'..='t').collect();
532        let alt: Vec<char> = ('A'..='T').collect();
533        (0..20)
534            .map(|i| if i < keep || !other { base[i] } else { alt[i] })
535            .collect()
536    }
537
538    // [FR-117]
539    #[test]
540    fn align_blocksは同一列ならすべてsameで添字が揃う() {
541        let a = vec![para("1", "x"), para("2", "y"), para("3", "z")];
542        let rows = align_blocks(&a, &a.clone());
543        assert_eq!(rows.len(), 3);
544        for (i, r) in rows.iter().enumerate() {
545            assert_eq!(r.kind, RowKind::Same);
546            assert_eq!((r.left, r.right), (Some(i), Some(i)));
547        }
548    }
549
550    // [FR-117]
551    #[test]
552    fn align_blocksは両方空なら行も空() {
553        assert!(align_blocks(&[], &[]).is_empty());
554    }
555
556    // [FR-117]
557    #[test]
558    fn align_blocksは片側だけのブロックを追加と削除にする() {
559        let a = vec![para("1", "x")];
560        let rows = align_blocks(&a, &[]);
561        assert_eq!(rows.len(), 1);
562        assert_eq!(rows[0].kind, RowKind::Deleted);
563        assert_eq!((rows[0].left, rows[0].right), (Some(0), None));
564        let rows = align_blocks(&[], &a);
565        assert_eq!(rows.len(), 1);
566        assert_eq!(rows[0].kind, RowKind::Added);
567        assert_eq!((rows[0].left, rows[0].right), (None, Some(0)));
568    }
569
570    // [FR-117]
571    #[test]
572    fn align_blocksは類似度が閾値以上の置換区間のブロックを対にする() {
573        // 10 common of 20 -> 0.5
574        let a = vec![para("l", &text20(10, false))];
575        let b = vec![para("r", &text20(10, true))];
576        let rows = align_blocks(&a, &b);
577        assert_eq!(rows.len(), 1);
578        assert_eq!(rows[0].kind, RowKind::Changed);
579        assert_eq!((rows[0].left, rows[0].right), (Some(0), Some(0)));
580    }
581
582    // [FR-117]
583    #[test]
584    fn align_blocksは類似度がちょうど0_45なら対にする() {
585        // 9 common of 20+20 -> 18/40 = 0.45
586        let a = vec![para("l", &text20(9, false))];
587        let b = vec![para("r", &text20(9, true))];
588        assert!((similarity(&a[0].text, &b[0].text) - 0.45).abs() < 1e-9);
589        let rows = align_blocks(&a, &b);
590        assert_eq!(count(&rows, RowKind::Changed), 1);
591    }
592
593    // [FR-117]
594    #[test]
595    fn align_blocksは類似度が閾値未満なら対にせず削除と追加にする() {
596        // 8 common of 20+20 -> 0.4
597        let a = vec![para("l", &text20(8, false))];
598        let b = vec![para("r", &text20(8, true))];
599        let rows = align_blocks(&a, &b);
600        assert_eq!(count(&rows, RowKind::Changed), 0);
601        assert_eq!(count(&rows, RowKind::Deleted), 1);
602        assert_eq!(count(&rows, RowKind::Added), 1);
603    }
604
605    // [FR-117]
606    #[test]
607    fn align_blocksは先読みして良い相手が後ろにあれば手前を単独の追加にする() {
608        // R0 is similar enough (0.5) to L0, but R1 is identical in text.
609        let l = vec![para("l", &text20(0, false))];
610        let r = vec![
611            para(
612                "r0",
613                &format!("{}{}", &text20(0, false)[..10], "XXXXXXXXXX"),
614            ),
615            para("r1", &text20(0, false)),
616        ];
617        let rows = align_blocks(&l, &r);
618        assert_eq!(count(&rows, RowKind::Changed), 1);
619        let changed = rows.iter().find(|r| r.kind == RowKind::Changed).unwrap();
620        assert_eq!((changed.left, changed.right), (Some(0), Some(1)));
621        let added = rows.iter().find(|r| r.kind == RowKind::Added).unwrap();
622        assert_eq!(added.right, Some(0));
623        assert_eq!(count(&rows, RowKind::Deleted), 0);
624    }
625
626    // [FR-117]
627    #[test]
628    fn align_blocksは対にしたブロックの順序を保つ() {
629        let a = vec![para("l0", "alpha beta"), para("l1", "gamma delta")];
630        let b = vec![para("r0", "alpha beta!"), para("r1", "gamma delta!")];
631        let rows = align_blocks(&a, &b);
632        let pairs: Vec<_> = rows
633            .iter()
634            .filter(|r| r.kind == RowKind::Changed)
635            .map(|r| (r.left.unwrap(), r.right.unwrap()))
636            .collect();
637        assert_eq!(pairs, vec![(0, 0), (1, 1)]);
638    }
639
640    // [FR-117]
641    #[test]
642    fn align_blocksは空テキストで種類が同じブロックを対にする() {
643        let a = vec![bk(BlockKind::Paragraph, "img-a", "")];
644        let b = vec![bk(BlockKind::Paragraph, "img-b", "")];
645        let rows = align_blocks(&a, &b);
646        assert_eq!(rows.len(), 1);
647        assert_eq!(rows[0].kind, RowKind::Changed);
648    }
649
650    // [FR-117]
651    #[test]
652    fn align_blocksは空テキストで種類が違うブロックを対にしない() {
653        let a = vec![bk(BlockKind::Paragraph, "img-a", "")];
654        let b = vec![bk(BlockKind::ListItem, "img-b", "")];
655        let rows = align_blocks(&a, &b);
656        assert_eq!(count(&rows, RowKind::Changed), 0);
657        assert_eq!(count(&rows, RowKind::Deleted), 1);
658        assert_eq!(count(&rows, RowKind::Added), 1);
659    }
660
661    fn many(prefix: &str, n: usize, same_text: bool) -> Vec<BlockKey> {
662        (0..n)
663            .map(|i| {
664                let text = if same_text {
665                    format!("text number {i}")
666                } else {
667                    format!("{prefix}{i}")
668                };
669                para(&format!("{prefix}{i}"), &text)
670            })
671            .collect()
672    }
673
674    // [FR-117]
675    #[test]
676    fn align_blocksは積が上限ちょうどなら対づけする() {
677        let a = many("l", 100, true);
678        let b = many("r", 100, true);
679        assert_eq!(a.len() * b.len(), PAIRING_MAX_PRODUCT);
680        let rows = align_blocks(&a, &b);
681        assert_eq!(count(&rows, RowKind::Changed), 100);
682    }
683
684    // [FR-117]
685    #[test]
686    fn align_blocksは大きな置換区間でも似ていないブロックを速く削除と追加にする() {
687        // 1,000 old blocks vs 1 new block, ~200 chars each, no shared chars.
688        let a: Vec<BlockKey> = (0..1000)
689            .map(|i| para(&format!("l{i}"), &"a".repeat(200)))
690            .collect();
691        let b = vec![para("r0", &"b".repeat(200))];
692        let rows = align_blocks(&a, &b);
693        assert_eq!(rows.len(), 1001);
694        assert_eq!(count(&rows, RowKind::Changed), 0);
695        assert_eq!(count(&rows, RowKind::Deleted), 1000);
696        assert_eq!(count(&rows, RowKind::Added), 1);
697        // Deleted first on equal lookahead bests, then the leftover Added.
698        assert!(rows[..1000].iter().all(|r| r.kind == RowKind::Deleted));
699        assert_eq!(rows[1000].kind, RowKind::Added);
700    }
701
702    // [FR-117]
703    #[test]
704    fn align_blocksは積が上限を超えると対づけせずすべて削除と追加にする() {
705        let a = many("l", 101, true);
706        let b = many("r", 100, true);
707        assert!(a.len() * b.len() > PAIRING_MAX_PRODUCT);
708        let rows = align_blocks(&a, &b);
709        assert_eq!(count(&rows, RowKind::Changed), 0);
710        assert_eq!(count(&rows, RowKind::Deleted), 101);
711        assert_eq!(count(&rows, RowKind::Added), 100);
712    }
713
714    // [FR-117]
715    #[test]
716    fn align_blocksはブロック数の和が上限ちょうどなら通常どおり比べる() {
717        let a = many("k", DOC_MAX_BLOCKS / 2, false);
718        let rows = align_blocks(&a, &a.clone());
719        assert_eq!(rows.len(), DOC_MAX_BLOCKS / 2);
720        assert!(rows.iter().all(|r| r.kind == RowKind::Same));
721    }
722
723    // [FR-117]
724    #[test]
725    fn align_blocksはブロック数の和が上限を超えると全削除の後に全追加にする() {
726        let a = many("k", DOC_MAX_BLOCKS / 2 + 1, false);
727        let b = many("k", DOC_MAX_BLOCKS / 2, false);
728        let rows = align_blocks(&a, &b);
729        assert_eq!(rows.len(), a.len() + b.len());
730        let (del, add) = rows.split_at(a.len());
731        assert!(del
732            .iter()
733            .all(|r| r.kind == RowKind::Deleted && r.right.is_none()));
734        assert!(add
735            .iter()
736            .all(|r| r.kind == RowKind::Added && r.left.is_none()));
737        assert_eq!(del[0].left, Some(0));
738        assert_eq!(add[0].right, Some(0));
739    }
740
741    // ---------------------------------------------------------- char_marks
742
743    fn chars(s: &str) -> Vec<char> {
744        s.chars().collect()
745    }
746
747    // [FR-117]
748    #[test]
749    fn char_marksは同一なら印を付けない() {
750        let a = chars("同じ文字列");
751        let (ma, mb) = char_marks(&a, &a.clone()).unwrap();
752        assert_eq!(ma, vec![false; 5]);
753        assert_eq!(mb, vec![false; 5]);
754    }
755
756    // [FR-117]
757    #[test]
758    fn char_marksは変わった文字だけに印を付ける() {
759        let (ma, mb) = char_marks(&chars("abcdefgh"), &chars("abXdefgh")).unwrap();
760        assert_eq!(
761            ma,
762            vec![false, false, true, false, false, false, false, false]
763        );
764        assert_eq!(
765            mb,
766            vec![false, false, true, false, false, false, false, false]
767        );
768    }
769
770    // [FR-117]
771    #[test]
772    fn char_marksは2文字以下の一致に挟まれた変更を1つにまとめる() {
773        let (ma, mb) = char_marks(&chars("AAxxBB"), &chars("CCxxDD")).unwrap();
774        assert_eq!(ma, vec![true; 6]);
775        assert_eq!(mb, vec![true; 6]);
776    }
777
778    // [FR-117]
779    #[test]
780    fn char_marksは1文字の一致に挟まれた変更もまとめる() {
781        let (ma, mb) = char_marks(&chars("AAxBB"), &chars("CCxDD")).unwrap();
782        assert_eq!(ma, vec![true; 5]);
783        assert_eq!(mb, vec![true; 5]);
784    }
785
786    // [FR-117]
787    #[test]
788    fn char_marksは3文字の一致に挟まれた変更はまとめない() {
789        let (ma, mb) = char_marks(&chars("AAxxxBB"), &chars("CCxxxDD")).unwrap();
790        assert_eq!(ma, vec![true, true, false, false, false, true, true]);
791        assert_eq!(mb, vec![true, true, false, false, false, true, true]);
792    }
793
794    // [FR-117]
795    #[test]
796    fn char_marksは挿入と削除を別々の側に印付けする() {
797        let (ma, mb) = char_marks(&chars("abcdefgh"), &chars("abcdXXefgh")).unwrap();
798        assert_eq!(ma.len(), 8);
799        assert_eq!(mb.len(), 10);
800        assert!(ma.iter().all(|m| !m));
801        assert_eq!(mb.iter().filter(|m| **m).count(), 2);
802        assert!(mb[4] && mb[5]);
803    }
804
805    // [FR-117]
806    #[test]
807    fn char_marksは文字数の和が上限ちょうどなら印を返す() {
808        let a = vec!['a'; CHAR_DIFF_MAX_CHARS / 2];
809        let b = vec!['a'; CHAR_DIFF_MAX_CHARS / 2];
810        assert!(char_marks(&a, &b).is_some());
811    }
812
813    // [FR-117]
814    #[test]
815    fn char_marksは文字数の和が上限を超えるとnone() {
816        let a = vec!['a'; CHAR_DIFF_MAX_CHARS / 2];
817        let b = vec!['a'; CHAR_DIFF_MAX_CHARS / 2 + 1];
818        assert!(char_marks(&a, &b).is_none());
819    }
820
821    // [FR-117]
822    #[test]
823    fn char_marksは編集距離が上限を超えるとnone() {
824        // sum == 4000 (allowed), but edit distance 4000 > MYERS_MAX_EDITS
825        let a = vec!['a'; CHAR_DIFF_MAX_CHARS / 2];
826        let b = vec!['b'; CHAR_DIFF_MAX_CHARS / 2];
827        assert!(char_marks(&a, &b).is_none());
828    }
829
830    // [FR-117]
831    #[test]
832    fn char_marksは編集距離が上限以内なら印を返す() {
833        let a = vec!['a'; 500];
834        let b = vec!['b'; 500];
835        let (ma, mb) = char_marks(&a, &b).unwrap();
836        assert!(ma.iter().all(|m| *m) && mb.iter().all(|m| *m));
837    }
838}