use std::fmt;
use crate::diff::{DiffLine, LineSource};
const CONTEXT_LINES: usize = 3;
#[derive(Debug, Clone)]
struct PatchLine {
prefix: char,
content: String,
old_line: Option<usize>,
new_line: Option<usize>,
}
#[derive(Debug)]
struct Hunk {
old_start: usize,
old_count: usize,
new_start: usize,
new_count: usize,
lines: Vec<PatchLine>,
}
impl Hunk {
fn header(&self) -> String {
let old_range = format_range(self.old_start, self.old_count);
let new_range = format_range(self.new_start, self.new_count);
format!("@@ -{} +{} @@", old_range, new_range)
}
}
fn format_range(start: usize, count: usize) -> String {
if count == 1 {
start.to_string()
} else {
format!("{},{}", start, count)
}
}
impl fmt::Display for Hunk {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{}", self.header())?;
for line in &self.lines {
writeln!(f, "{}{}", line.prefix, line.content)?;
}
Ok(())
}
}
#[derive(Debug)]
struct FilePatch {
path: String,
hunks: Vec<Hunk>,
is_new_file: bool,
is_deleted_file: bool,
}
impl FilePatch {
fn new(path: String, hunks: Vec<Hunk>) -> Self {
let is_new_file = !hunks.is_empty() && hunks.iter().all(|h| h.old_count == 0);
let is_deleted_file = !hunks.is_empty() && hunks.iter().all(|h| h.new_count == 0);
Self {
path,
hunks,
is_new_file,
is_deleted_file,
}
}
}
impl fmt::Display for FilePatch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.hunks.is_empty() {
return Ok(());
}
writeln!(f, "diff --git a/{} b/{}", self.path, self.path)?;
if self.is_new_file {
writeln!(f, "new file mode 100644")?;
writeln!(f, "--- /dev/null")?;
writeln!(f, "+++ b/{}", self.path)?;
} else if self.is_deleted_file {
writeln!(f, "deleted file mode 100644")?;
writeln!(f, "--- a/{}", self.path)?;
writeln!(f, "+++ /dev/null")?;
} else {
writeln!(f, "--- a/{}", self.path)?;
writeln!(f, "+++ b/{}", self.path)?;
}
for hunk in &self.hunks {
write!(f, "{}", hunk)?;
}
Ok(())
}
}
fn line_source_to_prefix(source: &LineSource) -> Option<char> {
match source {
LineSource::Base => Some(' '),
LineSource::Committed | LineSource::Staged | LineSource::Unstaged => Some('+'),
LineSource::DeletedBase | LineSource::DeletedCommitted | LineSource::DeletedStaged => {
Some('-')
}
LineSource::CanceledCommitted
| LineSource::CanceledStaged
| LineSource::FileHeader
| LineSource::Elided => None,
}
}
fn diff_lines_to_patch_lines(lines: &[DiffLine]) -> Vec<PatchLine> {
let mut patch_lines = Vec::new();
let mut old_line_num = 0usize;
let mut new_line_num = 0usize;
for diff_line in lines {
let Some(prefix) = line_source_to_prefix(&diff_line.source) else {
continue;
};
let (old_line, new_line) = match prefix {
' ' => {
old_line_num += 1;
new_line_num += 1;
(Some(old_line_num), Some(new_line_num))
}
'-' => {
old_line_num += 1;
(Some(old_line_num), None)
}
'+' => {
new_line_num += 1;
(None, Some(new_line_num))
}
_ => (None, None),
};
patch_lines.push(PatchLine {
prefix,
content: diff_line.content.clone(),
old_line,
new_line,
});
}
patch_lines
}
fn find_change_indices(lines: &[PatchLine]) -> Vec<usize> {
lines
.iter()
.enumerate()
.filter(|(_, line)| line.prefix == '+' || line.prefix == '-')
.map(|(i, _)| i)
.collect()
}
fn build_hunks(lines: &[PatchLine]) -> Vec<Hunk> {
if lines.is_empty() {
return Vec::new();
}
let change_indices = find_change_indices(lines);
if change_indices.is_empty() {
return Vec::new();
}
let mut included = vec![false; lines.len()];
for &idx in &change_indices {
included[idx] = true;
let start = idx.saturating_sub(CONTEXT_LINES);
for item in included.iter_mut().take(idx).skip(start) {
*item = true;
}
let end = (idx + CONTEXT_LINES + 1).min(lines.len());
for item in included.iter_mut().take(end).skip(idx + 1) {
*item = true;
}
}
let mut hunks = Vec::new();
let mut hunk_start: Option<usize> = None;
for (i, &inc) in included.iter().enumerate() {
match (inc, hunk_start) {
(true, None) => {
hunk_start = Some(i);
}
(false, Some(start)) => {
hunks.push(create_hunk(&lines[start..i]));
hunk_start = None;
}
_ => {}
}
}
if let Some(start) = hunk_start {
hunks.push(create_hunk(&lines[start..]));
}
hunks
}
fn create_hunk(lines: &[PatchLine]) -> Hunk {
let mut old_count = 0;
let mut new_count = 0;
let mut old_start = None;
let mut new_start = None;
for line in lines {
match line.prefix {
' ' => {
old_count += 1;
new_count += 1;
if old_start.is_none() {
old_start = line.old_line;
}
if new_start.is_none() {
new_start = line.new_line;
}
}
'-' => {
old_count += 1;
if old_start.is_none() {
old_start = line.old_line;
}
}
'+' => {
new_count += 1;
if new_start.is_none() {
new_start = line.new_line;
}
}
_ => {}
}
}
let old_start = if old_count == 0 { 0 } else { old_start.unwrap_or(1) };
let new_start = if new_count == 0 { 0 } else { new_start.unwrap_or(1) };
Hunk {
old_start,
old_count,
new_start,
new_count,
lines: lines.to_vec(),
}
}
pub fn generate_patch(lines: &[DiffLine]) -> String {
let mut files: Vec<(String, Vec<&DiffLine>)> = Vec::new();
let mut current_path: Option<String> = None;
for line in lines {
let path = match &line.file_path {
Some(p) if !p.is_empty() => p.clone(),
_ => continue, };
if current_path.as_ref() != Some(&path) {
files.push((path.clone(), Vec::new()));
current_path = Some(path);
}
if let Some((_, file_lines)) = files.last_mut() {
file_lines.push(line);
}
}
let mut output = String::new();
for (path, file_lines) in files {
let owned_lines: Vec<DiffLine> = file_lines.into_iter().cloned().collect();
let patch_lines = diff_lines_to_patch_lines(&owned_lines);
let hunks = build_hunks(&patch_lines);
let file_patch = FilePatch::new(path, hunks);
output.push_str(&file_patch.to_string());
}
output
}
#[cfg(test)]
mod tests {
use super::*;
fn make_diff_line(source: LineSource, content: &str, file_path: &str) -> DiffLine {
let prefix = match source {
LineSource::Base => ' ',
LineSource::Committed | LineSource::Staged | LineSource::Unstaged => '+',
LineSource::DeletedBase
| LineSource::DeletedCommitted
| LineSource::DeletedStaged => '-',
_ => ' ',
};
DiffLine::new(source, content.to_string(), prefix, None).with_file_path(file_path)
}
#[test]
fn test_simple_addition() {
let lines = vec![
make_diff_line(LineSource::Base, "line 1", "test.txt"),
make_diff_line(LineSource::Base, "line 2", "test.txt"),
make_diff_line(LineSource::Base, "line 3", "test.txt"),
make_diff_line(LineSource::Committed, "new line", "test.txt"),
make_diff_line(LineSource::Base, "line 4", "test.txt"),
make_diff_line(LineSource::Base, "line 5", "test.txt"),
make_diff_line(LineSource::Base, "line 6", "test.txt"),
];
let patch = generate_patch(&lines);
assert!(patch.contains("diff --git a/test.txt b/test.txt"));
assert!(patch.contains("--- a/test.txt"));
assert!(patch.contains("+++ b/test.txt"));
assert!(patch.contains("+new line"));
assert!(patch.contains("@@ -"));
}
#[test]
fn test_simple_deletion() {
let lines = vec![
make_diff_line(LineSource::Base, "line 1", "test.txt"),
make_diff_line(LineSource::Base, "line 2", "test.txt"),
make_diff_line(LineSource::Base, "line 3", "test.txt"),
make_diff_line(LineSource::DeletedCommitted, "deleted line", "test.txt"),
make_diff_line(LineSource::Base, "line 4", "test.txt"),
make_diff_line(LineSource::Base, "line 5", "test.txt"),
make_diff_line(LineSource::Base, "line 6", "test.txt"),
];
let patch = generate_patch(&lines);
assert!(patch.contains("-deleted line"));
}
#[test]
fn test_mixed_changes() {
let lines = vec![
make_diff_line(LineSource::Base, "context", "test.txt"),
make_diff_line(LineSource::DeletedStaged, "old line", "test.txt"),
make_diff_line(LineSource::Staged, "new line", "test.txt"),
make_diff_line(LineSource::Base, "more context", "test.txt"),
];
let patch = generate_patch(&lines);
assert!(patch.contains("-old line"));
assert!(patch.contains("+new line"));
}
#[test]
fn test_multiple_files() {
let lines = vec![
make_diff_line(LineSource::Base, "file1 line", "file1.txt"),
make_diff_line(LineSource::Committed, "file1 addition", "file1.txt"),
make_diff_line(LineSource::Base, "file2 line", "file2.txt"),
make_diff_line(LineSource::Unstaged, "file2 addition", "file2.txt"),
];
let patch = generate_patch(&lines);
assert!(patch.contains("diff --git a/file1.txt b/file1.txt"));
assert!(patch.contains("diff --git a/file2.txt b/file2.txt"));
assert!(patch.contains("+file1 addition"));
assert!(patch.contains("+file2 addition"));
}
#[test]
fn test_skips_canceled_lines() {
let lines = vec![
make_diff_line(LineSource::Base, "context", "test.txt"),
make_diff_line(LineSource::CanceledCommitted, "canceled", "test.txt"),
make_diff_line(LineSource::Committed, "actual change", "test.txt"),
];
let patch = generate_patch(&lines);
assert!(!patch.contains("canceled"));
assert!(patch.contains("+actual change"));
}
#[test]
fn test_skips_file_header() {
let lines = vec![
make_diff_line(LineSource::FileHeader, "src/test.txt", "test.txt"),
make_diff_line(LineSource::Base, "context", "test.txt"),
make_diff_line(LineSource::Committed, "change", "test.txt"),
];
let patch = generate_patch(&lines);
let lines: Vec<&str> = patch.lines().collect();
assert!(!lines.iter().any(|l| *l == "+src/test.txt" || *l == "-src/test.txt"));
}
#[test]
fn test_empty_diff() {
let lines: Vec<DiffLine> = vec![];
let patch = generate_patch(&lines);
assert!(patch.is_empty());
}
#[test]
fn test_no_changes() {
let lines = vec![
make_diff_line(LineSource::Base, "line 1", "test.txt"),
make_diff_line(LineSource::Base, "line 2", "test.txt"),
];
let patch = generate_patch(&lines);
assert!(!patch.contains("@@"));
}
#[test]
fn test_hunk_header_format_with_counts() {
let hunk = Hunk {
old_start: 10,
old_count: 5,
new_start: 12,
new_count: 7,
lines: vec![],
};
assert_eq!(hunk.header(), "@@ -10,5 +12,7 @@");
}
#[test]
fn test_hunk_header_omits_count_when_one() {
let hunk = Hunk {
old_start: 5,
old_count: 1,
new_start: 7,
new_count: 1,
lines: vec![],
};
assert_eq!(hunk.header(), "@@ -5 +7 @@");
}
#[test]
fn test_hunk_header_mixed_counts() {
let hunk = Hunk {
old_start: 10,
old_count: 1,
new_start: 12,
new_count: 3,
lines: vec![],
};
assert_eq!(hunk.header(), "@@ -10 +12,3 @@");
}
#[test]
fn test_hunk_header_zero_counts() {
let hunk = Hunk {
old_start: 0,
old_count: 0,
new_start: 1,
new_count: 5,
lines: vec![],
};
assert_eq!(hunk.header(), "@@ -0,0 +1,5 @@");
}
#[test]
fn test_context_limiting() {
let mut lines = Vec::new();
for i in 1..=20 {
lines.push(make_diff_line(
LineSource::Base,
&format!("line {}", i),
"test.txt",
));
}
lines.insert(
10,
make_diff_line(LineSource::Committed, "new line", "test.txt"),
);
let patch = generate_patch(&lines);
let line_count = patch.lines().count();
assert!(line_count < 15, "Patch should be limited: {}", line_count);
}
#[test]
fn test_all_change_types_combined() {
let lines = vec![
make_diff_line(LineSource::Base, "context 1", "test.txt"),
make_diff_line(LineSource::DeletedBase, "deleted base", "test.txt"),
make_diff_line(LineSource::Committed, "committed add", "test.txt"),
make_diff_line(LineSource::Base, "context 2", "test.txt"),
make_diff_line(LineSource::DeletedCommitted, "deleted committed", "test.txt"),
make_diff_line(LineSource::Staged, "staged add", "test.txt"),
make_diff_line(LineSource::Base, "context 3", "test.txt"),
make_diff_line(LineSource::DeletedStaged, "deleted staged", "test.txt"),
make_diff_line(LineSource::Unstaged, "unstaged add", "test.txt"),
];
let patch = generate_patch(&lines);
assert!(patch.contains("-deleted base"));
assert!(patch.contains("+committed add"));
assert!(patch.contains("-deleted committed"));
assert!(patch.contains("+staged add"));
assert!(patch.contains("-deleted staged"));
assert!(patch.contains("+unstaged add"));
}
#[test]
fn test_new_file_uses_dev_null() {
let lines = vec![
make_diff_line(LineSource::Committed, "line 1", "new_file.txt"),
make_diff_line(LineSource::Committed, "line 2", "new_file.txt"),
make_diff_line(LineSource::Committed, "line 3", "new_file.txt"),
];
let patch = generate_patch(&lines);
assert!(patch.contains("new file mode 100644"));
assert!(patch.contains("--- /dev/null"));
assert!(patch.contains("+++ b/new_file.txt"));
assert!(patch.contains("@@ -0,0 +1,3 @@"));
}
#[test]
fn test_deleted_file_uses_dev_null() {
let lines = vec![
make_diff_line(LineSource::DeletedCommitted, "line 1", "deleted.txt"),
make_diff_line(LineSource::DeletedCommitted, "line 2", "deleted.txt"),
];
let patch = generate_patch(&lines);
assert!(patch.contains("deleted file mode 100644"));
assert!(patch.contains("--- a/deleted.txt"));
assert!(patch.contains("+++ /dev/null"));
assert!(patch.contains("@@ -1,2 +0,0 @@"));
}
#[test]
fn test_line_numbers_are_correct() {
let lines = vec![
make_diff_line(LineSource::Base, "line 1", "test.txt"),
make_diff_line(LineSource::Base, "line 2", "test.txt"),
make_diff_line(LineSource::Base, "line 3", "test.txt"),
make_diff_line(LineSource::Committed, "inserted", "test.txt"),
make_diff_line(LineSource::Base, "line 4", "test.txt"),
];
let patch = generate_patch(&lines);
assert!(
patch.contains("@@ -1,4 +1,5 @@"),
"Unexpected hunk header in:\n{}",
patch
);
}
#[test]
fn test_deletion_line_numbers() {
let lines = vec![
make_diff_line(LineSource::Base, "keep 1", "test.txt"),
make_diff_line(LineSource::Base, "keep 2", "test.txt"),
make_diff_line(LineSource::DeletedCommitted, "removed", "test.txt"),
make_diff_line(LineSource::Base, "keep 3", "test.txt"),
make_diff_line(LineSource::Base, "keep 4", "test.txt"),
];
let patch = generate_patch(&lines);
assert!(
patch.contains("@@ -1,5 +1,4 @@"),
"Unexpected hunk header in:\n{}",
patch
);
}
#[test]
fn test_path_with_spaces() {
let lines = vec![
make_diff_line(LineSource::Base, "content", "path with spaces/file name.txt"),
make_diff_line(LineSource::Committed, "new", "path with spaces/file name.txt"),
];
let patch = generate_patch(&lines);
assert!(patch.contains("diff --git a/path with spaces/file name.txt b/path with spaces/file name.txt"));
assert!(patch.contains("--- a/path with spaces/file name.txt"));
assert!(patch.contains("+++ b/path with spaces/file name.txt"));
}
#[test]
fn test_lines_without_file_path_are_skipped() {
fn make_line_no_path(source: LineSource, content: &str) -> DiffLine {
DiffLine::new(source, content.to_string(), ' ', None)
}
let lines = vec![
make_line_no_path(LineSource::Base, "orphan line"),
make_diff_line(LineSource::Base, "context", "test.txt"),
make_diff_line(LineSource::Committed, "change", "test.txt"),
];
let patch = generate_patch(&lines);
assert!(!patch.contains("orphan"));
assert!(patch.contains("+change"));
}
#[test]
fn test_lines_with_empty_file_path_are_skipped() {
fn make_line_empty_path(source: LineSource, content: &str) -> DiffLine {
DiffLine::new(source, content.to_string(), ' ', None).with_file_path("")
}
let lines = vec![
make_line_empty_path(LineSource::Committed, "orphan"),
make_diff_line(LineSource::Committed, "real change", "test.txt"),
];
let patch = generate_patch(&lines);
assert!(!patch.contains("orphan"));
assert!(patch.contains("+real change"));
}
#[test]
fn test_format_range_helper() {
assert_eq!(format_range(1, 1), "1");
assert_eq!(format_range(5, 1), "5");
assert_eq!(format_range(1, 3), "1,3");
assert_eq!(format_range(10, 0), "10,0");
assert_eq!(format_range(0, 0), "0,0");
}
}