use std::io::{self, Write};
use objects::object::FileMode;
use super::{DiffReport, FileChange, LineDiff};
pub fn write_diff_patch<W: Write>(output: &DiffReport, writer: &mut W) -> io::Result<()> {
for change in &output.changes {
if change.symlink.is_some() {
write_symlink_change(change, writer)?;
} else {
write_text_change(change, writer)?;
}
}
Ok(())
}
pub fn render_diff_patch_bytes(output: &DiffReport) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::new();
write_diff_patch(output, &mut buf).expect("writing diff patch to Vec cannot fail");
buf
}
pub fn render_diff_patch(output: &DiffReport) -> String {
String::from_utf8_lossy(&render_diff_patch_bytes(output)).into_owned()
}
fn write_text_change<W: Write>(change: &FileChange, writer: &mut W) -> io::Result<()> {
let lines_ref = change.lines.as_deref();
let has_hunk_body = lines_ref.is_some_and(|lines| lines.iter().any(|line| line.prefix != " "));
let old_path = change.old_path.as_deref().unwrap_or(&change.path);
let is_rename = change
.old_path
.as_deref()
.is_some_and(|old| old != change.path);
let is_added = change.kind == "added";
let is_deleted = change.kind == "deleted";
let is_modified = !is_rename && !is_added && !is_deleted;
let mode_changed = is_modified
&& matches!((change.old_mode, change.mode), (Some(old), Some(new)) if old != new);
let has_text = change.lines.is_some();
if change.binary && !is_rename {
write_binary_change(change, is_added, is_deleted, mode_changed, writer)?;
return Ok(());
}
let should_render = if is_rename {
true
} else if is_added || is_deleted {
has_text
} else {
has_hunk_body || mode_changed
};
if !should_render {
return Ok(());
}
if is_rename {
writeln!(
writer,
"diff --git {} {}",
quote_path_for_patch("a/", old_path),
quote_path_for_patch("b/", &change.path)
)?;
if let (Some(old), Some(new)) = (change.old_mode, change.mode)
&& old != new
{
writeln!(writer, "old mode {}", mode_str(change.old_mode))?;
writeln!(writer, "new mode {}", mode_str(change.mode))?;
}
let pct = (change.similarity_score.unwrap_or(1.0).clamp(0.0, 1.0) * 100.0).round() as u32;
writeln!(writer, "similarity index {pct}%")?;
writeln!(writer, "rename from {}", quote_path_for_patch("", old_path))?;
writeln!(
writer,
"rename to {}",
quote_path_for_patch("", &change.path)
)?;
if !has_hunk_body {
return Ok(());
}
} else if is_added {
writeln!(
writer,
"diff --git {} {}",
quote_path_for_patch("a/", &change.path),
quote_path_for_patch("b/", &change.path)
)?;
writeln!(writer, "new file mode {}", mode_str(change.mode))?;
} else if is_deleted {
writeln!(
writer,
"diff --git {} {}",
quote_path_for_patch("a/", &change.path),
quote_path_for_patch("b/", &change.path)
)?;
writeln!(writer, "deleted file mode {}", mode_str(change.mode))?;
} else if mode_changed {
writeln!(
writer,
"diff --git {} {}",
quote_path_for_patch("a/", &change.path),
quote_path_for_patch("b/", &change.path)
)?;
writeln!(writer, "old mode {}", mode_str(change.old_mode))?;
writeln!(writer, "new mode {}", mode_str(change.mode))?;
} else {
writeln!(
writer,
"diff --git {} {}",
quote_path_for_patch("a/", &change.path),
quote_path_for_patch("b/", &change.path)
)?;
}
if (is_added || is_deleted) && !has_hunk_body {
return Ok(());
}
if is_modified && !has_hunk_body {
return Ok(());
}
if is_added {
writer.write_all(b"--- /dev/null\n")?;
} else {
writeln!(writer, "--- {}", quote_path_for_patch("a/", old_path))?;
}
if is_deleted {
writer.write_all(b"+++ /dev/null\n")?;
} else {
writeln!(writer, "+++ {}", quote_path_for_patch("b/", &change.path))?;
}
if let Some(lines) = lines_ref {
write_patch_hunks(change, lines, writer)?;
}
Ok(())
}
fn write_symlink_change<W: Write>(change: &FileChange, writer: &mut W) -> io::Result<()> {
let Some(sym) = change.symlink.as_ref() else {
return Ok(());
};
let old_path = change.old_path.as_deref().unwrap_or(&change.path);
let is_rename = change
.old_path
.as_deref()
.is_some_and(|old| old != change.path);
let is_added = change.kind == "added";
let is_deleted = change.kind == "deleted";
if is_rename {
writeln!(
writer,
"diff --git {} {}",
quote_path_for_patch("a/", old_path),
quote_path_for_patch("b/", &change.path)
)?;
if let (Some(old), Some(new)) = (change.old_mode, change.mode)
&& old != new
{
writeln!(writer, "old mode {}", mode_str(change.old_mode))?;
writeln!(writer, "new mode {}", mode_str(change.mode))?;
}
let pct = (change.similarity_score.unwrap_or(1.0).clamp(0.0, 1.0) * 100.0).round() as u32;
writeln!(writer, "similarity index {pct}%")?;
writeln!(writer, "rename from {}", quote_path_for_patch("", old_path))?;
writeln!(
writer,
"rename to {}",
quote_path_for_patch("", &change.path)
)?;
if sym.old == sym.new {
return Ok(());
}
writeln!(writer, "--- {}", quote_path_for_patch("a/", old_path))?;
writeln!(writer, "+++ {}", quote_path_for_patch("b/", &change.path))?;
} else if is_added {
writeln!(
writer,
"diff --git {} {}",
quote_path_for_patch("a/", &change.path),
quote_path_for_patch("b/", &change.path)
)?;
writeln!(writer, "new file mode {}", mode_str(change.mode))?;
writer.write_all(b"--- /dev/null\n")?;
writeln!(writer, "+++ {}", quote_path_for_patch("b/", &change.path))?;
} else if is_deleted {
writeln!(
writer,
"diff --git {} {}",
quote_path_for_patch("a/", &change.path),
quote_path_for_patch("b/", &change.path)
)?;
writeln!(writer, "deleted file mode {}", mode_str(change.mode))?;
writeln!(writer, "--- {}", quote_path_for_patch("a/", &change.path))?;
writer.write_all(b"+++ /dev/null\n")?;
} else {
if sym.old == sym.new {
return Ok(());
}
writeln!(
writer,
"diff --git {} {}",
quote_path_for_patch("a/", &change.path),
quote_path_for_patch("b/", &change.path)
)?;
writeln!(writer, "--- {}", quote_path_for_patch("a/", &change.path))?;
writeln!(writer, "+++ {}", quote_path_for_patch("b/", &change.path))?;
}
write_symlink_hunk(sym.old.as_deref(), sym.new.as_deref(), writer)?;
Ok(())
}
fn write_symlink_hunk<W: Write>(
old: Option<&[u8]>,
new: Option<&[u8]>,
writer: &mut W,
) -> io::Result<()> {
let old_lines = split_target_lines(old);
let new_lines = split_target_lines(new);
let old_count = old_lines.len();
let new_count = new_lines.len();
let old_start = if old_count == 0 { 0 } else { 1 };
let new_start = if new_count == 0 { 0 } else { 1 };
writeln!(
writer,
"@@ -{old_start},{old_count} +{new_start},{new_count} @@"
)?;
let old_no_eol = !target_has_trailing_newline(old);
let new_no_eol = !target_has_trailing_newline(new);
for (idx, line) in old_lines.iter().enumerate() {
writer.write_all(b"-")?;
writer.write_all(line)?;
writer.write_all(b"\n")?;
if old_no_eol && idx + 1 == old_count {
writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
}
}
for (idx, line) in new_lines.iter().enumerate() {
writer.write_all(b"+")?;
writer.write_all(line)?;
writer.write_all(b"\n")?;
if new_no_eol && idx + 1 == new_count {
writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
}
}
Ok(())
}
fn split_target_lines(target: Option<&[u8]>) -> Vec<&[u8]> {
let Some(bytes) = target else {
return Vec::new();
};
if bytes.is_empty() {
return Vec::new();
}
let mut lines: Vec<&[u8]> = bytes.split(|&byte| byte == b'\n').collect();
if bytes.ends_with(b"\n") {
lines.pop();
}
lines
}
fn target_has_trailing_newline(target: Option<&[u8]>) -> bool {
target.is_some_and(|bytes| bytes.ends_with(b"\n"))
}
fn write_binary_change<W: Write>(
change: &FileChange,
is_added: bool,
is_deleted: bool,
mode_changed: bool,
writer: &mut W,
) -> io::Result<()> {
let path = &change.path;
writeln!(
writer,
"diff --git {} {}",
quote_path_for_patch("a/", path),
quote_path_for_patch("b/", path)
)?;
if is_added {
writeln!(writer, "new file mode {}", mode_str(change.mode))?;
writer.write_all(b"index 0000000..0000000\n")?;
} else if is_deleted {
writeln!(writer, "deleted file mode {}", mode_str(change.mode))?;
writer.write_all(b"index 0000000..0000000\n")?;
} else if mode_changed {
writeln!(writer, "old mode {}", mode_str(change.old_mode))?;
writeln!(writer, "new mode {}", mode_str(change.mode))?;
writer.write_all(b"index 0000000..0000000\n")?;
} else {
writeln!(writer, "index 0000000..0000000 {}", mode_str(change.mode))?;
}
let (a, b) = if is_added {
("/dev/null".to_string(), quote_path_for_patch("b/", path))
} else if is_deleted {
(quote_path_for_patch("a/", path), "/dev/null".to_string())
} else {
(
quote_path_for_patch("a/", path),
quote_path_for_patch("b/", path),
)
};
writeln!(writer, "Binary files {a} and {b} differ")?;
Ok(())
}
fn mode_str(mode: Option<FileMode>) -> &'static str {
match mode {
Some(FileMode::Executable) => "100755",
Some(FileMode::Symlink) => "120000",
Some(FileMode::Gitlink) => "160000",
Some(FileMode::Normal) | Some(FileMode::Spoollink) | None => "100644",
}
}
fn quote_path_for_patch(prefix: &str, path: &str) -> String {
if !needs_c_quoting(prefix) && !needs_c_quoting(path) {
return format!("{prefix}{path}");
}
let mut out = String::with_capacity(prefix.len() + path.len() + 2);
out.push('"');
push_c_quoted(&mut out, prefix);
push_c_quoted(&mut out, path);
out.push('"');
out
}
fn needs_c_quoting(s: &str) -> bool {
s.bytes().any(byte_needs_escape)
}
fn byte_needs_escape(byte: u8) -> bool {
matches!(byte, b'"' | b'\\') || !(0x20..0x7f).contains(&byte)
}
fn push_c_quoted(out: &mut String, s: &str) {
for byte in s.bytes() {
match byte {
b'"' => out.push_str("\\\""),
b'\\' => out.push_str("\\\\"),
0x07 => out.push_str("\\a"),
0x08 => out.push_str("\\b"),
0x09 => out.push_str("\\t"),
0x0a => out.push_str("\\n"),
0x0b => out.push_str("\\v"),
0x0c => out.push_str("\\f"),
0x0d => out.push_str("\\r"),
0x20..=0x7e => out.push(byte as char),
other => out.push_str(&format!("\\{other:03o}")),
}
}
}
const NO_NEWLINE_MARKER: &str = "\\ No newline at end of file\n";
fn write_patch_hunks<W: Write>(
change: &FileChange,
lines: &[LineDiff],
writer: &mut W,
) -> io::Result<()> {
let old_no_eol = !change.eol.old_has_final_newline;
let new_no_eol = !change.eol.new_has_final_newline;
let old_tail_idx = if old_no_eol && change.eol.old_line_count > 0 {
find_side_tail_idx(lines, Side::Old, change.eol.old_line_count)
} else {
None
};
let new_tail_idx = if new_no_eol && change.eol.new_line_count > 0 {
find_side_tail_idx(lines, Side::New, change.eol.new_line_count)
} else {
None
};
for (idx, line) in lines.iter().enumerate() {
let is_old_tail = Some(idx) == old_tail_idx;
let is_new_tail = Some(idx) == new_tail_idx;
let needs_old_marker = is_old_tail && old_no_eol;
let needs_new_marker = is_new_tail && new_no_eol;
if line.prefix == " " && (needs_old_marker || needs_new_marker) {
if is_old_tail && is_new_tail && needs_old_marker && needs_new_marker {
write_patch_line(writer, line)?;
writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
} else {
writer.write_all(b"-")?;
writer.write_all(line.content.as_bytes())?;
writer.write_all(b"\n")?;
if needs_old_marker {
writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
}
writer.write_all(b"+")?;
writer.write_all(line.content.as_bytes())?;
writer.write_all(b"\n")?;
if needs_new_marker {
writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
}
}
continue;
}
write_patch_line(writer, line)?;
if needs_old_marker && line.prefix == "-" {
writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
}
if needs_new_marker && line.prefix == "+" {
writer.write_all(NO_NEWLINE_MARKER.as_bytes())?;
}
}
Ok(())
}
#[derive(Clone, Copy)]
enum Side {
Old,
New,
}
fn find_side_tail_idx(lines: &[LineDiff], side: Side, target: usize) -> Option<usize> {
lines.iter().enumerate().rev().find_map(|(idx, line)| {
let (on_side, line_number) = match side {
Side::Old => (line.prefix == "-" || line.prefix == " ", line.old_line),
Side::New => (line.prefix == "+" || line.prefix == " ", line.new_line),
};
if on_side && line_number == Some(target) {
Some(idx)
} else {
None
}
})
}
fn write_patch_line<W: Write>(writer: &mut W, line: &LineDiff) -> io::Result<()> {
writer.write_all(line.prefix.as_bytes())?;
writer.write_all(line.content.as_bytes())?;
writer.write_all(b"\n")
}
#[cfg(test)]
mod tests {
use objects::object::FileMode;
use super::{quote_path_for_patch, render_diff_patch, render_diff_patch_bytes};
use crate::diff::{DiffReport, FileChange, FileEolState, LineDiff, SymlinkChange};
fn modified_change_with_eol(path: &str, lines: Vec<LineDiff>, eol: FileEolState) -> FileChange {
FileChange {
path: path.to_string(),
kind: "modified".to_string(),
lines: Some(lines),
eol,
..Default::default()
}
}
fn diff_report_with(changes: Vec<FileChange>) -> DiffReport {
DiffReport::new(None, None, changes, None, None, None)
}
#[cfg(unix)]
fn hermetic_git_command(dir: &std::path::Path, args: &[&str]) -> std::process::Command {
let mut command = std::process::Command::new("git");
command
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "Heddle Test")
.env("GIT_AUTHOR_EMAIL", "heddle@example.com")
.env("GIT_COMMITTER_NAME", "Heddle Test")
.env("GIT_COMMITTER_EMAIL", "heddle@example.com");
command
}
#[cfg(unix)]
fn hermetic_git(dir: &std::path::Path, args: &[&str]) {
let status = hermetic_git_command(dir, args)
.status()
.unwrap_or_else(|err| panic!("git {args:?} should spawn: {err}"));
assert!(status.success(), "git {args:?} should succeed");
}
#[cfg(unix)]
fn pipe_git_apply(dir: &std::path::Path, args: &[&str], patch: &[u8]) -> std::process::Output {
use std::{io::Write, process::Stdio};
let mut child = hermetic_git_command(dir, args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|err| panic!("git {args:?} should spawn: {err}"));
child.stdin.as_mut().unwrap().write_all(patch).unwrap();
child
.wait_with_output()
.unwrap_or_else(|err| panic!("git {args:?} should finish: {err}"))
}
#[cfg(unix)]
#[test]
fn render_diff_patch_bytes_applies_non_utf8_symlink_target_byte_exactly() {
use std::os::unix::ffi::OsStrExt;
let target = b"target-\xff\xfe";
let change = FileChange {
path: "linky".to_string(),
kind: "added".to_string(),
mode: Some(FileMode::Symlink),
symlink: Some(SymlinkChange {
old: None,
new: Some(target.to_vec()),
}),
..Default::default()
};
let patch = render_diff_patch_bytes(&diff_report_with(vec![change]));
assert!(
patch.windows(target.len()).any(|window| window == target),
"patch must carry the raw non-UTF-8 target bytes:\n{}",
String::from_utf8_lossy(&patch)
);
let scratch = tempfile::TempDir::new().unwrap();
hermetic_git(scratch.path(), &["init", "-q"]);
hermetic_git(scratch.path(), &["checkout", "-q", "-b", "main"]);
let check = pipe_git_apply(scratch.path(), &["apply", "--check"], &patch);
assert!(
check.status.success(),
"git apply --check rejected patch;\nstderr={}\npatch=\n{}",
String::from_utf8_lossy(&check.stderr),
String::from_utf8_lossy(&patch)
);
let applied = pipe_git_apply(scratch.path(), &["apply"], &patch);
assert!(
applied.status.success(),
"git apply rejected patch;\nstderr={}\npatch=\n{}",
String::from_utf8_lossy(&applied.stderr),
String::from_utf8_lossy(&patch)
);
let applied_target = std::fs::read_link(scratch.path().join("linky")).unwrap();
assert_eq!(
applied_target.as_os_str().as_bytes(),
target,
"applied symlink target must be byte-exact"
);
}
#[test]
fn render_diff_patch_emits_mode_only_header_for_chmod() {
let change = FileChange {
path: "run.sh".to_string(),
kind: "modified".to_string(),
lines: Some(Vec::new()),
old_mode: Some(FileMode::Normal),
mode: Some(FileMode::Executable),
..Default::default()
};
let rendered = render_diff_patch(&diff_report_with(vec![change]));
assert!(
rendered.contains("diff --git a/run.sh b/run.sh"),
"chmod-only must emit the `diff --git` header:\n{rendered}"
);
assert!(
rendered.contains("old mode 100644") && rendered.contains("new mode 100755"),
"chmod-only must emit `old mode`/`new mode`:\n{rendered}"
);
assert!(
!rendered.contains("@@") && !rendered.contains("--- a/"),
"chmod-only is header-only — no hunk body:\n{rendered}"
);
}
#[test]
fn render_diff_patch_emits_gitlink_mode_without_blob_hunk() {
let change = FileChange {
path: "vendor".to_string(),
kind: "added".to_string(),
lines: Some(Vec::new()),
mode: Some(FileMode::Gitlink),
..Default::default()
};
let rendered = render_diff_patch(&diff_report_with(vec![change]));
assert!(
rendered.contains("new file mode 160000"),
"gitlinks must render their durable mode, not a regular-file mode:\n{rendered}"
);
assert!(
!rendered.contains("@@") && !rendered.contains("heddle-submodule:"),
"gitlink patch output must not synthesize legacy marker blob content:\n{rendered}"
);
}
#[test]
fn render_diff_patch_emits_mode_headers_with_content_hunk() {
let change = FileChange {
path: "run.sh".to_string(),
kind: "modified".to_string(),
lines: Some(vec![
LineDiff::with_lines("@", "@ -1,1 +1,1 @@", None, None),
LineDiff::with_lines("-", "echo old", Some(1), None),
LineDiff::with_lines("+", "echo new", None, Some(1)),
]),
old_mode: Some(FileMode::Normal),
mode: Some(FileMode::Executable),
..Default::default()
};
let rendered = render_diff_patch(&diff_report_with(vec![change]));
assert!(
rendered.contains("old mode 100644") && rendered.contains("new mode 100755"),
"content+mode change must still emit the mode headers:\n{rendered}"
);
assert!(
rendered.contains("--- a/run.sh")
&& rendered.contains("+++ b/run.sh")
&& rendered.contains("+echo new"),
"content+mode change must still emit the line-diff body:\n{rendered}"
);
}
#[test]
fn render_diff_patch_skips_modify_with_same_mode_and_no_body() {
let change = FileChange {
path: "run.sh".to_string(),
kind: "modified".to_string(),
lines: Some(Vec::new()),
old_mode: Some(FileMode::Normal),
mode: Some(FileMode::Normal),
..Default::default()
};
let rendered = render_diff_patch(&diff_report_with(vec![change]));
assert!(
rendered.is_empty(),
"no-op modify (same mode, no body) must emit nothing:\n{rendered}"
);
}
#[test]
fn render_diff_patch_binary_modify_emits_marker_with_index() {
let change = FileChange {
path: "binary.bin".to_string(),
kind: "modified".to_string(),
binary: true,
lines: None,
mode: Some(FileMode::Normal),
old_mode: Some(FileMode::Normal),
..Default::default()
};
let rendered = render_diff_patch(&diff_report_with(vec![change]));
assert!(
rendered.contains("diff --git a/binary.bin b/binary.bin"),
"binary modify must emit a diff header:\n{rendered}"
);
assert!(
rendered.contains("index 0000000..0000000 100644"),
"binary modify must emit a placeholder index line:\n{rendered}"
);
assert!(
rendered.contains("Binary files a/binary.bin and b/binary.bin differ"),
"binary modify must emit the binary marker:\n{rendered}"
);
assert!(
!rendered.contains("--- a/binary.bin"),
"binary modify must not emit a text hunk header:\n{rendered}"
);
}
#[test]
fn render_diff_patch_binary_modify_with_mode_change_keeps_marker() {
let change = FileChange {
path: "binary.bin".to_string(),
kind: "modified".to_string(),
binary: true,
lines: None,
old_mode: Some(FileMode::Normal),
mode: Some(FileMode::Executable),
..Default::default()
};
let rendered = render_diff_patch(&diff_report_with(vec![change]));
assert!(
rendered.contains("old mode 100644") && rendered.contains("new mode 100755"),
"binary+mode change must still record the chmod:\n{rendered}"
);
assert!(
rendered.contains("index 0000000..0000000"),
"binary+mode change must emit the placeholder index line:\n{rendered}"
);
assert!(
rendered.contains("Binary files a/binary.bin and b/binary.bin differ"),
"binary+mode change must still emit the binary marker:\n{rendered}"
);
}
#[test]
fn render_diff_patch_binary_add_and_delete_emit_markers() {
let added = FileChange {
path: "added.bin".to_string(),
kind: "added".to_string(),
binary: true,
lines: None,
mode: Some(FileMode::Normal),
..Default::default()
};
let rendered = render_diff_patch(&diff_report_with(vec![added]));
assert!(
rendered.contains("new file mode 100644")
&& rendered.contains("index 0000000..0000000")
&& rendered.contains("Binary files /dev/null and b/added.bin differ"),
"binary add marker:\n{rendered}"
);
let deleted = FileChange {
path: "gone.bin".to_string(),
kind: "deleted".to_string(),
binary: true,
lines: None,
mode: Some(FileMode::Normal),
..Default::default()
};
let rendered = render_diff_patch(&diff_report_with(vec![deleted]));
assert!(
rendered.contains("deleted file mode 100644")
&& rendered.contains("index 0000000..0000000")
&& rendered.contains("Binary files a/gone.bin and /dev/null differ"),
"binary delete marker:\n{rendered}"
);
}
#[test]
fn render_diff_patch_skips_change_with_empty_lines() {
let empty = FileChange {
path: "empty.txt".to_string(),
kind: "modified".to_string(),
lines: Some(Vec::new()),
..Default::default()
};
let real = modified_change_with_eol(
"real.txt",
vec![
LineDiff::with_lines("@", "@ -1,1 +1,1 @@", None, None),
LineDiff::with_lines("-", "old", Some(1), None),
LineDiff::with_lines("+", "new", None, Some(1)),
],
FileEolState::default(),
);
let rendered = render_diff_patch(&diff_report_with(vec![empty, real]));
assert!(
!rendered.contains("empty.txt"),
"skipped change must not emit a header: {rendered}"
);
assert!(
rendered.contains("--- a/real.txt"),
"renderable change must still be emitted: {rendered}"
);
}
#[test]
fn render_diff_patch_collapses_both_side_no_eol_marker_on_shared_tail() {
let lines = vec![
LineDiff::with_lines("@", "@ -1,2 +1,2 @@", None, None),
LineDiff::with_lines("-", "hello", Some(1), None),
LineDiff::with_lines("+", "world", None, Some(1)),
LineDiff::with_lines(" ", "more", Some(2), Some(2)),
];
let eol = FileEolState {
old_has_final_newline: false,
new_has_final_newline: false,
old_line_count: 2,
new_line_count: 2,
};
let change = modified_change_with_eol("tail.txt", lines, eol);
let rendered = render_diff_patch(&diff_report_with(vec![change]));
let marker_count = rendered.matches("\\ No newline at end of file").count();
assert_eq!(
marker_count, 1,
"shared-tail double-no-eol must emit exactly one marker, got:\n{rendered}"
);
assert!(
!rendered.contains("-more\n"),
"context tail must not be split when both sides agree:\n{rendered}"
);
assert!(
!rendered.contains("+more\n"),
"context tail must not be split when both sides agree:\n{rendered}"
);
assert!(
rendered.contains(" more\n\\ No newline at end of file\n"),
"marker must sit immediately after the shared context line:\n{rendered}"
);
}
#[test]
fn render_diff_patch_splits_context_tail_when_only_old_lacks_newline() {
let lines = vec![
LineDiff::with_lines("@", "@ -1,1 +1,2 @@", None, None),
LineDiff::with_lines(" ", "hello", Some(1), Some(1)),
LineDiff::with_lines("+", "more", None, Some(2)),
];
let eol = FileEolState {
old_has_final_newline: false,
new_has_final_newline: true,
old_line_count: 1,
new_line_count: 2,
};
let change = modified_change_with_eol("old.txt", lines, eol);
let rendered = render_diff_patch(&diff_report_with(vec![change]));
assert!(
rendered.contains("-hello\n\\ No newline at end of file\n+hello\n"),
"OLD-side context-tail split must emit `-hello` + marker + `+hello`:\n{rendered}"
);
let marker_count = rendered.matches("\\ No newline at end of file").count();
assert_eq!(
marker_count, 1,
"exactly one marker expected (OLD side only):\n{rendered}"
);
}
#[test]
fn render_diff_patch_splits_context_tail_when_only_new_lacks_newline() {
let lines = vec![
LineDiff::with_lines("@", "@ -1,2 +1,1 @@", None, None),
LineDiff::with_lines(" ", "hello", Some(1), Some(1)),
LineDiff::with_lines("-", "more", Some(2), None),
];
let eol = FileEolState {
old_has_final_newline: true,
new_has_final_newline: false,
old_line_count: 2,
new_line_count: 1,
};
let change = modified_change_with_eol("new.txt", lines, eol);
let rendered = render_diff_patch(&diff_report_with(vec![change]));
assert!(
rendered.contains("-hello\n+hello\n\\ No newline at end of file\n"),
"NEW-side context-tail split must emit `-hello` + `+hello` + marker:\n{rendered}"
);
let marker_count = rendered.matches("\\ No newline at end of file").count();
assert_eq!(
marker_count, 1,
"exactly one marker expected (NEW side only):\n{rendered}"
);
}
#[test]
fn render_diff_patch_marker_after_minus_line_when_old_tail_is_deletion() {
let lines = vec![
LineDiff::with_lines("@", "@ -1,2 +1,1 @@", None, None),
LineDiff::with_lines("-", "only", Some(1), None),
LineDiff::with_lines("-", "tail", Some(2), None),
LineDiff::with_lines("+", "only", None, Some(1)),
];
let eol = FileEolState {
old_has_final_newline: false,
new_has_final_newline: true,
old_line_count: 2,
new_line_count: 1,
};
let change = modified_change_with_eol("del.txt", lines, eol);
let rendered = render_diff_patch(&diff_report_with(vec![change]));
assert!(
rendered.contains("-tail\n\\ No newline at end of file\n"),
"marker must follow the OLD tail deletion line:\n{rendered}"
);
}
#[test]
fn quote_path_matches_git_c_style() {
assert_eq!(quote_path_for_patch("a/", "src/main.rs"), "a/src/main.rs");
assert_eq!(
quote_path_for_patch("a/", "with space.txt"),
"a/with space.txt"
);
assert_eq!(quote_path_for_patch("a/", "tab\there"), "\"a/tab\\there\"");
assert_eq!(
quote_path_for_patch("b/", "line\nbreak"),
"\"b/line\\nbreak\""
);
assert_eq!(quote_path_for_patch("a/", "quo\"te"), "\"a/quo\\\"te\"");
assert_eq!(
quote_path_for_patch("a/", "back\\slash"),
"\"a/back\\\\slash\""
);
assert_eq!(quote_path_for_patch("a/", "café"), "\"a/caf\\303\\251\"");
assert_eq!(quote_path_for_patch("", "x\ty"), "\"x\\ty\"");
assert_eq!(
quote_path_for_patch("", "\u{07}\u{08}\u{0b}\u{0c}\r\u{01}"),
"\"\\a\\b\\v\\f\\r\\001\""
);
}
}