Skip to main content

neco_diffcore/
lib.rs

1//! Line-level and character-level diff computation with hunk mapping and side-by-side view support.
2
3use neco_textpatch::{TextPatch, TextPatchError};
4
5/// Diff operation kind.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum DiffOp {
8    Equal,
9    Insert,
10    Delete,
11}
12
13/// Byte range reference into an original or new text.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct ByteRange {
16    start: usize,
17    end: usize,
18}
19
20impl ByteRange {
21    pub fn new(start: usize, end: usize) -> Self {
22        Self { start, end }
23    }
24
25    pub fn start(&self) -> usize {
26        self.start
27    }
28
29    pub fn end(&self) -> usize {
30        self.end
31    }
32
33    pub fn len(&self) -> usize {
34        self.end - self.start
35    }
36
37    pub fn is_empty(&self) -> bool {
38        self.start == self.end
39    }
40}
41
42/// One line in a diff result with its operation and source ranges.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct DiffLine {
45    op: DiffOp,
46    old_line: Option<u32>,
47    new_line: Option<u32>,
48    old_range: Option<ByteRange>,
49    new_range: Option<ByteRange>,
50}
51
52impl DiffLine {
53    pub fn op(&self) -> DiffOp {
54        self.op
55    }
56
57    pub fn old_line(&self) -> Option<u32> {
58        self.old_line
59    }
60
61    pub fn new_line(&self) -> Option<u32> {
62        self.new_line
63    }
64
65    pub fn old_range(&self) -> Option<ByteRange> {
66        self.old_range
67    }
68
69    pub fn new_range(&self) -> Option<ByteRange> {
70        self.new_range
71    }
72}
73
74/// Group of contiguous changes with surrounding context lines.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct DiffHunk {
77    old_start: u32,
78    old_count: u32,
79    new_start: u32,
80    new_count: u32,
81    lines: Vec<DiffLine>,
82}
83
84impl DiffHunk {
85    pub fn old_start(&self) -> u32 {
86        self.old_start
87    }
88
89    pub fn old_count(&self) -> u32 {
90        self.old_count
91    }
92
93    pub fn new_start(&self) -> u32 {
94        self.new_start
95    }
96
97    pub fn new_count(&self) -> u32 {
98        self.new_count
99    }
100
101    pub fn lines(&self) -> &[DiffLine] {
102        &self.lines
103    }
104}
105
106/// Result of a line-level diff computation.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct DiffResult {
109    lines: Vec<DiffLine>,
110}
111
112impl DiffResult {
113    pub fn lines(&self) -> &[DiffLine] {
114        &self.lines
115    }
116
117    pub fn to_hunks(&self, context_lines: u32) -> Vec<DiffHunk> {
118        let ctx = context_lines as usize;
119        let n = self.lines.len();
120        if n == 0 {
121            return Vec::new();
122        }
123
124        let change_indices: Vec<usize> = self
125            .lines
126            .iter()
127            .enumerate()
128            .filter(|(_, l)| l.op != DiffOp::Equal)
129            .map(|(i, _)| i)
130            .collect();
131
132        if change_indices.is_empty() {
133            return Vec::new();
134        }
135
136        let mut groups: Vec<(usize, usize)> = Vec::new();
137        let mut group_start = change_indices[0].saturating_sub(ctx);
138        let mut group_end = (change_indices[0] + ctx).min(n - 1);
139
140        for &ci in &change_indices[1..] {
141            let lo = ci.saturating_sub(ctx);
142            let hi = (ci + ctx).min(n - 1);
143            if lo <= group_end + 1 {
144                group_end = hi;
145            } else {
146                groups.push((group_start, group_end));
147                group_start = lo;
148                group_end = hi;
149            }
150        }
151        groups.push((group_start, group_end));
152
153        groups
154            .into_iter()
155            .map(|(start, end)| {
156                let hunk_lines: Vec<DiffLine> = self.lines[start..=end].to_vec();
157                let mut old_start = u32::MAX;
158                let mut old_count = 0u32;
159                let mut new_start = u32::MAX;
160                let mut new_count = 0u32;
161                for line in &hunk_lines {
162                    if let Some(ol) = line.old_line {
163                        if ol < old_start {
164                            old_start = ol;
165                        }
166                        old_count += 1;
167                    }
168                    if let Some(nl) = line.new_line {
169                        if nl < new_start {
170                            new_start = nl;
171                        }
172                        new_count += 1;
173                    }
174                }
175                if old_start == u32::MAX {
176                    old_start = 0;
177                }
178                if new_start == u32::MAX {
179                    new_start = 0;
180                }
181                DiffHunk {
182                    old_start,
183                    old_count,
184                    new_start,
185                    new_count,
186                    lines: hunk_lines,
187                }
188            })
189            .collect()
190    }
191}
192
193/// Character-level diff range within a single line.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub struct IntraLineRange {
196    start: usize,
197    end: usize,
198    op: DiffOp,
199}
200
201impl IntraLineRange {
202    pub fn new(start: usize, end: usize, op: DiffOp) -> Self {
203        Self { start, end, op }
204    }
205
206    pub fn start(&self) -> usize {
207        self.start
208    }
209
210    pub fn end(&self) -> usize {
211        self.end
212    }
213
214    pub fn op(&self) -> DiffOp {
215        self.op
216    }
217}
218
219/// Character-level diff result for one line pair.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct IntraLineDiff {
222    ranges: Vec<IntraLineRange>,
223}
224
225impl IntraLineDiff {
226    pub fn ranges(&self) -> &[IntraLineRange] {
227        &self.ranges
228    }
229}
230
231/// One side of a side-by-side diff row.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct SideLine {
234    line: u32,
235    op: DiffOp,
236    range: ByteRange,
237}
238
239impl SideLine {
240    pub fn new(line: u32, op: DiffOp, range: ByteRange) -> Self {
241        Self { line, op, range }
242    }
243
244    pub fn line(&self) -> u32 {
245        self.line
246    }
247
248    pub fn op(&self) -> DiffOp {
249        self.op
250    }
251
252    pub fn range(&self) -> ByteRange {
253        self.range
254    }
255}
256
257/// Side-by-side diff row pairing left and right lines.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct SideBySideLine {
260    left: Option<SideLine>,
261    right: Option<SideLine>,
262}
263
264impl SideBySideLine {
265    pub fn new(left: Option<SideLine>, right: Option<SideLine>) -> Self {
266        Self { left, right }
267    }
268
269    pub fn left(&self) -> Option<&SideLine> {
270        self.left.as_ref()
271    }
272
273    pub fn right(&self) -> Option<&SideLine> {
274        self.right.as_ref()
275    }
276}
277
278/// Myers O(ND) diff。edit script を (DiffOp, count) の run-length encoding で返す。
279fn myers_diff<T: PartialEq>(old: &[T], new: &[T]) -> Vec<(DiffOp, usize)> {
280    let n = old.len();
281    let m = new.len();
282
283    if n == 0 && m == 0 {
284        return Vec::new();
285    }
286    if n == 0 {
287        return vec![(DiffOp::Insert, m)];
288    }
289    if m == 0 {
290        return vec![(DiffOp::Delete, n)];
291    }
292
293    let max = n + m;
294    // v は k → x のマッピング。k = x - y で、index は k + offset。
295    let size = 2 * max + 1;
296    let mut v: Vec<usize> = vec![0; size];
297    let off = max; // offset for indexing: v[k + off]
298
299    // trace[d] = v snapshot at step d (before snake extension? no, after)
300    // We store v at the START of each d iteration (before computing d's values).
301    let mut trace: Vec<Vec<usize>> = Vec::new();
302
303    let mut found_d = 0;
304    'search: for d in 0..=max {
305        trace.push(v.clone());
306        let d_i = d as isize;
307        let mut k = -d_i;
308        while k <= d_i {
309            let ki = (k + off as isize) as usize;
310            let mut x = if k == -d_i
311                || (k != d_i
312                    && v[(k - 1 + off as isize) as usize] < v[(k + 1 + off as isize) as usize])
313            {
314                v[(k + 1 + off as isize) as usize] // move down (insert)
315            } else {
316                v[(k - 1 + off as isize) as usize] + 1 // move right (delete)
317            };
318            let mut y = (x as isize - k) as usize;
319
320            // extend snake
321            while x < n && y < m && old[x] == new[y] {
322                x += 1;
323                y += 1;
324            }
325
326            v[ki] = x;
327
328            if x >= n && y >= m {
329                found_d = d;
330                break 'search;
331            }
332
333            k += 2;
334        }
335    }
336
337    // Backtrack from (n, m)
338    let mut x = n;
339    let mut y = m;
340    let mut edits: Vec<DiffOp> = Vec::new();
341
342    for d in (0..=found_d).rev() {
343        let v_prev = &trace[d];
344        let k = x as isize - y as isize;
345        let d_i = d as isize;
346
347        // Determine prev_k: which k did we come from at step d-1?
348        // At step d (1-indexed), we stored trace[d] = v at the start of step d.
349        // trace[d] is the state BEFORE computing step d's values.
350        // So trace[d] = v at end of step d-1.
351        // Wait - we push v.clone() at the START of d loop, before computing.
352        // So trace[0] = initial v, trace[1] = v after d=0, etc.
353        // trace[d] = v state after d-1 steps completed.
354
355        if d == 0 {
356            // All remaining moves must be diagonal (equal)
357            while x > 0 && y > 0 {
358                x -= 1;
359                y -= 1;
360                edits.push(DiffOp::Equal);
361            }
362            break;
363        }
364
365        // v_prev = trace[d] = state after d-1 steps
366        let prev_k = if k == -d_i
367            || (k != d_i
368                && v_prev[(k - 1 + off as isize) as usize]
369                    < v_prev[(k + 1 + off as isize) as usize])
370        {
371            k + 1
372        } else {
373            k - 1
374        };
375
376        let prev_x = v_prev[(prev_k + off as isize) as usize];
377        let prev_y = (prev_x as isize - prev_k) as usize;
378
379        // Diagonal (snake) from (prev_x, prev_y) after the edit step
380        while x > prev_x && y > prev_y {
381            x -= 1;
382            y -= 1;
383            edits.push(DiffOp::Equal);
384        }
385
386        // The edit step
387        if prev_k == k + 1 {
388            // came from k+1 → moved down → insert
389            y -= 1;
390            edits.push(DiffOp::Insert);
391        } else {
392            // came from k-1 → moved right → delete
393            x -= 1;
394            edits.push(DiffOp::Delete);
395        }
396    }
397
398    edits.reverse();
399
400    // Run-length encode
401    let mut result: Vec<(DiffOp, usize)> = Vec::new();
402    for op in edits {
403        if let Some(last) = result.last_mut() {
404            if last.0 == op {
405                last.1 += 1;
406                continue;
407            }
408        }
409        result.push((op, 1));
410    }
411    result
412}
413
414struct LineInfo {
415    byte_start: usize,
416    byte_end: usize,
417}
418
419fn split_lines(text: &str) -> Vec<LineInfo> {
420    if text.is_empty() {
421        return Vec::new();
422    }
423    let mut lines = Vec::new();
424    let mut start = 0;
425    for (i, ch) in text.char_indices() {
426        if ch == '\n' {
427            lines.push(LineInfo {
428                byte_start: start,
429                byte_end: i + 1,
430            });
431            start = i + 1;
432        }
433    }
434    if start < text.len() {
435        lines.push(LineInfo {
436            byte_start: start,
437            byte_end: text.len(),
438        });
439    }
440    lines
441}
442
443/// Compute a line-level diff between two texts using the Myers O(ND) algorithm.
444pub fn diff(old: &str, new: &str) -> DiffResult {
445    let old_lines = split_lines(old);
446    let new_lines = split_lines(new);
447
448    let old_strs: Vec<&str> = old_lines
449        .iter()
450        .map(|l| &old[l.byte_start..l.byte_end])
451        .collect();
452    let new_strs: Vec<&str> = new_lines
453        .iter()
454        .map(|l| &new[l.byte_start..l.byte_end])
455        .collect();
456
457    let ops = myers_diff(&old_strs, &new_strs);
458
459    let mut result_lines = Vec::new();
460    let mut old_idx = 0usize;
461    let mut new_idx = 0usize;
462
463    for &(op, count) in &ops {
464        match op {
465            DiffOp::Equal => {
466                for _ in 0..count {
467                    let ol = &old_lines[old_idx];
468                    let nl = &new_lines[new_idx];
469                    result_lines.push(DiffLine {
470                        op: DiffOp::Equal,
471                        old_line: Some(u32::try_from(old_idx).expect("line count fits u32")),
472                        new_line: Some(u32::try_from(new_idx).expect("line count fits u32")),
473                        old_range: Some(ByteRange::new(ol.byte_start, ol.byte_end)),
474                        new_range: Some(ByteRange::new(nl.byte_start, nl.byte_end)),
475                    });
476                    old_idx += 1;
477                    new_idx += 1;
478                }
479            }
480            DiffOp::Delete => {
481                for _ in 0..count {
482                    let ol = &old_lines[old_idx];
483                    result_lines.push(DiffLine {
484                        op: DiffOp::Delete,
485                        old_line: Some(u32::try_from(old_idx).expect("line count fits u32")),
486                        new_line: None,
487                        old_range: Some(ByteRange::new(ol.byte_start, ol.byte_end)),
488                        new_range: None,
489                    });
490                    old_idx += 1;
491                }
492            }
493            DiffOp::Insert => {
494                for _ in 0..count {
495                    let nl = &new_lines[new_idx];
496                    result_lines.push(DiffLine {
497                        op: DiffOp::Insert,
498                        old_line: None,
499                        new_line: Some(u32::try_from(new_idx).expect("line count fits u32")),
500                        old_range: None,
501                        new_range: Some(ByteRange::new(nl.byte_start, nl.byte_end)),
502                    });
503                    new_idx += 1;
504                }
505            }
506        }
507    }
508
509    DiffResult {
510        lines: result_lines,
511    }
512}
513
514/// Compute a character-level diff between two lines.
515pub fn diff_intra_line(old_line: &str, new_line: &str) -> IntraLineDiff {
516    let old_chars: Vec<char> = old_line.chars().collect();
517    let new_chars: Vec<char> = new_line.chars().collect();
518
519    let ops = myers_diff(&old_chars, &new_chars);
520
521    let mut ranges = Vec::new();
522    let mut old_byte = 0usize;
523    let mut new_byte = 0usize;
524    let mut old_ci = 0usize;
525    let mut new_ci = 0usize;
526
527    for &(op, count) in &ops {
528        match op {
529            DiffOp::Equal => {
530                for _ in 0..count {
531                    old_byte += old_chars[old_ci].len_utf8();
532                    new_byte += new_chars[new_ci].len_utf8();
533                    old_ci += 1;
534                    new_ci += 1;
535                }
536            }
537            DiffOp::Delete => {
538                let start = old_byte;
539                for _ in 0..count {
540                    old_byte += old_chars[old_ci].len_utf8();
541                    old_ci += 1;
542                }
543                ranges.push(IntraLineRange::new(start, old_byte, DiffOp::Delete));
544            }
545            DiffOp::Insert => {
546                let start = new_byte;
547                for _ in 0..count {
548                    new_byte += new_chars[new_ci].len_utf8();
549                    new_ci += 1;
550                }
551                ranges.push(IntraLineRange::new(start, new_byte, DiffOp::Insert));
552            }
553        }
554    }
555
556    IntraLineDiff { ranges }
557}
558
559/// Convert a diff result to side-by-side display rows.
560pub fn to_side_by_side(result: &DiffResult) -> Vec<SideBySideLine> {
561    let lines = result.lines();
562    let mut output = Vec::new();
563    let mut i = 0;
564
565    while i < lines.len() {
566        match lines[i].op {
567            DiffOp::Equal => {
568                let l = &lines[i];
569                output.push(SideBySideLine::new(
570                    Some(SideLine::new(
571                        l.old_line.unwrap(),
572                        DiffOp::Equal,
573                        l.old_range.unwrap(),
574                    )),
575                    Some(SideLine::new(
576                        l.new_line.unwrap(),
577                        DiffOp::Equal,
578                        l.new_range.unwrap(),
579                    )),
580                ));
581                i += 1;
582            }
583            DiffOp::Delete => {
584                let mut deletes = Vec::new();
585                while i < lines.len() && lines[i].op == DiffOp::Delete {
586                    deletes.push(&lines[i]);
587                    i += 1;
588                }
589                let mut inserts = Vec::new();
590                while i < lines.len() && lines[i].op == DiffOp::Insert {
591                    inserts.push(&lines[i]);
592                    i += 1;
593                }
594                let max_len = deletes.len().max(inserts.len());
595                for j in 0..max_len {
596                    let left = deletes.get(j).map(|d| {
597                        SideLine::new(d.old_line.unwrap(), DiffOp::Delete, d.old_range.unwrap())
598                    });
599                    let right = inserts.get(j).map(|ins| {
600                        SideLine::new(
601                            ins.new_line.unwrap(),
602                            DiffOp::Insert,
603                            ins.new_range.unwrap(),
604                        )
605                    });
606                    output.push(SideBySideLine::new(left, right));
607                }
608            }
609            DiffOp::Insert => {
610                let l = &lines[i];
611                output.push(SideBySideLine::new(
612                    None,
613                    Some(SideLine::new(
614                        l.new_line.unwrap(),
615                        DiffOp::Insert,
616                        l.new_range.unwrap(),
617                    )),
618                ));
619                i += 1;
620            }
621        }
622    }
623
624    output
625}
626
627/// Convert a diff result to a list of `TextPatch` values that can be applied to the old text.
628pub fn diff_to_patches(new: &str, result: &DiffResult) -> Result<Vec<TextPatch>, TextPatchError> {
629    let mut patches = Vec::new();
630    let lines = result.lines();
631    let mut i = 0;
632
633    while i < lines.len() {
634        match lines[i].op {
635            DiffOp::Equal => {
636                i += 1;
637            }
638            DiffOp::Delete => {
639                let delete_start = i;
640                while i < lines.len() && lines[i].op == DiffOp::Delete {
641                    i += 1;
642                }
643                let delete_end = i;
644
645                let insert_start = i;
646                while i < lines.len() && lines[i].op == DiffOp::Insert {
647                    i += 1;
648                }
649                let insert_end = i;
650
651                let first_del = &lines[delete_start];
652                let last_del = &lines[delete_end - 1];
653                let del_byte_start = first_del.old_range.unwrap().start();
654                let del_byte_end = last_del.old_range.unwrap().end();
655
656                if insert_start < insert_end {
657                    let first_ins = &lines[insert_start];
658                    let last_ins = &lines[insert_end - 1];
659                    let ins_byte_start = first_ins.new_range.unwrap().start();
660                    let ins_byte_end = last_ins.new_range.unwrap().end();
661                    let replacement = &new[ins_byte_start..ins_byte_end];
662                    patches.push(TextPatch::replace(
663                        del_byte_start,
664                        del_byte_end,
665                        replacement,
666                    )?);
667                } else {
668                    patches.push(TextPatch::delete(del_byte_start, del_byte_end)?);
669                }
670            }
671            DiffOp::Insert => {
672                let insert_start = i;
673                while i < lines.len() && lines[i].op == DiffOp::Insert {
674                    i += 1;
675                }
676                let insert_end = i;
677
678                let first_ins = &lines[insert_start];
679                let last_ins = &lines[insert_end - 1];
680                let ins_byte_start = first_ins.new_range.unwrap().start();
681                let ins_byte_end = last_ins.new_range.unwrap().end();
682                let replacement = &new[ins_byte_start..ins_byte_end];
683
684                let offset = if insert_start > 0 {
685                    let prev = &lines[insert_start - 1];
686                    if let Some(r) = prev.old_range {
687                        r.end()
688                    } else {
689                        0
690                    }
691                } else {
692                    0
693                };
694
695                patches.push(TextPatch::insert(offset, replacement));
696            }
697        }
698    }
699
700    Ok(patches)
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use neco_textpatch::apply_patches;
707
708    #[test]
709    fn diff_empty_texts() {
710        let result = diff("", "");
711        assert!(result.lines().is_empty());
712    }
713
714    #[test]
715    fn diff_identical_texts() {
716        let text = "hello\nworld\n";
717        let result = diff(text, text);
718        assert!(result.lines().iter().all(|l| l.op() == DiffOp::Equal));
719        assert_eq!(result.lines().len(), 2);
720    }
721
722    #[test]
723    fn diff_all_deleted() {
724        let result = diff("a\nb\n", "");
725        assert!(result.lines().iter().all(|l| l.op() == DiffOp::Delete));
726        assert_eq!(result.lines().len(), 2);
727    }
728
729    #[test]
730    fn diff_all_inserted() {
731        let result = diff("", "x\ny\n");
732        assert!(result.lines().iter().all(|l| l.op() == DiffOp::Insert));
733        assert_eq!(result.lines().len(), 2);
734    }
735
736    #[test]
737    fn diff_mixed_changes() {
738        let old = "a\nb\nc\nd\n";
739        let new = "a\nB\nc\nD\n";
740        let result = diff(old, new);
741        let ops: Vec<DiffOp> = result.lines().iter().map(|l| l.op()).collect();
742        assert_eq!(
743            ops,
744            vec![
745                DiffOp::Equal,
746                DiffOp::Delete,
747                DiffOp::Insert,
748                DiffOp::Equal,
749                DiffOp::Delete,
750                DiffOp::Insert,
751            ]
752        );
753    }
754
755    #[test]
756    fn diff_multibyte() {
757        let old = "こんにちは\n世界\n";
758        let new = "こんにちは\n宇宙\n";
759        let result = diff(old, new);
760        assert_eq!(result.lines().len(), 3);
761        assert_eq!(result.lines()[0].op(), DiffOp::Equal);
762        assert_eq!(result.lines()[1].op(), DiffOp::Delete);
763        assert_eq!(result.lines()[2].op(), DiffOp::Insert);
764    }
765
766    #[test]
767    fn to_hunks_splits_by_context() {
768        let old = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n";
769        let new = "1\n2\n3\n4\nFIVE\n6\n7\n8\n9\nTEN\n";
770        let result = diff(old, new);
771        let hunks = result.to_hunks(1);
772        assert_eq!(hunks.len(), 2);
773        assert!(hunks[0].lines().iter().any(|l| l.op() != DiffOp::Equal));
774        assert!(hunks[1].lines().iter().any(|l| l.op() != DiffOp::Equal));
775    }
776
777    #[test]
778    fn to_hunks_merges_close_changes() {
779        let old = "1\n2\n3\n4\n5\n";
780        let new = "ONE\n2\n3\n4\nFIVE\n";
781        let result = diff(old, new);
782        let hunks = result.to_hunks(3);
783        assert_eq!(hunks.len(), 1);
784    }
785
786    #[test]
787    fn diff_intra_line_detects_changes() {
788        let result = diff_intra_line("hello world", "hello there");
789        assert!(!result.ranges().is_empty());
790        let has_delete = result.ranges().iter().any(|r| r.op() == DiffOp::Delete);
791        let has_insert = result.ranges().iter().any(|r| r.op() == DiffOp::Insert);
792        assert!(has_delete);
793        assert!(has_insert);
794    }
795
796    #[test]
797    fn diff_intra_line_multibyte() {
798        let result = diff_intra_line("あいう", "あえう");
799        assert!(!result.ranges().is_empty());
800        let has_delete = result.ranges().iter().any(|r| r.op() == DiffOp::Delete);
801        let has_insert = result.ranges().iter().any(|r| r.op() == DiffOp::Insert);
802        assert!(has_delete);
803        assert!(has_insert);
804    }
805
806    #[test]
807    fn side_by_side_layout() {
808        let old = "a\nb\nc\n";
809        let new = "a\nB\nc\n";
810        let result = diff(old, new);
811        let sbs = to_side_by_side(&result);
812
813        assert!(sbs[0].left().is_some() && sbs[0].right().is_some());
814        assert_eq!(sbs[0].left().unwrap().op(), DiffOp::Equal);
815
816        let delete_line = sbs
817            .iter()
818            .find(|l| l.left().is_some_and(|s| s.op() == DiffOp::Delete));
819        assert!(delete_line.is_some());
820
821        let insert_line = sbs
822            .iter()
823            .find(|l| l.right().is_some_and(|s| s.op() == DiffOp::Insert));
824        assert!(insert_line.is_some());
825    }
826
827    #[test]
828    fn side_by_side_insert_only() {
829        let old = "a\n";
830        let new = "a\nb\n";
831        let result = diff(old, new);
832        let sbs = to_side_by_side(&result);
833        let insert_only = sbs
834            .iter()
835            .find(|l| l.left().is_none() && l.right().is_some());
836        assert!(insert_only.is_some());
837    }
838
839    #[test]
840    fn side_by_side_delete_only() {
841        let old = "a\nb\n";
842        let new = "a\n";
843        let result = diff(old, new);
844        let sbs = to_side_by_side(&result);
845        let delete_only = sbs
846            .iter()
847            .find(|l| l.left().is_some() && l.right().is_none());
848        assert!(delete_only.is_some());
849    }
850
851    fn roundtrip(old: &str, new: &str) {
852        let result = diff(old, new);
853        let patches = diff_to_patches(new, &result).expect("patches should be created");
854        let applied = apply_patches(old, &patches).expect("patches should apply");
855        assert_eq!(applied, new, "roundtrip failed for old={old:?} new={new:?}");
856    }
857
858    #[test]
859    fn roundtrip_empty_to_empty() {
860        roundtrip("", "");
861    }
862
863    #[test]
864    fn roundtrip_identical() {
865        roundtrip("hello\nworld\n", "hello\nworld\n");
866    }
867
868    #[test]
869    fn roundtrip_all_deleted() {
870        roundtrip("a\nb\nc\n", "");
871    }
872
873    #[test]
874    fn roundtrip_all_inserted() {
875        roundtrip("", "x\ny\nz\n");
876    }
877
878    #[test]
879    fn roundtrip_mixed_changes() {
880        roundtrip("a\nb\nc\nd\n", "a\nB\nc\nD\n");
881    }
882
883    #[test]
884    fn roundtrip_insert_at_beginning() {
885        roundtrip("b\nc\n", "a\nb\nc\n");
886    }
887
888    #[test]
889    fn roundtrip_insert_at_end() {
890        roundtrip("a\nb\n", "a\nb\nc\n");
891    }
892
893    #[test]
894    fn roundtrip_delete_at_beginning() {
895        roundtrip("a\nb\nc\n", "b\nc\n");
896    }
897
898    #[test]
899    fn roundtrip_delete_at_end() {
900        roundtrip("a\nb\nc\n", "a\nb\n");
901    }
902
903    #[test]
904    fn roundtrip_multibyte() {
905        roundtrip("こんにちは\n世界\n", "こんにちは\n宇宙\n");
906    }
907
908    #[test]
909    fn roundtrip_complex() {
910        let old = "line1\nline2\nline3\nline4\nline5\n";
911        let new = "line1\nmodified2\nline3\nnew_line\nline5\nextra\n";
912        roundtrip(old, new);
913    }
914
915    #[test]
916    fn roundtrip_no_trailing_newline() {
917        roundtrip("hello\nworld", "hello\nthere");
918    }
919
920    #[test]
921    fn roundtrip_consecutive_deletes_and_inserts() {
922        roundtrip("a\nb\nc\n", "x\ny\nz\n");
923    }
924}