1use 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#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct SideBytes {
26 pub data: Vec<u8>,
28 pub encoding_lossy: bool,
30}
31
32impl SideBytes {
33 #[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 #[must_use]
45 pub fn text(&self) -> String {
46 String::from_utf8_lossy(&self.data).into_owned()
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct StructuredSegment {
53 pub text: String,
55 pub emphasis: bool,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum StructuredLineKind {
62 Context,
64 Add,
66 Del,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct StructuredLine {
73 pub kind: StructuredLineKind,
75 pub old: Option<usize>,
77 pub new: Option<usize>,
79 pub segments: Vec<StructuredSegment>,
81 pub no_newline_at_eof: bool,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct StructuredHunk {
88 pub old_start: usize,
90 pub new_start: usize,
92 pub old_lines: usize,
94 pub new_lines: usize,
96 pub context: Option<String>,
98 pub lines: Vec<StructuredLine>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct StructuredTextDiff {
105 pub hunks: Vec<StructuredHunk>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct StructuredFileDiff {
112 pub old_mode: Option<String>,
114 pub new_mode: Option<String>,
116 pub old_encoding_lossy: bool,
118 pub new_encoding_lossy: bool,
120 pub hunks: Vec<StructuredHunk>,
122}
123
124#[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
139pub(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
150fn side_text_for_diff(side: &SideBytes) -> Cow<'_, str> {
152 text_bytes_for_diff(&side.data)
153}
154
155fn 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#[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
264fn 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 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
292fn 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#[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
327fn 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
344pub(crate) fn old_line_bodies_for_context(text: &str) -> Vec<&str> {
346 TextLines::from_text(text).lines
347}
348
349pub(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#[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}