Skip to main content

lattice_diff/
compute.rs

1//! Hunk computation: the engine's single public entry point is
2//! [`compute_diff`], which dispatches by participant count to
3//! crate-private [`two_way`] / [`three_way`] helpers. External
4//! consumers (subsystem, benches, integration tests in other
5//! crates) go through `compute_diff` exclusively — no code
6//! outside this crate branches on arity. v1 supports N ∈
7//! {0, 1, 2, 3}; N=0 errors as `Empty`, N=1 returns an empty
8//! `HunkIndex` (dormant session, no peers to diff against), N=2
9//! / N=3 dispatch to the named helpers, N≥4 errors as
10//! `Unsupported`. See `docs/dev/architecture/n-way-diff-membership.md`
11//! (D.8) for the rationale.
12//!
13//! Both helpers materialise the input ropes to strings,
14//! tokenise by line, and run `imara-diff`. The two-way path is
15//! a thin wrapper around the engine; the three-way path
16//! composes two two-way diffs against the base and merges them
17//! by overlapping base ranges, producing `Conflict` hunks
18//! where both sides independently touched the same base
19//! lines.
20//!
21//! See `docs/dev/architecture/diff-system.md` §3 and §4.
22
23use std::ops::Range;
24
25use imara_diff::intern::InternedInput;
26use imara_diff::{Algorithm, Sink};
27use ropey::Rope;
28use smallvec::smallvec;
29
30use crate::types::{DiffAlgorithm, Hunk, HunkIndex, HunkKind, LineRange};
31
32fn algorithm_to_imara(alg: DiffAlgorithm) -> Algorithm {
33    match alg {
34        DiffAlgorithm::Histogram => Algorithm::Histogram,
35        DiffAlgorithm::Myers => Algorithm::Myers,
36        DiffAlgorithm::MyersMinimal => Algorithm::MyersMinimal,
37    }
38}
39
40/// `imara_diff::Sink` impl that collects each `process_change`
41/// callback into a two-way `Hunk`.
42struct TwoWaySink {
43    hunks: Vec<Hunk>,
44}
45
46impl Sink for TwoWaySink {
47    type Out = Vec<Hunk>;
48
49    fn process_change(&mut self, before: Range<u32>, after: Range<u32>) {
50        let a = LineRange::new(before.start, before.end);
51        let b = LineRange::new(after.start, after.end);
52        let kind = classify_two_way(a, b);
53        self.hunks.push(Hunk {
54            kind,
55            ranges: smallvec![a, b],
56            refine: Default::default(),
57        });
58    }
59
60    fn finish(self) -> Self::Out {
61        self.hunks
62    }
63}
64
65fn classify_two_way(a: LineRange, b: LineRange) -> HunkKind {
66    match (a.is_empty(), b.is_empty()) {
67        // imara-diff does not emit an empty/empty hunk; fall
68        // back to `Change` defensively rather than panic.
69        (true, true) => HunkKind::Change,
70        (true, false) => HunkKind::Add,
71        (false, true) => HunkKind::Remove,
72        (false, false) => HunkKind::Change,
73    }
74}
75
76/// D.8.a: the engine's only public dispatch entry. Routes by
77/// `sources.len()` to the appropriate algorithm, returning a
78/// typed error for arities outside the supported range.
79///
80/// - **N=0**: `Err(DiffEngineError::Empty)` — there's nothing to
81///   diff. Callers should never reach this; defensive.
82/// - **N=1**: empty `HunkIndex` — a single participant has no
83///   peers to compare against. The session is "dormant" in the
84///   subsystem's terms (D.8.e); the first `:diffthis` lands
85///   here, before a second `:diffthis` extends it to N=2.
86/// - **N=2**: dispatches to [`two_way`].
87/// - **N=3**: dispatches to [`three_way`].
88/// - **N≥4**: `Err(DiffEngineError::Unsupported { n })`. v1 cap.
89///   The reference table in §12 of
90///   `docs/dev/architecture/n-way-diff-membership.md` surveys
91///   how other editors handle this — Helix/Zed/VSCode also
92///   don't support N≥4; Vim does via pairwise-vs-anchor. The
93///   cap moves when user-feedback signals N>3 is wanted.
94///
95/// `compute_diff` is the **only** public entry — all external
96/// callers go through it. The dispatch decision lives entirely
97/// inside this function so consumers never branch on arity.
98pub fn compute_diff(
99    sources: &[Rope],
100    algorithm: DiffAlgorithm,
101) -> Result<HunkIndex, DiffEngineError> {
102    match sources.len() {
103        0 => Err(DiffEngineError::Empty),
104        1 => Ok(HunkIndex {
105            hunks: Vec::new(),
106            algorithm,
107            revision: 0,
108        }),
109        2 => Ok(two_way(&sources[0], &sources[1], algorithm)),
110        3 => Ok(three_way(&sources[0], &sources[1], &sources[2], algorithm)),
111        n => Err(DiffEngineError::Unsupported { n }),
112    }
113}
114
115/// D.8.a: errors `compute_diff` returns for unsupported
116/// participant counts. v1's cap (N ≤ 3) is enforced here, not in
117/// the descriptor / subsystem layer — moving the cap means
118/// extending the `Unsupported` arm with new computation.
119#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
120pub enum DiffEngineError {
121    #[error("diff requires at least one participant")]
122    Empty,
123    #[error("v1 supports up to 3 participants; got N = {n}")]
124    Unsupported { n: usize },
125}
126
127/// Compute the two-way hunk list between ropes `a` and `b`.
128///
129/// Crate-private since D.8.a — external consumers call
130/// [`compute_diff`] which dispatches here for `sources.len() ==
131/// 2`. Kept as a named function (rather than inlined) for the
132/// internal tests in this module + the three-way pipeline's
133/// `two_way_str` reuse.
134///
135/// Each hunk's `ranges` is `[a_range, b_range]`. The
136/// classification (Add / Remove / Change) is relative to `a`
137/// being the "earlier" side.
138///
139/// Allocates the full text of both ropes once each via
140/// `Rope::to_string()` for the engine's interner. The cost is
141/// O(N + M) bytes for ropes of sizes N and M; bench gated in
142/// `benches/recompute.rs`.
143pub(crate) fn two_way(a: &Rope, b: &Rope, algorithm: DiffAlgorithm) -> HunkIndex {
144    let a_str = a.to_string();
145    let b_str = b.to_string();
146    let mut hunks = two_way_str(&a_str, &b_str, algorithm);
147    // DR.4: refine here, where BOTH sides are in hand, rather than at
148    // render time where only the baseline is. Computed once per diff
149    // and carried on the hunk, so every consumer reads the same answer
150    // and none of them re-derives it.
151    fill_refinements(&mut hunks, &a_str, &b_str);
152    HunkIndex {
153        hunks,
154        algorithm,
155        revision: 0,
156    }
157}
158
159/// DR.4: populate each `Change` hunk's `refine` from the two sides.
160///
161/// Only a `Change` has both sides — an `Add` has no removed
162/// counterpart and a `Remove` no added one, so neither has anything to
163/// compare against.
164///
165/// DR.5: the two sides go to `refine_regions` whole. There is no
166/// length precondition any more — an *n*-removed / *m*-added hunk
167/// refines like any other, which is the fix; `refine_regions` decides
168/// on its own whether the result is signal (identical, wholesale, or
169/// oversized regions come back empty).
170fn fill_refinements(hunks: &mut [Hunk], a: &str, b: &str) {
171    let a_lines: Vec<&str> = a.lines().collect();
172    let b_lines: Vec<&str> = b.lines().collect();
173    for hunk in hunks.iter_mut() {
174        if hunk.kind != HunkKind::Change {
175            continue;
176        }
177        let (Some(before), Some(after)) = (hunk.ranges.first(), hunk.ranges.get(1)) else {
178            continue;
179        };
180        let slice = |src: &[&str], r: &LineRange| -> Vec<String> {
181            (r.start as usize..r.end as usize)
182                .filter_map(|i| src.get(i).map(|s| s.to_string()))
183                .collect()
184        };
185        let removed = slice(&a_lines, before);
186        let added = slice(&b_lines, after);
187        let removed_refs: Vec<&str> = removed.iter().map(|s| s.as_str()).collect();
188        let added_refs: Vec<&str> = added.iter().map(|s| s.as_str()).collect();
189        hunk.refine = crate::refine::refine_regions(&removed_refs, &added_refs);
190    }
191}
192
193/// Internal: two-way diff over `&str` inputs. Used by both
194/// [`two_way`] and [`three_way`] (which materialises ropes once
195/// and reuses the strings for the two base-vs-side diffs).
196///
197/// Tokenises with `imara_diff::sources::lines_with_terminator`
198/// so trailing-newline differences are preserved (the default
199/// `&str` tokenisation uses `str::lines()` which strips
200/// terminators and treats `"x"` and `"x\n"` as identical).
201fn two_way_str(a: &str, b: &str, algorithm: DiffAlgorithm) -> Vec<Hunk> {
202    let input = InternedInput::new(
203        imara_diff::sources::lines_with_terminator(a),
204        imara_diff::sources::lines_with_terminator(b),
205    );
206    imara_diff::diff(
207        algorithm_to_imara(algorithm),
208        &input,
209        TwoWaySink { hunks: Vec::new() },
210    )
211}
212
213/// Compute a three-way hunk list between `base`, `local`, and
214/// `remote`.
215///
216/// Crate-private since D.8.a — external consumers call
217/// [`compute_diff`] which dispatches here for `sources.len() ==
218/// 3`. Kept as a named function for internal tests + clarity.
219///
220/// Each hunk's `ranges` is `[base_range, local_range,
221/// remote_range]`. A hunk is `Conflict` iff both `local` and
222/// `remote` independently modified an overlapping base region.
223/// Otherwise the hunk is classified relative to whichever side
224/// changed (the other side's range covers the corresponding
225/// untouched region in that side's coordinate system, computed
226/// via running offset).
227///
228/// Adjacent (touching but not overlapping) hunks from
229/// different sides are kept separate — they don't conflict.
230/// Strict overlap (`a.start < b.end && b.start < a.end`) is
231/// the conflict predicate.
232pub(crate) fn three_way(
233    base: &Rope,
234    local: &Rope,
235    remote: &Rope,
236    algorithm: DiffAlgorithm,
237) -> HunkIndex {
238    let base_str = base.to_string();
239    let local_str = local.to_string();
240    let remote_str = remote.to_string();
241
242    let local_hunks = two_way_str(&base_str, &local_str, algorithm);
243    let remote_hunks = two_way_str(&base_str, &remote_str, algorithm);
244
245    let merged = merge_three_way(&local_hunks, &remote_hunks, &local_str, &remote_str);
246
247    HunkIndex {
248        hunks: merged,
249        algorithm,
250        revision: 0,
251    }
252}
253
254/// Precompute byte offsets of every line start in `s`.
255///
256/// `offsets[i]` is the byte index in `s` where line `i`
257/// starts. `offsets[len_lines]` is `s.len()` (past-the-end
258/// sentinel). Used by [`line_slice`] for O(1) line-range
259/// indexing during three-way merge content comparison.
260fn line_offsets(s: &str) -> Vec<usize> {
261    let mut offsets = Vec::with_capacity(s.len() / 32 + 2);
262    offsets.push(0);
263    for (i, byte) in s.bytes().enumerate() {
264        if byte == b'\n' {
265            offsets.push(i + 1);
266        }
267    }
268    if offsets.last().copied() != Some(s.len()) {
269        offsets.push(s.len());
270    }
271    offsets
272}
273
274/// Slice `s` by line range, using precomputed offsets.
275/// Returns an empty slice if the range is empty or out of
276/// bounds.
277fn line_slice<'a>(s: &'a str, offsets: &[usize], range: LineRange) -> &'a str {
278    let start = offsets
279        .get(range.start as usize)
280        .copied()
281        .unwrap_or(s.len());
282    let end = offsets.get(range.end as usize).copied().unwrap_or(s.len());
283    if start > end || start > s.len() {
284        return "";
285    }
286    &s[start..end.min(s.len())]
287}
288
289/// Merge two sorted two-way hunk lists (each `[base, side]`)
290/// into a unified three-way list `[base, local, remote]`.
291///
292/// Walks both lists in tandem in base-ascending order. Picks
293/// the earliest unprocessed hunk as the seed of a new union
294/// region, then takes every subsequent hunk from either side
295/// that strictly overlaps the growing union. A union that
296/// took hunks from both sides is classified `Conflict`; a
297/// union touched by only one side is attributed to that
298/// side's change kind.
299fn merge_three_way(
300    local_hunks: &[Hunk],
301    remote_hunks: &[Hunk],
302    local_str: &str,
303    remote_str: &str,
304) -> Vec<Hunk> {
305    let local_offsets = line_offsets(local_str);
306    let remote_offsets = line_offsets(remote_str);
307
308    let mut merged = Vec::new();
309    let mut li = 0;
310    let mut ri = 0;
311
312    // Running net deltas: how many lines local/remote has
313    // gained (positive) or lost (negative) vs base by the time
314    // we reach the current base position. Used to project an
315    // untouched base range into the side's coordinate space.
316    let mut local_delta: i64 = 0;
317    let mut remote_delta: i64 = 0;
318
319    while li < local_hunks.len() || ri < remote_hunks.len() {
320        let l_pos = local_hunks.get(li).map(|h| h.ranges[0].start);
321        let r_pos = remote_hunks.get(ri).map(|h| h.ranges[0].start);
322
323        // Pick the earliest unprocessed hunk as the seed.
324        let take_local_first = match (l_pos, r_pos) {
325            (Some(l), Some(r)) => l <= r,
326            (Some(_), None) => true,
327            (None, Some(_)) => false,
328            (None, None) => break,
329        };
330
331        let mut taken_local: Vec<usize> = Vec::new();
332        let mut taken_remote: Vec<usize> = Vec::new();
333        let mut union_base;
334
335        if take_local_first {
336            union_base = local_hunks[li].ranges[0];
337            taken_local.push(li);
338            li += 1;
339        } else {
340            union_base = remote_hunks[ri].ranges[0];
341            taken_remote.push(ri);
342            ri += 1;
343        }
344
345        // Extend the union while either side has a strictly-
346        // overlapping next hunk.
347        loop {
348            let mut extended = false;
349            if let Some(h) = local_hunks.get(li)
350                && h.ranges[0].start < union_base.end
351            {
352                union_base = LineRange::new(union_base.start, union_base.end.max(h.ranges[0].end));
353                taken_local.push(li);
354                li += 1;
355                extended = true;
356            }
357            if let Some(h) = remote_hunks.get(ri)
358                && h.ranges[0].start < union_base.end
359            {
360                union_base = LineRange::new(union_base.start, union_base.end.max(h.ranges[0].end));
361                taken_remote.push(ri);
362                ri += 1;
363                extended = true;
364            }
365            if !extended {
366                break;
367            }
368        }
369
370        let local_range = side_range(&taken_local, local_hunks, union_base, local_delta);
371        let remote_range = side_range(&taken_remote, remote_hunks, union_base, remote_delta);
372
373        // Advance running deltas past the taken hunks.
374        for &idx in &taken_local {
375            let h = &local_hunks[idx];
376            local_delta += h.ranges[1].len() as i64 - h.ranges[0].len() as i64;
377        }
378        for &idx in &taken_remote {
379            let h = &remote_hunks[idx];
380            remote_delta += h.ranges[1].len() as i64 - h.ranges[0].len() as i64;
381        }
382
383        let kind = if !taken_local.is_empty() && !taken_remote.is_empty() {
384            // Both sides touched this region. If the resulting
385            // content is identical, this is a "soft" merge
386            // where both sides made the same change — not a
387            // conflict. Compare the actual line content rather
388            // than just the ranges, since equivalent edits can
389            // land at different line indices in their side's
390            // coordinate space.
391            let local_text = line_slice(local_str, &local_offsets, local_range);
392            let remote_text = line_slice(remote_str, &remote_offsets, remote_range);
393            if local_text == remote_text {
394                classify_three_way_attributed(union_base, local_range)
395            } else {
396                HunkKind::Conflict
397            }
398        } else if !taken_local.is_empty() {
399            classify_three_way_attributed(union_base, local_range)
400        } else {
401            classify_three_way_attributed(union_base, remote_range)
402        };
403
404        merged.push(Hunk {
405            kind,
406            ranges: smallvec![union_base, local_range, remote_range],
407            refine: Default::default(),
408        });
409    }
410
411    merged
412}
413
414/// Compute one side's (local *or* remote) range corresponding
415/// to the union base range.
416///
417/// - If the side touched the union (one or more hunks taken),
418///   the range spans from the first taken hunk's side-start
419///   (extended by the base-prefix preceding it) to the last
420///   taken hunk's side-end (extended by the base-suffix
421///   following it).
422/// - If the side did not touch the union, project the base
423///   range into the side's coordinate space via the running
424///   delta.
425fn side_range(
426    taken: &[usize],
427    hunks: &[Hunk],
428    union_base: LineRange,
429    side_delta: i64,
430) -> LineRange {
431    if taken.is_empty() {
432        let start = (union_base.start as i64 + side_delta).max(0) as u32;
433        let end = (union_base.end as i64 + side_delta).max(0) as u32;
434        LineRange::new(start, end)
435    } else {
436        let first = &hunks[taken[0]];
437        let last = &hunks[*taken.last().expect("taken non-empty")];
438        // How much base extends before the first taken hunk
439        // (untouched prefix that we count as side-untouched).
440        let prefix = first.ranges[0].start.saturating_sub(union_base.start);
441        let suffix = union_base.end.saturating_sub(last.ranges[0].end);
442        let start = first.ranges[1].start.saturating_sub(prefix);
443        let end = last.ranges[1].end + suffix;
444        LineRange::new(start, end)
445    }
446}
447
448fn classify_three_way_attributed(base: LineRange, side: LineRange) -> HunkKind {
449    match (base.is_empty(), side.is_empty()) {
450        (true, false) => HunkKind::Add,
451        (false, true) => HunkKind::Remove,
452        (false, false) => HunkKind::Change,
453        // An empty/empty union shouldn't occur after a
454        // hunk-taking iteration; classify defensively.
455        (true, true) => HunkKind::Change,
456    }
457}
458
459#[cfg(test)]
460mod refine_at_compute_time {
461    use super::*;
462
463    fn rope(s: &str) -> Rope {
464        Rope::from_str(s)
465    }
466
467    /// DR.4: a Change hunk carries its own refinement, computed once at
468    /// diff time. The render path only has the baseline rope, so if it
469    /// were not computed here it could not be computed at all.
470    #[test]
471    fn a_change_hunk_carries_refinement() {
472        let a = rope("fn main() {\n    let old = 1;\n}\n");
473        let b = rope("fn main() {\n    let new = 1;\n}\n");
474        let idx = two_way(&a, &b, DiffAlgorithm::Histogram);
475        let change = idx
476            .hunks
477            .iter()
478            .find(|h| h.kind == HunkKind::Change)
479            .expect("one changed line");
480        // The ranges address the SOURCE line, not the diff line — there
481        // is no +/- marker here.
482        let line = "    let old = 1;";
483        let got: Vec<&str> = change
484            .refine
485            .removed_line(0)
486            .iter()
487            .map(|x| &line[x.clone()])
488            .collect();
489        assert_eq!(got, vec!["old"]);
490    }
491
492    /// An Add has no removed counterpart, so there is nothing to
493    /// compare against and the hunk keeps an empty refinement.
494    #[test]
495    fn an_add_hunk_carries_no_refinement() {
496        let a = rope("one\n");
497        let b = rope("one\ntwo\n");
498        let idx = two_way(&a, &b, DiffAlgorithm::Histogram);
499        assert!(idx.hunks.iter().all(|h| h.refine.is_empty()));
500    }
501
502    /// DR.5: a 1-removed / 2-added change refines. Under DR.1's
503    /// pairing rule this declined outright — unequal runs had no
504    /// principled pairing — and that was the bug: the shape is what
505    /// "rewrite a line and add one above it" produces every time.
506    #[test]
507    fn an_unequal_change_still_refines() {
508        let a = rope("let value = compute(a);\n");
509        let b = rope("// explain it\nlet value = derive(a);\n");
510        let idx = two_way(&a, &b, DiffAlgorithm::Histogram);
511        let change = idx
512            .hunks
513            .iter()
514            .find(|h| h.kind == HunkKind::Change)
515            .expect("a change hunk");
516        let line = "let value = compute(a);";
517        let got: Vec<&str> = change
518            .refine
519            .removed_line(0)
520            .iter()
521            .map(|x| &line[x.clone()])
522            .collect();
523        assert_eq!(
524            got,
525            vec!["compute"],
526            "the removed side marks the replaced identifier"
527        );
528    }
529
530    /// Each side is indexed by its OWN range, so consumers can index
531    /// removed by `line - ranges[0].start` and added by
532    /// `line - ranges[1].start` — which is what the overlay and the
533    /// magit path do.
534    #[test]
535    fn each_side_is_aligned_with_its_own_range() {
536        let a = rope("a1\na2\n");
537        let b = rope("b1\nb2\n");
538        let idx = two_way(&a, &b, DiffAlgorithm::Histogram);
539        for h in idx.hunks.iter().filter(|h| h.kind == HunkKind::Change) {
540            if h.refine.is_empty() {
541                continue;
542            }
543            assert_eq!(
544                h.refine.removed.len(),
545                (h.ranges[0].end - h.ranges[0].start) as usize,
546                "one entry per baseline line in the hunk"
547            );
548            assert_eq!(
549                h.refine.added.len(),
550                (h.ranges[1].end - h.ranges[1].start) as usize,
551                "one entry per added line in the hunk"
552            );
553        }
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn empty_inputs_have_no_hunks() {
563        let a = Rope::new();
564        let b = Rope::new();
565        let idx = two_way(&a, &b, DiffAlgorithm::Histogram);
566        assert!(idx.is_empty());
567    }
568
569    #[test]
570    fn identical_inputs_have_no_hunks() {
571        let a = Rope::from("alpha\nbeta\ngamma\n");
572        let b = Rope::from("alpha\nbeta\ngamma\n");
573        let idx = two_way(&a, &b, DiffAlgorithm::Histogram);
574        assert!(idx.is_empty());
575    }
576
577    #[test]
578    fn pure_add_classifies_as_add() {
579        let a = Rope::from("alpha\ngamma\n");
580        let b = Rope::from("alpha\nbeta\ngamma\n");
581        let idx = two_way(&a, &b, DiffAlgorithm::Histogram);
582        assert_eq!(idx.len(), 1);
583        assert_eq!(idx.hunks[0].kind, HunkKind::Add);
584    }
585
586    #[test]
587    fn pure_remove_classifies_as_remove() {
588        let a = Rope::from("alpha\nbeta\ngamma\n");
589        let b = Rope::from("alpha\ngamma\n");
590        let idx = two_way(&a, &b, DiffAlgorithm::Histogram);
591        assert_eq!(idx.len(), 1);
592        assert_eq!(idx.hunks[0].kind, HunkKind::Remove);
593    }
594
595    #[test]
596    fn change_classifies_as_change() {
597        let a = Rope::from("alpha\nbeta\ngamma\n");
598        let b = Rope::from("alpha\nBETA\ngamma\n");
599        let idx = two_way(&a, &b, DiffAlgorithm::Histogram);
600        assert_eq!(idx.len(), 1);
601        assert_eq!(idx.hunks[0].kind, HunkKind::Change);
602    }
603
604    #[test]
605    fn three_way_non_overlapping_changes_no_conflict() {
606        let base = Rope::from("a\nb\nc\nd\ne\nf\n");
607        let local = Rope::from("a\nB\nc\nd\ne\nf\n"); // changed line 1
608        let remote = Rope::from("a\nb\nc\nd\nE\nf\n"); // changed line 4
609        let idx = three_way(&base, &local, &remote, DiffAlgorithm::Histogram);
610        // Two separate non-conflict hunks (one per side).
611        assert_eq!(idx.len(), 2);
612        assert!(idx.hunks.iter().all(|h| h.kind != HunkKind::Conflict));
613    }
614
615    #[test]
616    fn three_way_overlapping_changes_yield_conflict() {
617        let base = Rope::from("a\nb\nc\n");
618        let local = Rope::from("a\nLOCAL\nc\n"); // changed line 1
619        let remote = Rope::from("a\nREMOTE\nc\n"); // changed line 1 differently
620        let idx = three_way(&base, &local, &remote, DiffAlgorithm::Histogram);
621        assert_eq!(idx.len(), 1);
622        assert_eq!(idx.hunks[0].kind, HunkKind::Conflict);
623    }
624
625    #[test]
626    fn three_way_no_changes_no_hunks() {
627        let base = Rope::from("a\nb\nc\n");
628        let local = base.clone();
629        let remote = base.clone();
630        let idx = three_way(&base, &local, &remote, DiffAlgorithm::Histogram);
631        assert!(idx.is_empty());
632    }
633
634    #[test]
635    fn all_algorithms_agree_on_simple_change() {
636        let a = Rope::from("alpha\nbeta\ngamma\n");
637        let b = Rope::from("alpha\nBETA\ngamma\n");
638        for alg in [
639            DiffAlgorithm::Histogram,
640            DiffAlgorithm::Myers,
641            DiffAlgorithm::MyersMinimal,
642        ] {
643            let idx = two_way(&a, &b, alg);
644            assert_eq!(idx.len(), 1, "algorithm {alg:?}");
645            assert_eq!(idx.hunks[0].kind, HunkKind::Change, "algorithm {alg:?}");
646            assert_eq!(idx.algorithm, alg);
647        }
648    }
649
650    // ──────────────────────────────────────────────────────
651    // D.8.a (2026-05-31): compute_diff dispatch matrix
652    // ──────────────────────────────────────────────────────
653
654    #[test]
655    fn compute_diff_zero_participants_is_empty_error() {
656        let result = compute_diff(&[], DiffAlgorithm::Histogram);
657        assert!(matches!(result, Err(DiffEngineError::Empty)));
658    }
659
660    #[test]
661    fn compute_diff_one_participant_returns_empty_hunk_index() {
662        // N=1 = dormant session. Vim parity: `:set diff` on a
663        // single buffer with no peers is a visual no-op.
664        let rope = Rope::from("alpha\nbeta\n");
665        let idx =
666            compute_diff(&[rope], DiffAlgorithm::Histogram).expect("N=1 must succeed (dormant)");
667        assert!(idx.is_empty());
668        assert_eq!(idx.algorithm, DiffAlgorithm::Histogram);
669    }
670
671    #[test]
672    fn compute_diff_two_participants_dispatches_to_two_way() {
673        let a = Rope::from("alpha\nbeta\ngamma\n");
674        let b = Rope::from("alpha\nBETA\ngamma\n");
675        let via_dispatch = compute_diff(&[a.clone(), b.clone()], DiffAlgorithm::Histogram)
676            .expect("N=2 is supported");
677        let direct = two_way(&a, &b, DiffAlgorithm::Histogram);
678        // Same hunks shape (the dispatch is a thin wrapper).
679        assert_eq!(via_dispatch.len(), direct.len());
680        assert_eq!(via_dispatch.hunks.len(), 1);
681        assert_eq!(via_dispatch.hunks[0].kind, HunkKind::Change);
682    }
683
684    #[test]
685    fn compute_diff_three_participants_dispatches_to_three_way() {
686        let base = Rope::from("aaa\nbbb\nccc\n");
687        let local = Rope::from("aaa\nBBB\nccc\n");
688        let remote = Rope::from("aaa\nbbb\nCCC\n");
689        let via_dispatch = compute_diff(
690            &[base.clone(), local.clone(), remote.clone()],
691            DiffAlgorithm::Histogram,
692        )
693        .expect("N=3 is supported");
694        let direct = three_way(&base, &local, &remote, DiffAlgorithm::Histogram);
695        assert_eq!(via_dispatch.len(), direct.len());
696        // Disjoint edits → no Conflict.
697        assert!(
698            via_dispatch
699                .hunks
700                .iter()
701                .all(|h| !matches!(h.kind, HunkKind::Conflict))
702        );
703        // Every hunk carries 3 ranges per the three-way contract.
704        for h in &via_dispatch.hunks {
705            assert_eq!(h.ranges.len(), 3);
706        }
707    }
708
709    #[test]
710    fn compute_diff_four_participants_errors_unsupported() {
711        let r = Rope::from("x\n");
712        let result = compute_diff(
713            &[r.clone(), r.clone(), r.clone(), r],
714            DiffAlgorithm::Histogram,
715        );
716        assert!(matches!(result, Err(DiffEngineError::Unsupported { n: 4 })));
717    }
718
719    #[test]
720    fn compute_diff_arbitrarily_large_n_errors_unsupported() {
721        let sources: Vec<Rope> = (0..10).map(|i| Rope::from(format!("rope-{i}\n"))).collect();
722        let result = compute_diff(&sources, DiffAlgorithm::Histogram);
723        assert!(matches!(
724            result,
725            Err(DiffEngineError::Unsupported { n: 10 })
726        ));
727    }
728
729    #[test]
730    fn diff_engine_error_messages_name_the_cap() {
731        // Surfaces in dispatch.rs error messages; verify they
732        // stay user-readable.
733        let empty = DiffEngineError::Empty;
734        let unsup = DiffEngineError::Unsupported { n: 5 };
735        assert_eq!(format!("{empty}"), "diff requires at least one participant");
736        assert_eq!(
737            format!("{unsup}"),
738            "v1 supports up to 3 participants; got N = 5"
739        );
740    }
741
742    #[test]
743    fn compute_diff_three_way_overlap_produces_conflict() {
744        // Belt-and-braces: confirm the three-way Conflict path
745        // fires through the dispatch wrapper.
746        let base = Rope::from("aaa\nbbb\nccc\n");
747        let local = Rope::from("aaa\nLOCAL\nccc\n");
748        let remote = Rope::from("aaa\nREMOTE\nccc\n");
749        let idx =
750            compute_diff(&[base, local, remote], DiffAlgorithm::Histogram).expect("N=3 supported");
751        assert!(
752            idx.hunks
753                .iter()
754                .any(|h| matches!(h.kind, HunkKind::Conflict))
755        );
756    }
757}