Skip to main content

grit_lib/
structured_diff.rs

1//! Structured line diffs for CLI and embedders (`grit diff`, `grit show`).
2//!
3//! Preserves line-ending details (CRLF vs LF, missing newline at EOF), mode-only
4//! changes, hunk line counts, and optional UTF-8 lossy decoding markers — without
5//! relying on unified patch text parsing.
6
7use std::borrow::Cow;
8
9use similar::{ChangeTag, TextDiff};
10
11use crate::diff::options::DiffOptions;
12
13#[path = "diff/line_engine.rs"]
14mod line_engine;
15
16#[cfg(test)]
17pub(crate) use line_engine::structured_hunk_for_id_from_unified_slice;
18pub(crate) use line_engine::{
19    hash_hunk_body_from_unified_slice, hunk_body_digest_from_unified_slice,
20};
21
22/// Bytes of one file side prepared for diffing.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SideBytes {
25    /// Raw file contents.
26    pub data: Vec<u8>,
27    /// `true` when [`SideBytes::text`] used lossy UTF-8 decoding.
28    pub encoding_lossy: bool,
29}
30
31impl SideBytes {
32    /// Decode as text for diffing (NUL in the first 8 KiB ⇒ treat as binary upstream).
33    #[must_use]
34    pub fn from_bytes(data: Vec<u8>) -> Self {
35        let encoding_lossy = !data.is_empty() && std::str::from_utf8(&data).is_err();
36        Self {
37            data,
38            encoding_lossy,
39        }
40    }
41
42    /// Lossy UTF-8 view of [`Self::data`].
43    #[must_use]
44    pub fn text(&self) -> String {
45        String::from_utf8_lossy(&self.data).into_owned()
46    }
47}
48
49/// One run of text within a line, with optional intra-line emphasis.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct StructuredSegment {
52    /// Text for this item.
53    pub text: String,
54    /// Emphasis for this item.
55    pub emphasis: bool,
56}
57
58/// Kind of line in a structured hunk.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum StructuredLineKind {
61    /// Returned when context.
62    Context,
63    /// Returned when add.
64    Add,
65    /// Returned when del.
66    Del,
67}
68
69/// One rendered line in a structured hunk.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct StructuredLine {
72    /// Object type name when known (`commit`, `tree`, etc.).
73    pub kind: StructuredLineKind,
74    /// Old for this item.
75    pub old: Option<usize>,
76    /// New for this item.
77    pub new: Option<usize>,
78    /// Segments for this item.
79    pub segments: Vec<StructuredSegment>,
80    /// Git `\ No newline at end of file` on this side.
81    pub no_newline_at_eof: bool,
82}
83
84/// A contiguous run of changes plus surrounding context.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct StructuredHunk {
87    /// Old start for this item.
88    pub old_start: usize,
89    /// New start for this item.
90    pub new_start: usize,
91    /// Old lines for this item.
92    pub old_lines: usize,
93    /// New lines for this item.
94    pub new_lines: usize,
95    /// Context for this item.
96    pub context: Option<String>,
97    /// Lines for this item.
98    pub lines: Vec<StructuredLine>,
99}
100
101/// Line-level diff of two text sides (no file metadata).
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct StructuredTextDiff {
104    /// Hunks for this item.
105    pub hunks: Vec<StructuredHunk>,
106}
107
108/// File metadata plus a structured line diff.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct StructuredFileDiff {
111    /// Old mode for this item.
112    pub old_mode: Option<String>,
113    /// New mode for this item.
114    pub new_mode: Option<String>,
115    /// Old encoding lossy for this item.
116    pub old_encoding_lossy: bool,
117    /// New encoding lossy for this item.
118    pub new_encoding_lossy: bool,
119    /// Hunks for this item.
120    pub hunks: Vec<StructuredHunk>,
121}
122
123/// Pre-split line bodies for O(1) lookup while building hunks.
124#[derive(Debug, Clone)]
125struct TextLines {
126    lines: Vec<String>,
127}
128
129impl TextLines {
130    fn from_text(text: &str) -> Self {
131        let (parts, _) = lines_with_trailing_newline(text);
132        Self {
133            lines: parts
134                .iter()
135                .map(|s| strip_diff_newline_only(s).to_owned())
136                .collect(),
137        }
138    }
139}
140
141/// Decode side bytes as UTF-8 when valid to avoid an extra allocation on the hot path.
142fn side_text_for_diff(side: &SideBytes) -> Cow<'_, str> {
143    if side.data.is_empty() {
144        return Cow::Borrowed("");
145    }
146    match std::str::from_utf8(&side.data) {
147        Ok(text) => Cow::Borrowed(text),
148        Err(_) => Cow::Owned(String::from_utf8_lossy(&side.data).into_owned()),
149    }
150}
151
152/// Like [`structured_text_diff`] but accepts borrowed text when sides are valid UTF-8.
153fn structured_text_diff_cow(
154    old_text: Cow<'_, str>,
155    new_text: Cow<'_, str>,
156    options: DiffOptions,
157) -> StructuredTextDiff {
158    structured_text_diff(old_text.as_ref(), new_text.as_ref(), options)
159}
160
161/// Build a structured line diff from two decoded text sides.
162#[must_use]
163pub fn structured_text_diff(
164    old_text: &str,
165    new_text: &str,
166    options: DiffOptions,
167) -> StructuredTextDiff {
168    let mut hunks = line_engine::line_hunks(old_text, new_text, &options, options.function_context);
169    if options.word_level_inline {
170        apply_word_level_inline(&mut hunks, old_text, new_text);
171    }
172    StructuredTextDiff { hunks }
173}
174
175fn apply_word_level_inline(hunks: &mut [StructuredHunk], old_text: &str, new_text: &str) {
176    let old = TextLines::from_text(old_text);
177    let new = TextLines::from_text(new_text);
178    let old_snapshot = old.lines.clone();
179    let new_snapshot = new.lines.clone();
180
181    for hunk in &mut *hunks {
182        let mut i = 0usize;
183        while i < hunk.lines.len() {
184            if hunk.lines[i].kind != StructuredLineKind::Del {
185                i += 1;
186                continue;
187            }
188            let del_start = i;
189            while i < hunk.lines.len() && hunk.lines[i].kind == StructuredLineKind::Del {
190                i += 1;
191            }
192            let add_start = i;
193            while i < hunk.lines.len() && hunk.lines[i].kind == StructuredLineKind::Add {
194                i += 1;
195            }
196            let del_end = i.min(add_start);
197            let add_end = i;
198            if del_end > del_start && add_end > add_start {
199                let del_count = del_end - del_start;
200                let add_count = add_end - add_start;
201                let pairs = del_count.min(add_count);
202                for p in 0..pairs {
203                    let del_idx = del_start + p;
204                    let add_idx = add_start + p;
205                    let (left, right) = hunk.lines.split_at_mut(add_idx);
206                    let del_line = &mut left[del_idx];
207                    let add_line = &mut right[0];
208                    emphasize_replace_line_pair(del_line, add_line);
209                }
210            }
211        }
212    }
213
214    pin_canonical_line_text(hunks, &old_snapshot, &new_snapshot);
215}
216
217fn emphasize_replace_line_pair(del: &mut StructuredLine, add: &mut StructuredLine) {
218    let old_line = del.segments[0].text.clone();
219    let new_line = add.segments[0].text.clone();
220    let diff = TextDiff::from_words(&old_line, &new_line);
221    del.segments = word_segments_from_diff(&diff, &old_line, true);
222    add.segments = word_segments_from_diff(&diff, &new_line, false);
223}
224
225fn word_segments_from_diff(
226    diff: &TextDiff<'_, '_, str>,
227    full_line: &str,
228    old_side: bool,
229) -> Vec<StructuredSegment> {
230    let mut segments: Vec<StructuredSegment> = Vec::new();
231    for change in diff.iter_all_changes() {
232        let is_this_side = match change.tag() {
233            ChangeTag::Delete => old_side,
234            ChangeTag::Insert => !old_side,
235            ChangeTag::Equal => true,
236        };
237        if !is_this_side {
238            continue;
239        }
240        let emphasis = change.tag() != ChangeTag::Equal;
241        let text = change.value().to_owned();
242        if text.is_empty() {
243            continue;
244        }
245        if let Some(last) = segments.last_mut() {
246            if last.emphasis == emphasis {
247                last.text.push_str(&text);
248                continue;
249            }
250        }
251        segments.push(StructuredSegment { text, emphasis });
252    }
253    if segments.is_empty() && !full_line.is_empty() {
254        segments.push(StructuredSegment {
255            text: full_line.to_owned(),
256            emphasis: false,
257        });
258    }
259    reconcile_segments_with_line(&mut segments, full_line);
260    segments
261}
262
263/// One O(n) pass: `similar` may drop `\r` in inline segments; restore from precomputed lines.
264fn pin_canonical_line_text(
265    hunks: &mut [StructuredHunk],
266    old_lines: &[String],
267    new_lines: &[String],
268) {
269    for hunk in hunks {
270        for line in &mut hunk.lines {
271            let canonical = match line.kind {
272                StructuredLineKind::Del => line
273                    .old
274                    .and_then(|n| old_lines.get(n.saturating_sub(1)).cloned()),
275                StructuredLineKind::Add => line
276                    .new
277                    .and_then(|n| new_lines.get(n.saturating_sub(1)).cloned()),
278                StructuredLineKind::Context => line
279                    .old
280                    .and_then(|n| old_lines.get(n.saturating_sub(1)).cloned()),
281            };
282            let Some(canonical) = canonical else {
283                continue;
284            };
285            if line.kind == StructuredLineKind::Del {
286                let emphasis = line.segments.iter().any(|s| s.emphasis);
287                line.segments = vec![StructuredSegment {
288                    text: canonical,
289                    emphasis,
290                }];
291                continue;
292            }
293            let rendered: String = line.segments.iter().map(|s| s.text.as_str()).collect();
294            if rendered != canonical {
295                let emphasis = line.segments.iter().any(|s| s.emphasis);
296                line.segments = vec![StructuredSegment {
297                    text: canonical,
298                    emphasis,
299                }];
300            }
301        }
302    }
303}
304
305/// Build a structured file diff including mode metadata from a tree diff entry.
306#[must_use]
307pub fn structured_file_diff(
308    old_mode: &str,
309    new_mode: &str,
310    old: &SideBytes,
311    new: &SideBytes,
312    options: DiffOptions,
313) -> StructuredFileDiff {
314    let old_mode = mode_display(old_mode);
315    let new_mode = mode_display(new_mode);
316    let old_text = side_text_for_diff(old);
317    let new_text = side_text_for_diff(new);
318    let hunks = structured_text_diff_cow(old_text, new_text, options).hunks;
319    StructuredFileDiff {
320        old_mode,
321        new_mode,
322        old_encoding_lossy: old.encoding_lossy,
323        new_encoding_lossy: new.encoding_lossy,
324        hunks,
325    }
326}
327
328fn mode_display(mode: &str) -> Option<String> {
329    if mode == "000000" {
330        None
331    } else {
332        Some(mode.to_owned())
333    }
334}
335
336/// Split on `\n` only so `\r` stays inside line bodies (Git CRLF parity).
337fn lines_with_trailing_newline(text: &str) -> (Vec<&str>, bool) {
338    if text.is_empty() {
339        return (Vec::new(), false);
340    }
341    let ends_nl = text.ends_with('\n');
342    let mut parts: Vec<&str> = text.split('\n').collect();
343    if ends_nl && parts.last() == Some(&"") {
344        parts.pop();
345    }
346    (parts, ends_nl)
347}
348
349fn strip_diff_newline_only(s: &str) -> &str {
350    s.strip_suffix('\n').unwrap_or(s)
351}
352
353/// Old-side line bodies for structured hunk metadata (CRLF-safe split).
354pub(crate) fn old_line_bodies_for_context(text: &str) -> Vec<String> {
355    TextLines::from_text(text).lines
356}
357
358/// Nearest enclosing definition line above a 1-based old hunk start (Git `-W` style).
359pub(crate) fn enclosing_function_context(
360    lines: &[String],
361    old_start_1based: usize,
362) -> Option<String> {
363    if old_start_1based == 0 {
364        return None;
365    }
366    function_context(lines, old_start_1based.saturating_sub(1))
367}
368
369fn function_context(lines: &[String], start_0based: usize) -> Option<String> {
370    lines[..start_0based.min(lines.len())]
371        .iter()
372        .rev()
373        .find_map(|line| {
374            let first = line.chars().next()?;
375            (first.is_alphabetic() || first == '_').then(|| line.trim_end().to_owned())
376        })
377}
378
379fn reconcile_segments_with_line(segments: &mut Vec<StructuredSegment>, full_line: &str) {
380    if full_line.is_empty() {
381        return;
382    }
383    let rendered: String = segments.iter().map(|s| s.text.as_str()).collect();
384    if rendered != full_line {
385        let emphasis = segments.iter().any(|s| s.emphasis);
386        segments.clear();
387        segments.push(StructuredSegment {
388            text: full_line.to_owned(),
389            emphasis,
390        });
391    }
392}
393
394/// Format one unified hunk range side (`1`, `1,2`, or `0,0`).
395#[must_use]
396pub fn format_hunk_range(start: usize, count: usize) -> String {
397    if count == 0 {
398        "0,0".to_owned()
399    } else if count == 1 {
400        start.to_string()
401    } else {
402        format!("{start},{count}")
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use std::process::Command;
410
411    fn git_diff(old: &str, new: &str) -> String {
412        let tmp = tempfile::tempdir().expect("tempdir");
413        let path = tmp.path().join("f.txt");
414        std::fs::write(&path, old).expect("write old");
415        Command::new("git")
416            .args(["init", "-q", "-b", "main"])
417            .current_dir(tmp.path())
418            .status()
419            .expect("git init");
420        Command::new("git")
421            .args(["config", "user.email", "t@e.com"])
422            .current_dir(tmp.path())
423            .status()
424            .unwrap();
425        Command::new("git")
426            .args(["config", "user.name", "T"])
427            .current_dir(tmp.path())
428            .status()
429            .unwrap();
430        Command::new("git")
431            .args(["add", "f.txt"])
432            .current_dir(tmp.path())
433            .status()
434            .unwrap();
435        Command::new("git")
436            .args(["commit", "-qm", "init"])
437            .current_dir(tmp.path())
438            .status()
439            .unwrap();
440        std::fs::write(&path, new).expect("write new");
441        let out = Command::new("git")
442            .args(["diff", "f.txt"])
443            .current_dir(tmp.path())
444            .output()
445            .expect("git diff");
446        String::from_utf8(out.stdout).expect("utf8")
447    }
448
449    fn parse_hunk_header(patch: &str) -> (usize, usize, usize, usize) {
450        let line = patch.lines().find(|l| l.starts_with("@@")).expect("hunk");
451        let rest = line.strip_prefix("@@ ").expect("prefix");
452        let (old_part, new_part) = rest.split_once(" +").expect("plus");
453        let old_part = old_part.strip_prefix('-').expect("minus");
454        let (os, ol) = parse_range(old_part.split(' ').next().expect("old"));
455        let new_part = new_part.split(" @@").next().expect("close");
456        let (ns, nl) = parse_range(new_part);
457        (os, ol, ns, nl)
458    }
459
460    fn parse_range(s: &str) -> (usize, usize) {
461        if let Some((a, b)) = s.split_once(',') {
462            (a.parse().expect("start"), b.parse().expect("count"))
463        } else {
464            (s.parse().expect("start"), 1)
465        }
466    }
467
468    #[test]
469    fn lines_with_trailing_newline_splits_crlf() {
470        let (parts, ends) = lines_with_trailing_newline("a\r\nb\r\n");
471        assert!(ends);
472        assert_eq!(parts, ["a\r", "b\r"]);
473    }
474
475    #[test]
476    fn text_lines_from_text_preserves_cr() {
477        let t = TextLines::from_text("a\r\nb\r\n");
478        assert_eq!(t.lines[0], "a\r");
479    }
480
481    #[test]
482    fn crlf_to_lf_shows_carriage_return_on_removed_lines() {
483        let old = "a\r\nb\r\n";
484        let new = "a\nb\n";
485        let d = structured_text_diff(old, new, DiffOptions::default());
486        let del: Vec<_> = d.hunks[0]
487            .lines
488            .iter()
489            .filter(|l| l.kind == StructuredLineKind::Del)
490            .collect();
491        assert_eq!(del[0].segments[0].text, "a\r");
492    }
493
494    #[test]
495    fn trailing_newline_only_change_sets_no_newline_flag() {
496        let old = "line1\nline2";
497        let new = "line1\nline2\n";
498        let d = structured_text_diff(old, new, DiffOptions::default());
499        let del = d.hunks[0]
500            .lines
501            .iter()
502            .find(|l| l.kind == StructuredLineKind::Del)
503            .expect("deleted line2");
504        assert!(del.no_newline_at_eof);
505    }
506
507    #[test]
508    fn context_last_line_without_eof_newline_is_marked() {
509        let old = "old\nlast";
510        let new = "new\nlast";
511        let d = structured_text_diff(old, new, DiffOptions::default());
512        let ctx = d.hunks[0]
513            .lines
514            .iter()
515            .find(|l| l.kind == StructuredLineKind::Context && l.segments[0].text == "last")
516            .expect("context last");
517        assert!(ctx.no_newline_at_eof);
518    }
519
520    fn git_diff_u0(old: &str, new: &str) -> String {
521        let tmp = tempfile::tempdir().expect("tempdir");
522        let path = tmp.path().join("f.txt");
523        std::fs::write(&path, old).expect("write old");
524        Command::new("git")
525            .args(["init", "-q", "-b", "main"])
526            .current_dir(tmp.path())
527            .status()
528            .expect("git init");
529        for (k, v) in [("user.email", "t@e.com"), ("user.name", "T")] {
530            Command::new("git")
531                .args(["config", k, v])
532                .current_dir(tmp.path())
533                .status()
534                .unwrap();
535        }
536        Command::new("git")
537            .args(["add", "f.txt"])
538            .current_dir(tmp.path())
539            .status()
540            .unwrap();
541        Command::new("git")
542            .args(["commit", "-qm", "init"])
543            .current_dir(tmp.path())
544            .status()
545            .unwrap();
546        std::fs::write(&path, new).expect("write new");
547        let out = Command::new("git")
548            .args(["diff", "-U0", "f.txt"])
549            .current_dir(tmp.path())
550            .output()
551            .expect("git diff");
552        String::from_utf8(out.stdout).expect("utf8")
553    }
554
555    #[test]
556    fn zero_context_insertion_anchor_matches_git() {
557        let old = "a\nb\nc\n";
558        let new = "a\nb\nX\nc\n";
559        let patch = git_diff_u0(old, new);
560        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
561        let d = structured_text_diff(
562            old,
563            new,
564            DiffOptions {
565                context_lines: 0,
566                ..DiffOptions::default()
567            },
568        );
569        let h = &d.hunks[0];
570        assert_eq!(
571            (h.old_start, h.old_lines, h.new_start, h.new_lines),
572            (g_os, g_ol, g_ns, g_nl)
573        );
574        assert_eq!((h.old_start, h.old_lines), (2, 0));
575    }
576
577    #[test]
578    fn zero_context_deletion_anchor_matches_git() {
579        let old = "a\nb\nc\n";
580        let new = "a\nc\n";
581        let patch = git_diff_u0(old, new);
582        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
583        let d = structured_text_diff(
584            old,
585            new,
586            DiffOptions {
587                context_lines: 0,
588                ..DiffOptions::default()
589            },
590        );
591        let h = &d.hunks[0];
592        assert_eq!(
593            (h.old_start, h.old_lines, h.new_start, h.new_lines),
594            (g_os, g_ol, g_ns, g_nl)
595        );
596        assert_eq!((h.new_start, h.new_lines), (1, 0));
597    }
598
599    #[test]
600    fn structured_hunk_retains_enclosing_function_context() {
601        let old = "fn foo() {\n  let x = 1;\n}\n";
602        let new = "fn foo() {\n  let x = 2;\n}\n";
603        let mut opts = DiffOptions::default();
604        opts.function_context = true;
605        let d = structured_text_diff(old, new, opts);
606        let h = &d.hunks[0];
607        assert_eq!(h.context.as_deref(), Some("fn foo() {"));
608    }
609
610    #[test]
611    fn insert_into_empty_file_matches_git_hunk_header() {
612        let patch = git_diff("", "x\n");
613        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
614        let d = structured_text_diff("", "x\n", DiffOptions::default());
615        let h = &d.hunks[0];
616        assert_eq!(
617            (h.old_start, h.old_lines, h.new_start, h.new_lines),
618            (g_os, g_ol, g_ns, g_nl)
619        );
620        assert_eq!((h.old_start, h.old_lines), (0, 0));
621    }
622
623    #[test]
624    fn delete_to_empty_matches_git_hunk_header() {
625        let patch = git_diff("x\n", "");
626        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
627        let d = structured_text_diff("x\n", "", DiffOptions::default());
628        let h = &d.hunks[0];
629        assert_eq!(
630            (h.old_start, h.old_lines, h.new_start, h.new_lines),
631            (g_os, g_ol, g_ns, g_nl)
632        );
633        assert_eq!((h.new_start, h.new_lines), (0, 0));
634    }
635
636    #[test]
637    fn line_level_multiple_hunks_match_git() {
638        let old = "keep\nchange-a\nmid\nchange-b\nend\n";
639        let new = "keep\nCHANGE-A\nmid\nCHANGE-B\nend\n";
640        let patch = git_diff_u0(old, new);
641        let git_hunks: Vec<_> = patch.lines().filter(|l| l.starts_with("@@")).collect();
642        assert_eq!(git_hunks.len(), 2);
643        let d = structured_text_diff(
644            old,
645            new,
646            DiffOptions {
647                context_lines: 0,
648                word_level_inline: false,
649                ..DiffOptions::default()
650            },
651        );
652        assert_eq!(d.hunks.len(), 2);
653        for (h, header) in d.hunks.iter().zip(git_hunks) {
654            let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&format!("{header}\n"));
655            assert_eq!(
656                (h.old_start, h.old_lines, h.new_start, h.new_lines),
657                (g_os, g_ol, g_ns, g_nl)
658            );
659        }
660    }
661
662    #[test]
663    fn line_level_blank_line_in_hunk() {
664        let old = "a\n\nb\n";
665        let new = "a\nX\n\nb\n";
666        let d = structured_text_diff(
667            old,
668            new,
669            DiffOptions {
670                context_lines: 1,
671                word_level_inline: false,
672                ..DiffOptions::default()
673            },
674        );
675        assert!(!d.hunks.is_empty());
676        assert!(
677            d.hunks[0]
678                .lines
679                .iter()
680                .any(|l| l.segments[0].text.is_empty() && l.kind == StructuredLineKind::Context)
681                || d.hunks[0]
682                    .lines
683                    .iter()
684                    .any(|l| l.segments[0].text.is_empty())
685        );
686    }
687
688    #[test]
689    fn line_level_missing_eof_newline_flag() {
690        let old = "only";
691        let new = "only\n";
692        let d = structured_text_diff(
693            old,
694            new,
695            DiffOptions {
696                context_lines: 0,
697                word_level_inline: false,
698                ..DiffOptions::default()
699            },
700        );
701        let h = &d.hunks[0];
702        let add = h
703            .lines
704            .iter()
705            .find(|l| l.kind == StructuredLineKind::Add)
706            .expect("add line");
707        assert!(add.no_newline_at_eof || h.lines.iter().any(|l| l.no_newline_at_eof));
708    }
709
710    #[test]
711    fn hunk_header_counts_match_line_totals() {
712        let old = "x\n";
713        let new = "x\nmore\n";
714        let d = structured_text_diff(
715            old,
716            new,
717            DiffOptions {
718                context_lines: 3,
719                ..DiffOptions::default()
720            },
721        );
722        let h = &d.hunks[0];
723        assert_eq!(h.old_lines, 1);
724        assert_eq!(h.new_lines, 2);
725    }
726
727    #[test]
728    fn mode_only_change_has_empty_hunks() {
729        let old = SideBytes::from_bytes(b"x\n".to_vec());
730        let new = SideBytes::from_bytes(b"x\n".to_vec());
731        let f = structured_file_diff("100644", "100755", &old, &new, DiffOptions::default());
732        assert!(f.hunks.is_empty());
733    }
734}