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