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<'a> {
127    lines: Vec<&'a str>,
128}
129
130impl<'a> TextLines<'a> {
131    fn from_text(text: &'a str) -> Self {
132        let (parts, _) = lines_with_trailing_newline(text);
133        Self {
134            lines: parts.into_iter().map(strip_diff_newline_only).collect(),
135        }
136    }
137}
138
139/// Decode blob bytes for text diffing and patch-ID hashing (borrow when valid UTF-8).
140pub(crate) fn text_bytes_for_diff(data: &[u8]) -> Cow<'_, str> {
141    if data.is_empty() {
142        return Cow::Borrowed("");
143    }
144    match std::str::from_utf8(data) {
145        Ok(text) => Cow::Borrowed(text),
146        Err(_) => Cow::Owned(String::from_utf8_lossy(data).into_owned()),
147    }
148}
149
150/// Decode side bytes as UTF-8 when valid to avoid an extra allocation on the hot path.
151fn side_text_for_diff(side: &SideBytes) -> Cow<'_, str> {
152    text_bytes_for_diff(&side.data)
153}
154
155/// Like [`structured_text_diff`] but accepts borrowed text when sides are valid UTF-8.
156fn structured_text_diff_cow(
157    old_text: Cow<'_, str>,
158    new_text: Cow<'_, str>,
159    options: DiffOptions,
160) -> StructuredTextDiff {
161    structured_text_diff(old_text.as_ref(), new_text.as_ref(), options)
162}
163
164/// Build a structured line diff from two decoded text sides.
165#[must_use]
166pub fn structured_text_diff(
167    old_text: &str,
168    new_text: &str,
169    options: DiffOptions,
170) -> StructuredTextDiff {
171    let mut hunks = line_engine::line_hunks(old_text, new_text, &options, options.function_context);
172    if options.word_level_inline {
173        apply_word_level_inline(&mut hunks, old_text, new_text);
174    }
175    StructuredTextDiff { hunks }
176}
177
178fn apply_word_level_inline(hunks: &mut [StructuredHunk], old_text: &str, new_text: &str) {
179    let old = TextLines::from_text(old_text);
180    let new = TextLines::from_text(new_text);
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.lines, &new.lines);
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(hunks: &mut [StructuredHunk], old_lines: &[&str], new_lines: &[&str]) {
266    for hunk in hunks {
267        for line in &mut hunk.lines {
268            let canonical = match line.kind {
269                StructuredLineKind::Del | StructuredLineKind::Context => line
270                    .old
271                    .and_then(|n| old_lines.get(n.saturating_sub(1)).copied()),
272                StructuredLineKind::Add => line
273                    .new
274                    .and_then(|n| new_lines.get(n.saturating_sub(1)).copied()),
275            };
276            let Some(canonical) = canonical else {
277                continue;
278            };
279            // Deleted lines always end up as exactly one segment.
280            let collapse = line.kind == StructuredLineKind::Del && line.segments.len() != 1;
281            if collapse || !segments_render_as(&line.segments, canonical) {
282                let emphasis = line.segments.iter().any(|s| s.emphasis);
283                line.segments = vec![StructuredSegment {
284                    text: canonical.to_owned(),
285                    emphasis,
286                }];
287            }
288        }
289    }
290}
291
292/// Whether the concatenated segment texts equal `text`, without allocating.
293fn segments_render_as(segments: &[StructuredSegment], text: &str) -> bool {
294    let mut rest = text;
295    for segment in segments {
296        match rest.strip_prefix(segment.text.as_str()) {
297            Some(tail) => rest = tail,
298            None => return false,
299        }
300    }
301    rest.is_empty()
302}
303
304/// Build a structured file diff including mode metadata from a tree diff entry.
305#[must_use]
306pub fn structured_file_diff(
307    old_mode: FileMode,
308    new_mode: FileMode,
309    old: &SideBytes,
310    new: &SideBytes,
311    options: DiffOptions,
312) -> StructuredFileDiff {
313    let old_mode = old_mode.octal_if_present();
314    let new_mode = new_mode.octal_if_present();
315    let old_text = side_text_for_diff(old);
316    let new_text = side_text_for_diff(new);
317    let hunks = structured_text_diff_cow(old_text, new_text, options).hunks;
318    StructuredFileDiff {
319        old_mode,
320        new_mode,
321        old_encoding_lossy: old.encoding_lossy,
322        new_encoding_lossy: new.encoding_lossy,
323        hunks,
324    }
325}
326
327/// Split on `\n` only so `\r` stays inside line bodies (Git CRLF parity).
328fn lines_with_trailing_newline(text: &str) -> (Vec<&str>, bool) {
329    if text.is_empty() {
330        return (Vec::new(), false);
331    }
332    let ends_nl = text.ends_with('\n');
333    let mut parts: Vec<&str> = text.split('\n').collect();
334    if ends_nl && parts.last() == Some(&"") {
335        parts.pop();
336    }
337    (parts, ends_nl)
338}
339
340fn strip_diff_newline_only(s: &str) -> &str {
341    s.strip_suffix('\n').unwrap_or(s)
342}
343
344/// Old-side line bodies for structured hunk metadata (CRLF-safe split).
345pub(crate) fn old_line_bodies_for_context(text: &str) -> Vec<&str> {
346    TextLines::from_text(text).lines
347}
348
349/// Nearest enclosing definition line above a 1-based old hunk start (Git `-W` style).
350pub(crate) fn enclosing_function_context(
351    lines: &[&str],
352    old_start_1based: usize,
353) -> Option<String> {
354    if old_start_1based == 0 {
355        return None;
356    }
357    function_context(lines, old_start_1based.saturating_sub(1))
358}
359
360fn function_context(lines: &[&str], start_0based: usize) -> Option<String> {
361    lines[..start_0based.min(lines.len())]
362        .iter()
363        .rev()
364        .find_map(|line| {
365            let first = line.chars().next()?;
366            (first.is_alphabetic() || first == '_').then(|| line.trim_end().to_owned())
367        })
368}
369
370fn reconcile_segments_with_line(segments: &mut Vec<StructuredSegment>, full_line: &str) {
371    if full_line.is_empty() {
372        return;
373    }
374    let rendered: String = segments.iter().map(|s| s.text.as_str()).collect();
375    if rendered != full_line {
376        let emphasis = segments.iter().any(|s| s.emphasis);
377        segments.clear();
378        segments.push(StructuredSegment {
379            text: full_line.to_owned(),
380            emphasis,
381        });
382    }
383}
384
385/// Format one unified hunk range side (`1`, `1,2`, or `0,0`).
386#[must_use]
387pub fn format_hunk_range(start: usize, count: usize) -> String {
388    if count == 0 {
389        "0,0".to_owned()
390    } else if count == 1 {
391        start.to_string()
392    } else {
393        format!("{start},{count}")
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use std::process::Command;
401
402    fn git_diff(old: &str, new: &str) -> String {
403        let tmp = tempfile::tempdir().expect("tempdir");
404        let path = tmp.path().join("f.txt");
405        std::fs::write(&path, old).expect("write old");
406        Command::new("git")
407            .args(["init", "-q", "-b", "main"])
408            .current_dir(tmp.path())
409            .status()
410            .expect("git init");
411        Command::new("git")
412            .args(["config", "user.email", "t@e.com"])
413            .current_dir(tmp.path())
414            .status()
415            .unwrap();
416        Command::new("git")
417            .args(["config", "user.name", "T"])
418            .current_dir(tmp.path())
419            .status()
420            .unwrap();
421        Command::new("git")
422            .args(["add", "f.txt"])
423            .current_dir(tmp.path())
424            .status()
425            .unwrap();
426        Command::new("git")
427            .args(["commit", "-qm", "init"])
428            .current_dir(tmp.path())
429            .status()
430            .unwrap();
431        std::fs::write(&path, new).expect("write new");
432        let out = Command::new("git")
433            .args(["diff", "f.txt"])
434            .current_dir(tmp.path())
435            .output()
436            .expect("git diff");
437        String::from_utf8(out.stdout).expect("utf8")
438    }
439
440    fn parse_hunk_header(patch: &str) -> (usize, usize, usize, usize) {
441        let line = patch.lines().find(|l| l.starts_with("@@")).expect("hunk");
442        let rest = line.strip_prefix("@@ ").expect("prefix");
443        let (old_part, new_part) = rest.split_once(" +").expect("plus");
444        let old_part = old_part.strip_prefix('-').expect("minus");
445        let (os, ol) = parse_range(old_part.split(' ').next().expect("old"));
446        let new_part = new_part.split(" @@").next().expect("close");
447        let (ns, nl) = parse_range(new_part);
448        (os, ol, ns, nl)
449    }
450
451    fn parse_range(s: &str) -> (usize, usize) {
452        if let Some((a, b)) = s.split_once(',') {
453            (a.parse().expect("start"), b.parse().expect("count"))
454        } else {
455            (s.parse().expect("start"), 1)
456        }
457    }
458
459    #[test]
460    fn lines_with_trailing_newline_splits_crlf() {
461        let (parts, ends) = lines_with_trailing_newline("a\r\nb\r\n");
462        assert!(ends);
463        assert_eq!(parts, ["a\r", "b\r"]);
464    }
465
466    #[test]
467    fn text_lines_from_text_preserves_cr() {
468        let t = TextLines::from_text("a\r\nb\r\n");
469        assert_eq!(t.lines[0], "a\r");
470    }
471
472    #[test]
473    fn crlf_to_lf_shows_carriage_return_on_removed_lines() {
474        let old = "a\r\nb\r\n";
475        let new = "a\nb\n";
476        let d = structured_text_diff(old, new, DiffOptions::default());
477        let del: Vec<_> = d.hunks[0]
478            .lines
479            .iter()
480            .filter(|l| l.kind == StructuredLineKind::Del)
481            .collect();
482        assert_eq!(del[0].segments[0].text, "a\r");
483    }
484
485    #[test]
486    fn trailing_newline_only_change_sets_no_newline_flag() {
487        let old = "line1\nline2";
488        let new = "line1\nline2\n";
489        let d = structured_text_diff(old, new, DiffOptions::default());
490        let del = d.hunks[0]
491            .lines
492            .iter()
493            .find(|l| l.kind == StructuredLineKind::Del)
494            .expect("deleted line2");
495        assert!(del.no_newline_at_eof);
496    }
497
498    #[test]
499    fn context_last_line_without_eof_newline_is_marked() {
500        let old = "old\nlast";
501        let new = "new\nlast";
502        let d = structured_text_diff(old, new, DiffOptions::default());
503        let ctx = d.hunks[0]
504            .lines
505            .iter()
506            .find(|l| l.kind == StructuredLineKind::Context && l.segments[0].text == "last")
507            .expect("context last");
508        assert!(ctx.no_newline_at_eof);
509    }
510
511    #[test]
512    fn segments_render_as_compares_concatenation() {
513        let seg = |text: &str| StructuredSegment {
514            text: text.into(),
515            emphasis: false,
516        };
517        assert!(segments_render_as(&[], ""));
518        assert!(!segments_render_as(&[], "a"));
519        assert!(segments_render_as(&[seg("ab"), seg("c")], "abc"));
520        assert!(!segments_render_as(&[seg("ab"), seg("c")], "abcd"));
521        assert!(!segments_render_as(&[seg("ab"), seg("x")], "abc"));
522        assert!(!segments_render_as(&[seg("abcd")], "abc"));
523    }
524
525    fn line_text(line: &StructuredLine) -> String {
526        line.segments.iter().map(|s| s.text.as_str()).collect()
527    }
528
529    #[test]
530    fn word_level_inline_keeps_canonical_line_text() {
531        let old = "keep\nfoo bar baz\n\nend\n";
532        let new = "keep\nfoo qux baz\nfilled\nend\n";
533        let plain = structured_text_diff(
534            old,
535            new,
536            DiffOptions::default().with_word_level_inline(false),
537        );
538        let inline = structured_text_diff(
539            old,
540            new,
541            DiffOptions::default().with_word_level_inline(true),
542        );
543        assert_eq!(plain.hunks.len(), inline.hunks.len());
544        for (p, i) in plain.hunks.iter().zip(&inline.hunks) {
545            assert_eq!(p.lines.len(), i.lines.len());
546            for (pl, il) in p.lines.iter().zip(&i.lines) {
547                assert_eq!((pl.kind, pl.old, pl.new), (il.kind, il.old, il.new));
548                assert_eq!(line_text(pl), line_text(il));
549                if il.kind == StructuredLineKind::Del {
550                    assert_eq!(
551                        il.segments.len(),
552                        1,
553                        "deleted lines collapse to one segment"
554                    );
555                }
556            }
557        }
558        let dels: Vec<_> = inline.hunks[0]
559            .lines
560            .iter()
561            .filter(|l| l.kind == StructuredLineKind::Del)
562            .collect();
563        assert_eq!(line_text(dels[0]), "foo bar baz");
564        assert!(dels[0].segments[0].emphasis);
565        assert_eq!(line_text(dels[1]), "");
566        let add = inline.hunks[0]
567            .lines
568            .iter()
569            .find(|l| l.kind == StructuredLineKind::Add)
570            .expect("added line");
571        assert_eq!(line_text(add), "foo qux baz");
572        assert!(add.segments.len() > 1, "added line keeps word segments");
573    }
574
575    fn git_diff_u0(old: &str, new: &str) -> String {
576        let tmp = tempfile::tempdir().expect("tempdir");
577        let path = tmp.path().join("f.txt");
578        std::fs::write(&path, old).expect("write old");
579        Command::new("git")
580            .args(["init", "-q", "-b", "main"])
581            .current_dir(tmp.path())
582            .status()
583            .expect("git init");
584        for (k, v) in [("user.email", "t@e.com"), ("user.name", "T")] {
585            Command::new("git")
586                .args(["config", k, v])
587                .current_dir(tmp.path())
588                .status()
589                .unwrap();
590        }
591        Command::new("git")
592            .args(["add", "f.txt"])
593            .current_dir(tmp.path())
594            .status()
595            .unwrap();
596        Command::new("git")
597            .args(["commit", "-qm", "init"])
598            .current_dir(tmp.path())
599            .status()
600            .unwrap();
601        std::fs::write(&path, new).expect("write new");
602        let out = Command::new("git")
603            .args(["diff", "-U0", "f.txt"])
604            .current_dir(tmp.path())
605            .output()
606            .expect("git diff");
607        String::from_utf8(out.stdout).expect("utf8")
608    }
609
610    #[cfg_attr(
611        not(feature = "full-tests"),
612        ignore = "starts system git or is heavy; run with --features full-tests"
613    )]
614    #[test]
615    fn zero_context_insertion_anchor_matches_git() {
616        let old = "a\nb\nc\n";
617        let new = "a\nb\nX\nc\n";
618        let patch = git_diff_u0(old, new);
619        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
620        let d = structured_text_diff(
621            old,
622            new,
623            DiffOptions {
624                context_lines: 0,
625                ..DiffOptions::default()
626            },
627        );
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.old_start, h.old_lines), (2, 0));
634    }
635
636    #[cfg_attr(
637        not(feature = "full-tests"),
638        ignore = "starts system git or is heavy; run with --features full-tests"
639    )]
640    #[test]
641    fn zero_context_deletion_anchor_matches_git() {
642        let old = "a\nb\nc\n";
643        let new = "a\nc\n";
644        let patch = git_diff_u0(old, new);
645        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
646        let d = structured_text_diff(
647            old,
648            new,
649            DiffOptions {
650                context_lines: 0,
651                ..DiffOptions::default()
652            },
653        );
654        let h = &d.hunks[0];
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        assert_eq!((h.new_start, h.new_lines), (1, 0));
660    }
661
662    #[cfg_attr(
663        not(feature = "full-tests"),
664        ignore = "starts system git or is heavy; run with --features full-tests"
665    )]
666    #[test]
667    fn structured_hunk_retains_enclosing_function_context() {
668        let old = "fn foo() {\n  let x = 1;\n}\n";
669        let new = "fn foo() {\n  let x = 2;\n}\n";
670        let mut opts = DiffOptions::default();
671        opts.function_context = true;
672        let d = structured_text_diff(old, new, opts);
673        let h = &d.hunks[0];
674        assert_eq!(h.context.as_deref(), Some("fn foo() {"));
675    }
676
677    #[cfg_attr(
678        not(feature = "full-tests"),
679        ignore = "starts system git or is heavy; run with --features full-tests"
680    )]
681    #[test]
682    fn insert_into_empty_file_matches_git_hunk_header() {
683        let patch = git_diff("", "x\n");
684        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
685        let d = structured_text_diff("", "x\n", DiffOptions::default());
686        let h = &d.hunks[0];
687        assert_eq!(
688            (h.old_start, h.old_lines, h.new_start, h.new_lines),
689            (g_os, g_ol, g_ns, g_nl)
690        );
691        assert_eq!((h.old_start, h.old_lines), (0, 0));
692    }
693
694    #[cfg_attr(
695        not(feature = "full-tests"),
696        ignore = "starts system git or is heavy; run with --features full-tests"
697    )]
698    #[test]
699    fn delete_to_empty_matches_git_hunk_header() {
700        let patch = git_diff("x\n", "");
701        let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
702        let d = structured_text_diff("x\n", "", DiffOptions::default());
703        let h = &d.hunks[0];
704        assert_eq!(
705            (h.old_start, h.old_lines, h.new_start, h.new_lines),
706            (g_os, g_ol, g_ns, g_nl)
707        );
708        assert_eq!((h.new_start, h.new_lines), (0, 0));
709    }
710
711    #[cfg_attr(
712        not(feature = "full-tests"),
713        ignore = "starts system git or is heavy; run with --features full-tests"
714    )]
715    #[test]
716    fn line_level_multiple_hunks_match_git() {
717        let old = "keep\nchange-a\nmid\nchange-b\nend\n";
718        let new = "keep\nCHANGE-A\nmid\nCHANGE-B\nend\n";
719        let patch = git_diff_u0(old, new);
720        let git_hunks: Vec<_> = patch.lines().filter(|l| l.starts_with("@@")).collect();
721        assert_eq!(git_hunks.len(), 2);
722        let d = structured_text_diff(
723            old,
724            new,
725            DiffOptions {
726                context_lines: 0,
727                word_level_inline: false,
728                ..DiffOptions::default()
729            },
730        );
731        assert_eq!(d.hunks.len(), 2);
732        for (h, header) in d.hunks.iter().zip(git_hunks) {
733            let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&format!("{header}\n"));
734            assert_eq!(
735                (h.old_start, h.old_lines, h.new_start, h.new_lines),
736                (g_os, g_ol, g_ns, g_nl)
737            );
738        }
739    }
740
741    #[test]
742    fn line_level_blank_line_in_hunk() {
743        let old = "a\n\nb\n";
744        let new = "a\nX\n\nb\n";
745        let d = structured_text_diff(
746            old,
747            new,
748            DiffOptions {
749                context_lines: 1,
750                word_level_inline: false,
751                ..DiffOptions::default()
752            },
753        );
754        assert!(!d.hunks.is_empty());
755        assert!(
756            d.hunks[0]
757                .lines
758                .iter()
759                .any(|l| l.segments[0].text.is_empty() && l.kind == StructuredLineKind::Context)
760                || d.hunks[0]
761                    .lines
762                    .iter()
763                    .any(|l| l.segments[0].text.is_empty())
764        );
765    }
766
767    #[test]
768    fn line_level_missing_eof_newline_flag() {
769        let old = "only";
770        let new = "only\n";
771        let d = structured_text_diff(
772            old,
773            new,
774            DiffOptions {
775                context_lines: 0,
776                word_level_inline: false,
777                ..DiffOptions::default()
778            },
779        );
780        let h = &d.hunks[0];
781        let add = h
782            .lines
783            .iter()
784            .find(|l| l.kind == StructuredLineKind::Add)
785            .expect("add line");
786        assert!(add.no_newline_at_eof || h.lines.iter().any(|l| l.no_newline_at_eof));
787    }
788
789    #[test]
790    fn hunk_header_counts_match_line_totals() {
791        let old = "x\n";
792        let new = "x\nmore\n";
793        let d = structured_text_diff(
794            old,
795            new,
796            DiffOptions {
797                context_lines: 3,
798                ..DiffOptions::default()
799            },
800        );
801        let h = &d.hunks[0];
802        assert_eq!(h.old_lines, 1);
803        assert_eq!(h.new_lines, 2);
804    }
805
806    #[test]
807    fn mode_only_change_has_empty_hunks() {
808        let old = SideBytes::from_bytes(b"x\n".to_vec());
809        let new = SideBytes::from_bytes(b"x\n".to_vec());
810        let f = structured_file_diff(
811            FileMode::REGULAR,
812            FileMode::EXECUTABLE,
813            &old,
814            &new,
815            DiffOptions::default(),
816        );
817        assert!(f.hunks.is_empty());
818    }
819}