1use std::io::{self, Write};
5
6use objects::object::FileMode;
7
8use super::{DiffReport, FileChange, LineDiff};
9
10pub fn write_diff_patch<W: Write>(output: &DiffReport, writer: &mut W) -> io::Result<()> {
11 for change in &output.changes {
12 if change.symlink.is_some() {
17 write_symlink_change(change, writer)?;
18 } else {
19 write_text_change(change, writer)?;
20 }
21 }
22 Ok(())
23}
24
25pub fn render_diff_patch_bytes(output: &DiffReport) -> Vec<u8> {
26 let mut buf: Vec<u8> = Vec::new();
27 write_diff_patch(output, &mut buf).expect("writing diff patch to Vec cannot fail");
28 buf
29}
30
31pub fn render_diff_patch(output: &DiffReport) -> String {
39 String::from_utf8_lossy(&render_diff_patch_bytes(output)).into_owned()
40}
41
42fn write_text_change<W: Write>(change: &FileChange, writer: &mut W) -> io::Result<()> {
48 let lines_ref = change.lines.as_deref();
49 let has_hunk_body = lines_ref.is_some_and(|lines| lines.iter().any(|line| line.prefix != " "));
50 let old_path = change.old_path.as_deref().unwrap_or(&change.path);
51 let is_rename = change
52 .old_path
53 .as_deref()
54 .is_some_and(|old| old != change.path);
55 let is_added = change.kind == "added";
56 let is_deleted = change.kind == "deleted";
57 let is_modified = !is_rename && !is_added && !is_deleted;
58 let mode_changed = is_modified
63 && matches!((change.old_mode, change.mode), (Some(old), Some(new)) if old != new);
64 let has_text = change.lines.is_some();
69
70 if change.binary && !is_rename {
84 write_binary_change(change, is_added, is_deleted, mode_changed, writer)?;
85 return Ok(());
86 }
87
88 let should_render = if is_rename {
99 true
100 } else if is_added || is_deleted {
101 has_text
102 } else {
103 has_hunk_body || mode_changed
104 };
105 if !should_render {
106 return Ok(());
107 }
108
109 if is_rename {
110 writeln!(
111 writer,
112 "diff --git {} {}",
113 quote_path_for_patch("a/", old_path),
114 quote_path_for_patch("b/", &change.path)
115 )?;
116 if let (Some(old), Some(new)) = (change.old_mode, change.mode)
122 && old != new
123 {
124 writeln!(writer, "old mode {}", mode_str(change.old_mode))?;
125 writeln!(writer, "new mode {}", mode_str(change.mode))?;
126 }
127 let pct = (change.similarity_score.unwrap_or(1.0).clamp(0.0, 1.0) * 100.0).round() as u32;
128 writeln!(writer, "similarity index {pct}%")?;
129 writeln!(writer, "rename from {}", quote_path_for_patch("", old_path))?;
130 writeln!(
131 writer,
132 "rename to {}",
133 quote_path_for_patch("", &change.path)
134 )?;
135 if !has_hunk_body {
139 return Ok(());
140 }
141 } else if is_added {
142 writeln!(
143 writer,
144 "diff --git {} {}",
145 quote_path_for_patch("a/", &change.path),
146 quote_path_for_patch("b/", &change.path)
147 )?;
148 writeln!(writer, "new file mode {}", mode_str(change.mode))?;
149 } else if is_deleted {
150 writeln!(
151 writer,
152 "diff --git {} {}",
153 quote_path_for_patch("a/", &change.path),
154 quote_path_for_patch("b/", &change.path)
155 )?;
156 writeln!(writer, "deleted file mode {}", mode_str(change.mode))?;
157 } else if mode_changed {
158 writeln!(
162 writer,
163 "diff --git {} {}",
164 quote_path_for_patch("a/", &change.path),
165 quote_path_for_patch("b/", &change.path)
166 )?;
167 writeln!(writer, "old mode {}", mode_str(change.old_mode))?;
168 writeln!(writer, "new mode {}", mode_str(change.mode))?;
169 } else {
170 writeln!(
180 writer,
181 "diff --git {} {}",
182 quote_path_for_patch("a/", &change.path),
183 quote_path_for_patch("b/", &change.path)
184 )?;
185 }
186
187 if (is_added || is_deleted) && !has_hunk_body {
193 return Ok(());
194 }
195 if is_modified && !has_hunk_body {
200 return Ok(());
201 }
202
203 if is_added {
204 writer.write_all(b"--- /dev/null\n")?;
205 } else {
206 writeln!(writer, "--- {}", quote_path_for_patch("a/", old_path))?;
207 }
208 if is_deleted {
209 writer.write_all(b"+++ /dev/null\n")?;
210 } else {
211 writeln!(writer, "+++ {}", quote_path_for_patch("b/", &change.path))?;
212 }
213 if let Some(lines) = lines_ref {
214 write_patch_hunks(change, lines, writer)?;
215 }
216 Ok(())
217}
218
219fn write_symlink_change<W: Write>(change: &FileChange, writer: &mut W) -> io::Result<()> {
232 let Some(sym) = change.symlink.as_ref() else {
233 return Ok(());
234 };
235 let old_path = change.old_path.as_deref().unwrap_or(&change.path);
236 let is_rename = change
237 .old_path
238 .as_deref()
239 .is_some_and(|old| old != change.path);
240 let is_added = change.kind == "added";
241 let is_deleted = change.kind == "deleted";
242
243 if is_rename {
244 writeln!(
245 writer,
246 "diff --git {} {}",
247 quote_path_for_patch("a/", old_path),
248 quote_path_for_patch("b/", &change.path)
249 )?;
250 if let (Some(old), Some(new)) = (change.old_mode, change.mode)
251 && old != new
252 {
253 writeln!(writer, "old mode {}", mode_str(change.old_mode))?;
254 writeln!(writer, "new mode {}", mode_str(change.mode))?;
255 }
256 let pct = (change.similarity_score.unwrap_or(1.0).clamp(0.0, 1.0) * 100.0).round() as u32;
257 writeln!(writer, "similarity index {pct}%")?;
258 writeln!(writer, "rename from {}", quote_path_for_patch("", old_path))?;
259 writeln!(
260 writer,
261 "rename to {}",
262 quote_path_for_patch("", &change.path)
263 )?;
264 if sym.old == sym.new {
267 return Ok(());
268 }
269 writeln!(writer, "--- {}", quote_path_for_patch("a/", old_path))?;
270 writeln!(writer, "+++ {}", quote_path_for_patch("b/", &change.path))?;
271 } else if is_added {
272 writeln!(
273 writer,
274 "diff --git {} {}",
275 quote_path_for_patch("a/", &change.path),
276 quote_path_for_patch("b/", &change.path)
277 )?;
278 writeln!(writer, "new file mode {}", mode_str(change.mode))?;
279 writer.write_all(b"--- /dev/null\n")?;
280 writeln!(writer, "+++ {}", quote_path_for_patch("b/", &change.path))?;
281 } else if is_deleted {
282 writeln!(
283 writer,
284 "diff --git {} {}",
285 quote_path_for_patch("a/", &change.path),
286 quote_path_for_patch("b/", &change.path)
287 )?;
288 writeln!(writer, "deleted file mode {}", mode_str(change.mode))?;
289 writeln!(writer, "--- {}", quote_path_for_patch("a/", &change.path))?;
290 writer.write_all(b"+++ /dev/null\n")?;
291 } else {
292 if sym.old == sym.new {
297 return Ok(());
298 }
299 writeln!(
300 writer,
301 "diff --git {} {}",
302 quote_path_for_patch("a/", &change.path),
303 quote_path_for_patch("b/", &change.path)
304 )?;
305 writeln!(writer, "--- {}", quote_path_for_patch("a/", &change.path))?;
306 writeln!(writer, "+++ {}", quote_path_for_patch("b/", &change.path))?;
307 }
308
309 write_symlink_hunk(sym.old.as_deref(), sym.new.as_deref(), writer)?;
310 Ok(())
311}
312
313fn write_symlink_hunk<W: Write>(
320 old: Option<&[u8]>,
321 new: Option<&[u8]>,
322 writer: &mut W,
323) -> io::Result<()> {
324 let old_lines = split_target_lines(old);
325 let new_lines = split_target_lines(new);
326 let old_count = old_lines.len();
327 let new_count = new_lines.len();
328 let old_start = if old_count == 0 { 0 } else { 1 };
329 let new_start = if new_count == 0 { 0 } else { 1 };
330 writeln!(
331 writer,
332 "@@ -{old_start},{old_count} +{new_start},{new_count} @@"
333 )?;
334 let old_no_eol = !target_has_trailing_newline(old);
335 let new_no_eol = !target_has_trailing_newline(new);
336 for (idx, line) in old_lines.iter().enumerate() {
337 writer.write_all(b"-")?;
338 writer.write_all(line)?;
339 writer.write_all(b"\n")?;
340 if old_no_eol && idx + 1 == old_count {
341 writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
342 }
343 }
344 for (idx, line) in new_lines.iter().enumerate() {
345 writer.write_all(b"+")?;
346 writer.write_all(line)?;
347 writer.write_all(b"\n")?;
348 if new_no_eol && idx + 1 == new_count {
349 writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
350 }
351 }
352 Ok(())
353}
354
355fn split_target_lines(target: Option<&[u8]>) -> Vec<&[u8]> {
360 let Some(bytes) = target else {
361 return Vec::new();
362 };
363 if bytes.is_empty() {
364 return Vec::new();
365 }
366 let mut lines: Vec<&[u8]> = bytes.split(|&byte| byte == b'\n').collect();
367 if bytes.ends_with(b"\n") {
368 lines.pop();
369 }
370 lines
371}
372
373fn target_has_trailing_newline(target: Option<&[u8]>) -> bool {
374 target.is_some_and(|bytes| bytes.ends_with(b"\n"))
375}
376
377fn write_binary_change<W: Write>(
392 change: &FileChange,
393 is_added: bool,
394 is_deleted: bool,
395 mode_changed: bool,
396 writer: &mut W,
397) -> io::Result<()> {
398 let path = &change.path;
399 writeln!(
400 writer,
401 "diff --git {} {}",
402 quote_path_for_patch("a/", path),
403 quote_path_for_patch("b/", path)
404 )?;
405 if is_added {
406 writeln!(writer, "new file mode {}", mode_str(change.mode))?;
407 writer.write_all(b"index 0000000..0000000\n")?;
408 } else if is_deleted {
409 writeln!(writer, "deleted file mode {}", mode_str(change.mode))?;
410 writer.write_all(b"index 0000000..0000000\n")?;
411 } else if mode_changed {
412 writeln!(writer, "old mode {}", mode_str(change.old_mode))?;
413 writeln!(writer, "new mode {}", mode_str(change.mode))?;
414 writer.write_all(b"index 0000000..0000000\n")?;
415 } else {
416 writeln!(writer, "index 0000000..0000000 {}", mode_str(change.mode))?;
419 }
420 let (a, b) = if is_added {
421 ("/dev/null".to_string(), quote_path_for_patch("b/", path))
422 } else if is_deleted {
423 (quote_path_for_patch("a/", path), "/dev/null".to_string())
424 } else {
425 (
426 quote_path_for_patch("a/", path),
427 quote_path_for_patch("b/", path),
428 )
429 };
430 writeln!(writer, "Binary files {a} and {b} differ")?;
431 Ok(())
432}
433
434fn mode_str(mode: Option<FileMode>) -> &'static str {
442 match mode {
443 Some(FileMode::Executable) => "100755",
444 Some(FileMode::Symlink) => "120000",
445 Some(FileMode::Gitlink) => "160000",
446 Some(FileMode::Normal) | Some(FileMode::Spoollink) | None => "100644",
447 }
448}
449
450fn quote_path_for_patch(prefix: &str, path: &str) -> String {
463 if !needs_c_quoting(prefix) && !needs_c_quoting(path) {
464 return format!("{prefix}{path}");
465 }
466 let mut out = String::with_capacity(prefix.len() + path.len() + 2);
467 out.push('"');
468 push_c_quoted(&mut out, prefix);
469 push_c_quoted(&mut out, path);
470 out.push('"');
471 out
472}
473
474fn needs_c_quoting(s: &str) -> bool {
475 s.bytes().any(byte_needs_escape)
476}
477
478fn byte_needs_escape(byte: u8) -> bool {
482 matches!(byte, b'"' | b'\\') || !(0x20..0x7f).contains(&byte)
483}
484
485fn push_c_quoted(out: &mut String, s: &str) {
486 for byte in s.bytes() {
487 match byte {
488 b'"' => out.push_str("\\\""),
489 b'\\' => out.push_str("\\\\"),
490 0x07 => out.push_str("\\a"),
491 0x08 => out.push_str("\\b"),
492 0x09 => out.push_str("\\t"),
493 0x0a => out.push_str("\\n"),
494 0x0b => out.push_str("\\v"),
495 0x0c => out.push_str("\\f"),
496 0x0d => out.push_str("\\r"),
497 0x20..=0x7e => out.push(byte as char),
498 other => out.push_str(&format!("\\{other:03o}")),
499 }
500 }
501}
502
503const NO_NEWLINE_MARKER: &str = "\\ No newline at end of file\n";
504
505fn write_patch_hunks<W: Write>(
517 change: &FileChange,
518 lines: &[LineDiff],
519 writer: &mut W,
520) -> io::Result<()> {
521 let old_no_eol = !change.eol.old_has_final_newline;
522 let new_no_eol = !change.eol.new_has_final_newline;
523 let old_tail_idx = if old_no_eol && change.eol.old_line_count > 0 {
524 find_side_tail_idx(lines, Side::Old, change.eol.old_line_count)
525 } else {
526 None
527 };
528 let new_tail_idx = if new_no_eol && change.eol.new_line_count > 0 {
529 find_side_tail_idx(lines, Side::New, change.eol.new_line_count)
530 } else {
531 None
532 };
533
534 for (idx, line) in lines.iter().enumerate() {
535 let is_old_tail = Some(idx) == old_tail_idx;
536 let is_new_tail = Some(idx) == new_tail_idx;
537 let needs_old_marker = is_old_tail && old_no_eol;
538 let needs_new_marker = is_new_tail && new_no_eol;
539
540 if line.prefix == " " && (needs_old_marker || needs_new_marker) {
541 if is_old_tail && is_new_tail && needs_old_marker && needs_new_marker {
542 write_patch_line(writer, line)?;
546 writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
547 } else {
548 writer.write_all(b"-")?;
553 writer.write_all(line.content.as_bytes())?;
554 writer.write_all(b"\n")?;
555 if needs_old_marker {
556 writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
557 }
558 writer.write_all(b"+")?;
559 writer.write_all(line.content.as_bytes())?;
560 writer.write_all(b"\n")?;
561 if needs_new_marker {
562 writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
563 }
564 }
565 continue;
566 }
567
568 write_patch_line(writer, line)?;
569 if needs_old_marker && line.prefix == "-" {
570 writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
571 }
572 if needs_new_marker && line.prefix == "+" {
573 writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
574 }
575 }
576 Ok(())
577}
578
579#[derive(Clone, Copy)]
580enum Side {
581 Old,
582 New,
583}
584
585fn find_side_tail_idx(lines: &[LineDiff], side: Side, target: usize) -> Option<usize> {
586 lines.iter().enumerate().rev().find_map(|(idx, line)| {
587 let (on_side, line_number) = match side {
588 Side::Old => (line.prefix == "-" || line.prefix == " ", line.old_line),
589 Side::New => (line.prefix == "+" || line.prefix == " ", line.new_line),
590 };
591 if on_side && line_number == Some(target) {
592 Some(idx)
593 } else {
594 None
595 }
596 })
597}
598
599fn write_patch_line<W: Write>(writer: &mut W, line: &LineDiff) -> io::Result<()> {
600 writer.write_all(line.prefix.as_bytes())?;
601 writer.write_all(line.content.as_bytes())?;
602 writer.write_all(b"\n")
603}
604
605#[cfg(test)]
606mod tests {
607 use objects::object::FileMode;
608
609 use super::{quote_path_for_patch, render_diff_patch, render_diff_patch_bytes};
610 use crate::diff::{DiffReport, FileChange, FileEolState, LineDiff, SymlinkChange};
611
612 fn modified_change_with_eol(path: &str, lines: Vec<LineDiff>, eol: FileEolState) -> FileChange {
613 FileChange {
614 path: path.to_string(),
615 kind: "modified".to_string(),
616 lines: Some(lines),
617 eol,
618 ..Default::default()
619 }
620 }
621
622 fn diff_report_with(changes: Vec<FileChange>) -> DiffReport {
623 DiffReport::new(None, None, changes, None, None, None)
624 }
625
626 #[cfg(unix)]
627 fn hermetic_git_command(dir: &std::path::Path, args: &[&str]) -> std::process::Command {
628 let mut command = std::process::Command::new("git");
629 command
630 .args(args)
631 .current_dir(dir)
632 .env("GIT_CONFIG_GLOBAL", "/dev/null")
633 .env("GIT_CONFIG_SYSTEM", "/dev/null")
634 .env("GIT_AUTHOR_NAME", "Heddle Test")
635 .env("GIT_AUTHOR_EMAIL", "heddle@example.com")
636 .env("GIT_COMMITTER_NAME", "Heddle Test")
637 .env("GIT_COMMITTER_EMAIL", "heddle@example.com");
638 command
639 }
640
641 #[cfg(unix)]
642 fn hermetic_git(dir: &std::path::Path, args: &[&str]) {
643 let status = hermetic_git_command(dir, args)
644 .status()
645 .unwrap_or_else(|err| panic!("git {args:?} should spawn: {err}"));
646 assert!(status.success(), "git {args:?} should succeed");
647 }
648
649 #[cfg(unix)]
650 fn pipe_git_apply(dir: &std::path::Path, args: &[&str], patch: &[u8]) -> std::process::Output {
651 use std::{io::Write, process::Stdio};
652
653 let mut child = hermetic_git_command(dir, args)
654 .stdin(Stdio::piped())
655 .stdout(Stdio::piped())
656 .stderr(Stdio::piped())
657 .spawn()
658 .unwrap_or_else(|err| panic!("git {args:?} should spawn: {err}"));
659 child.stdin.as_mut().unwrap().write_all(patch).unwrap();
660 child
661 .wait_with_output()
662 .unwrap_or_else(|err| panic!("git {args:?} should finish: {err}"))
663 }
664
665 #[cfg(unix)]
666 #[test]
667 fn render_diff_patch_bytes_applies_non_utf8_symlink_target_byte_exactly() {
668 use std::os::unix::ffi::OsStrExt;
669
670 let target = b"target-\xff\xfe";
671 let change = FileChange {
672 path: "linky".to_string(),
673 kind: "added".to_string(),
674 mode: Some(FileMode::Symlink),
675 symlink: Some(SymlinkChange {
676 old: None,
677 new: Some(target.to_vec()),
678 }),
679 ..Default::default()
680 };
681 let patch = render_diff_patch_bytes(&diff_report_with(vec![change]));
682 assert!(
683 patch.windows(target.len()).any(|window| window == target),
684 "patch must carry the raw non-UTF-8 target bytes:\n{}",
685 String::from_utf8_lossy(&patch)
686 );
687
688 let scratch = tempfile::TempDir::new().unwrap();
689 hermetic_git(scratch.path(), &["init", "-q"]);
690 hermetic_git(scratch.path(), &["checkout", "-q", "-b", "main"]);
691
692 let check = pipe_git_apply(scratch.path(), &["apply", "--check"], &patch);
693 assert!(
694 check.status.success(),
695 "git apply --check rejected patch;\nstderr={}\npatch=\n{}",
696 String::from_utf8_lossy(&check.stderr),
697 String::from_utf8_lossy(&patch)
698 );
699 let applied = pipe_git_apply(scratch.path(), &["apply"], &patch);
700 assert!(
701 applied.status.success(),
702 "git apply rejected patch;\nstderr={}\npatch=\n{}",
703 String::from_utf8_lossy(&applied.stderr),
704 String::from_utf8_lossy(&patch)
705 );
706
707 let applied_target = std::fs::read_link(scratch.path().join("linky")).unwrap();
708 assert_eq!(
709 applied_target.as_os_str().as_bytes(),
710 target,
711 "applied symlink target must be byte-exact"
712 );
713 }
714
715 #[test]
720 fn render_diff_patch_emits_mode_only_header_for_chmod() {
721 let change = FileChange {
722 path: "run.sh".to_string(),
723 kind: "modified".to_string(),
724 lines: Some(Vec::new()),
725 old_mode: Some(FileMode::Normal),
726 mode: Some(FileMode::Executable),
727 ..Default::default()
728 };
729 let rendered = render_diff_patch(&diff_report_with(vec![change]));
730 assert!(
731 rendered.contains("diff --git a/run.sh b/run.sh"),
732 "chmod-only must emit the `diff --git` header:\n{rendered}"
733 );
734 assert!(
735 rendered.contains("old mode 100644") && rendered.contains("new mode 100755"),
736 "chmod-only must emit `old mode`/`new mode`:\n{rendered}"
737 );
738 assert!(
739 !rendered.contains("@@") && !rendered.contains("--- a/"),
740 "chmod-only is header-only — no hunk body:\n{rendered}"
741 );
742 }
743
744 #[test]
745 fn render_diff_patch_emits_gitlink_mode_without_blob_hunk() {
746 let change = FileChange {
747 path: "vendor".to_string(),
748 kind: "added".to_string(),
749 lines: Some(Vec::new()),
750 mode: Some(FileMode::Gitlink),
751 ..Default::default()
752 };
753
754 let rendered = render_diff_patch(&diff_report_with(vec![change]));
755
756 assert!(
757 rendered.contains("new file mode 160000"),
758 "gitlinks must render their durable mode, not a regular-file mode:\n{rendered}"
759 );
760 assert!(
761 !rendered.contains("@@") && !rendered.contains("heddle-submodule:"),
762 "gitlink patch output must not synthesize legacy marker blob content:\n{rendered}"
763 );
764 }
765
766 #[test]
769 fn render_diff_patch_emits_mode_headers_with_content_hunk() {
770 let change = FileChange {
771 path: "run.sh".to_string(),
772 kind: "modified".to_string(),
773 lines: Some(vec![
774 LineDiff::with_lines("@", "@ -1,1 +1,1 @@", None, None),
775 LineDiff::with_lines("-", "echo old", Some(1), None),
776 LineDiff::with_lines("+", "echo new", None, Some(1)),
777 ]),
778 old_mode: Some(FileMode::Normal),
779 mode: Some(FileMode::Executable),
780 ..Default::default()
781 };
782 let rendered = render_diff_patch(&diff_report_with(vec![change]));
783 assert!(
784 rendered.contains("old mode 100644") && rendered.contains("new mode 100755"),
785 "content+mode change must still emit the mode headers:\n{rendered}"
786 );
787 assert!(
788 rendered.contains("--- a/run.sh")
789 && rendered.contains("+++ b/run.sh")
790 && rendered.contains("+echo new"),
791 "content+mode change must still emit the line-diff body:\n{rendered}"
792 );
793 }
794
795 #[test]
799 fn render_diff_patch_skips_modify_with_same_mode_and_no_body() {
800 let change = FileChange {
801 path: "run.sh".to_string(),
802 kind: "modified".to_string(),
803 lines: Some(Vec::new()),
804 old_mode: Some(FileMode::Normal),
805 mode: Some(FileMode::Normal),
806 ..Default::default()
807 };
808 let rendered = render_diff_patch(&diff_report_with(vec![change]));
809 assert!(
810 rendered.is_empty(),
811 "no-op modify (same mode, no body) must emit nothing:\n{rendered}"
812 );
813 }
814
815 #[test]
822 fn render_diff_patch_binary_modify_emits_marker_with_index() {
823 let change = FileChange {
824 path: "binary.bin".to_string(),
825 kind: "modified".to_string(),
826 binary: true,
827 lines: None,
828 mode: Some(FileMode::Normal),
829 old_mode: Some(FileMode::Normal),
830 ..Default::default()
831 };
832 let rendered = render_diff_patch(&diff_report_with(vec![change]));
833 assert!(
834 rendered.contains("diff --git a/binary.bin b/binary.bin"),
835 "binary modify must emit a diff header:\n{rendered}"
836 );
837 assert!(
838 rendered.contains("index 0000000..0000000 100644"),
839 "binary modify must emit a placeholder index line:\n{rendered}"
840 );
841 assert!(
842 rendered.contains("Binary files a/binary.bin and b/binary.bin differ"),
843 "binary modify must emit the binary marker:\n{rendered}"
844 );
845 assert!(
846 !rendered.contains("--- a/binary.bin"),
847 "binary modify must not emit a text hunk header:\n{rendered}"
848 );
849 }
850
851 #[test]
857 fn render_diff_patch_binary_modify_with_mode_change_keeps_marker() {
858 let change = FileChange {
859 path: "binary.bin".to_string(),
860 kind: "modified".to_string(),
861 binary: true,
862 lines: None,
863 old_mode: Some(FileMode::Normal),
864 mode: Some(FileMode::Executable),
865 ..Default::default()
866 };
867 let rendered = render_diff_patch(&diff_report_with(vec![change]));
868 assert!(
869 rendered.contains("old mode 100644") && rendered.contains("new mode 100755"),
870 "binary+mode change must still record the chmod:\n{rendered}"
871 );
872 assert!(
873 rendered.contains("index 0000000..0000000"),
874 "binary+mode change must emit the placeholder index line:\n{rendered}"
875 );
876 assert!(
877 rendered.contains("Binary files a/binary.bin and b/binary.bin differ"),
878 "binary+mode change must still emit the binary marker:\n{rendered}"
879 );
880 }
881
882 #[test]
885 fn render_diff_patch_binary_add_and_delete_emit_markers() {
886 let added = FileChange {
887 path: "added.bin".to_string(),
888 kind: "added".to_string(),
889 binary: true,
890 lines: None,
891 mode: Some(FileMode::Normal),
892 ..Default::default()
893 };
894 let rendered = render_diff_patch(&diff_report_with(vec![added]));
895 assert!(
896 rendered.contains("new file mode 100644")
897 && rendered.contains("index 0000000..0000000")
898 && rendered.contains("Binary files /dev/null and b/added.bin differ"),
899 "binary add marker:\n{rendered}"
900 );
901
902 let deleted = FileChange {
903 path: "gone.bin".to_string(),
904 kind: "deleted".to_string(),
905 binary: true,
906 lines: None,
907 mode: Some(FileMode::Normal),
908 ..Default::default()
909 };
910 let rendered = render_diff_patch(&diff_report_with(vec![deleted]));
911 assert!(
912 rendered.contains("deleted file mode 100644")
913 && rendered.contains("index 0000000..0000000")
914 && rendered.contains("Binary files a/gone.bin and /dev/null differ"),
915 "binary delete marker:\n{rendered}"
916 );
917 }
918
919 #[test]
924 fn render_diff_patch_skips_change_with_empty_lines() {
925 let empty = FileChange {
926 path: "empty.txt".to_string(),
927 kind: "modified".to_string(),
928 lines: Some(Vec::new()),
929 ..Default::default()
930 };
931 let real = modified_change_with_eol(
932 "real.txt",
933 vec![
934 LineDiff::with_lines("@", "@ -1,1 +1,1 @@", None, None),
935 LineDiff::with_lines("-", "old", Some(1), None),
936 LineDiff::with_lines("+", "new", None, Some(1)),
937 ],
938 FileEolState::default(),
939 );
940 let rendered = render_diff_patch(&diff_report_with(vec![empty, real]));
941 assert!(
942 !rendered.contains("empty.txt"),
943 "skipped change must not emit a header: {rendered}"
944 );
945 assert!(
946 rendered.contains("--- a/real.txt"),
947 "renderable change must still be emitted: {rendered}"
948 );
949 }
950
951 #[test]
957 fn render_diff_patch_collapses_both_side_no_eol_marker_on_shared_tail() {
958 let lines = vec![
961 LineDiff::with_lines("@", "@ -1,2 +1,2 @@", None, None),
962 LineDiff::with_lines("-", "hello", Some(1), None),
963 LineDiff::with_lines("+", "world", None, Some(1)),
964 LineDiff::with_lines(" ", "more", Some(2), Some(2)),
965 ];
966 let eol = FileEolState {
967 old_has_final_newline: false,
968 new_has_final_newline: false,
969 old_line_count: 2,
970 new_line_count: 2,
971 };
972 let change = modified_change_with_eol("tail.txt", lines, eol);
973 let rendered = render_diff_patch(&diff_report_with(vec![change]));
974
975 let marker_count = rendered.matches("\\ No newline at end of file").count();
976 assert_eq!(
977 marker_count, 1,
978 "shared-tail double-no-eol must emit exactly one marker, got:\n{rendered}"
979 );
980 assert!(
984 !rendered.contains("-more\n"),
985 "context tail must not be split when both sides agree:\n{rendered}"
986 );
987 assert!(
988 !rendered.contains("+more\n"),
989 "context tail must not be split when both sides agree:\n{rendered}"
990 );
991 assert!(
992 rendered.contains(" more\n\\ No newline at end of file\n"),
993 "marker must sit immediately after the shared context line:\n{rendered}"
994 );
995 }
996
997 #[test]
1003 fn render_diff_patch_splits_context_tail_when_only_old_lacks_newline() {
1004 let lines = vec![
1007 LineDiff::with_lines("@", "@ -1,1 +1,2 @@", None, None),
1008 LineDiff::with_lines(" ", "hello", Some(1), Some(1)),
1009 LineDiff::with_lines("+", "more", None, Some(2)),
1010 ];
1011 let eol = FileEolState {
1012 old_has_final_newline: false,
1013 new_has_final_newline: true,
1014 old_line_count: 1,
1015 new_line_count: 2,
1016 };
1017 let change = modified_change_with_eol("old.txt", lines, eol);
1018 let rendered = render_diff_patch(&diff_report_with(vec![change]));
1019
1020 assert!(
1021 rendered.contains("-hello\n\\ No newline at end of file\n+hello\n"),
1022 "OLD-side context-tail split must emit `-hello` + marker + `+hello`:\n{rendered}"
1023 );
1024 let marker_count = rendered.matches("\\ No newline at end of file").count();
1027 assert_eq!(
1028 marker_count, 1,
1029 "exactly one marker expected (OLD side only):\n{rendered}"
1030 );
1031 }
1032
1033 #[test]
1038 fn render_diff_patch_splits_context_tail_when_only_new_lacks_newline() {
1039 let lines = vec![
1042 LineDiff::with_lines("@", "@ -1,2 +1,1 @@", None, None),
1043 LineDiff::with_lines(" ", "hello", Some(1), Some(1)),
1044 LineDiff::with_lines("-", "more", Some(2), None),
1045 ];
1046 let eol = FileEolState {
1047 old_has_final_newline: true,
1048 new_has_final_newline: false,
1049 old_line_count: 2,
1050 new_line_count: 1,
1051 };
1052 let change = modified_change_with_eol("new.txt", lines, eol);
1053 let rendered = render_diff_patch(&diff_report_with(vec![change]));
1054
1055 assert!(
1056 rendered.contains("-hello\n+hello\n\\ No newline at end of file\n"),
1057 "NEW-side context-tail split must emit `-hello` + `+hello` + marker:\n{rendered}"
1058 );
1059 let marker_count = rendered.matches("\\ No newline at end of file").count();
1060 assert_eq!(
1061 marker_count, 1,
1062 "exactly one marker expected (NEW side only):\n{rendered}"
1063 );
1064 }
1065
1066 #[test]
1071 fn render_diff_patch_marker_after_minus_line_when_old_tail_is_deletion() {
1072 let lines = vec![
1076 LineDiff::with_lines("@", "@ -1,2 +1,1 @@", None, None),
1077 LineDiff::with_lines("-", "only", Some(1), None),
1078 LineDiff::with_lines("-", "tail", Some(2), None),
1079 LineDiff::with_lines("+", "only", None, Some(1)),
1080 ];
1081 let eol = FileEolState {
1082 old_has_final_newline: false,
1083 new_has_final_newline: true,
1084 old_line_count: 2,
1085 new_line_count: 1,
1086 };
1087 let change = modified_change_with_eol("del.txt", lines, eol);
1088 let rendered = render_diff_patch(&diff_report_with(vec![change]));
1089
1090 assert!(
1091 rendered.contains("-tail\n\\ No newline at end of file\n"),
1092 "marker must follow the OLD tail deletion line:\n{rendered}"
1093 );
1094 }
1095
1096 #[test]
1101 fn quote_path_matches_git_c_style() {
1102 assert_eq!(quote_path_for_patch("a/", "src/main.rs"), "a/src/main.rs");
1104 assert_eq!(
1105 quote_path_for_patch("a/", "with space.txt"),
1106 "a/with space.txt"
1107 );
1108 assert_eq!(quote_path_for_patch("a/", "tab\there"), "\"a/tab\\there\"");
1111 assert_eq!(
1112 quote_path_for_patch("b/", "line\nbreak"),
1113 "\"b/line\\nbreak\""
1114 );
1115 assert_eq!(quote_path_for_patch("a/", "quo\"te"), "\"a/quo\\\"te\"");
1116 assert_eq!(
1117 quote_path_for_patch("a/", "back\\slash"),
1118 "\"a/back\\\\slash\""
1119 );
1120 assert_eq!(quote_path_for_patch("a/", "café"), "\"a/caf\\303\\251\"");
1122 assert_eq!(quote_path_for_patch("", "x\ty"), "\"x\\ty\"");
1124 assert_eq!(
1126 quote_path_for_patch("", "\u{07}\u{08}\u{0b}\u{0c}\r\u{01}"),
1127 "\"\\a\\b\\v\\f\\r\\001\""
1128 );
1129 }
1130}