#[derive(Debug, Clone)]
pub struct DiffFile {
pub path: String,
pub header_lines: Vec<String>,
pub is_new: bool,
pub is_deleted: bool,
pub hunks: Vec<DiffHunk>,
}
#[derive(Debug, Clone)]
pub struct DiffHunk {
pub header: String,
pub lines: Vec<String>,
pub new_start: u32,
pub new_count: u32,
}
impl DiffFile {
pub fn synthetic_label(&self) -> &'static str {
if self.is_new {
"new file"
} else if self.is_deleted {
"deleted file"
} else {
"empty change"
}
}
}
pub fn parse_diff(diff_text: &str) -> Vec<DiffFile> {
let mut files: Vec<DiffFile> = Vec::new();
let lines: Vec<&str> = diff_text.lines().collect();
let mut i = 0;
while i < lines.len() {
if !lines[i].starts_with("diff --git ") {
i += 1;
continue;
}
let mut header_lines = vec![lines[i].to_string()];
let mut is_new = false;
let mut is_deleted = false;
let mut path = extract_path_from_diff_line(lines[i]);
i += 1;
while i < lines.len() && !lines[i].starts_with("diff --git ") && !lines[i].starts_with("@@")
{
let line = lines[i];
header_lines.push(line.to_string());
if line.starts_with("new file mode") {
is_new = true;
} else if line.starts_with("deleted file mode") {
is_deleted = true;
} else if let Some(parsed_path) = extract_path_from_header(line, "+++ ") {
path = parsed_path;
} else if is_deleted && let Some(parsed_path) = extract_path_from_header(line, "--- ") {
path = parsed_path;
}
i += 1;
}
let mut hunks = Vec::new();
while i < lines.len() && !lines[i].starts_with("diff --git ") {
if lines[i].starts_with("@@") {
let header = lines[i].to_string();
let (new_start, new_count) = parse_hunk_header(lines[i]);
i += 1;
let mut hunk_lines = Vec::new();
while i < lines.len()
&& !lines[i].starts_with("@@")
&& !lines[i].starts_with("diff --git ")
{
hunk_lines.push(lines[i].to_string());
i += 1;
}
hunks.push(DiffHunk {
header,
lines: hunk_lines,
new_start,
new_count,
});
} else {
i += 1;
}
}
if hunks.is_empty() && (is_new || is_deleted) {
hunks.push(DiffHunk {
header: String::new(),
lines: Vec::new(),
new_start: 0,
new_count: 0,
});
}
files.push(DiffFile {
path,
header_lines,
is_new,
is_deleted,
hunks,
});
}
files
}
fn extract_path_from_diff_line(line: &str) -> String {
let rest = &line["diff --git ".len()..];
let Some((_, rest)) = parse_git_path_token(rest) else {
return rest.to_string();
};
parse_git_path_token(rest.trim_start())
.map(|(path, _)| strip_diff_prefix(path))
.unwrap_or_else(|| rest.to_string())
}
fn extract_path_from_header(line: &str, prefix: &str) -> Option<String> {
let rest = line.strip_prefix(prefix)?;
if rest == "/dev/null" {
return None;
}
parse_git_path_token(rest).map(|(path, _)| strip_diff_prefix(path))
}
fn strip_diff_prefix(path: String) -> String {
path.strip_prefix("a/")
.or_else(|| path.strip_prefix("b/"))
.unwrap_or(&path)
.to_string()
}
fn parse_git_path_token(input: &str) -> Option<(String, &str)> {
if input.is_empty() {
return None;
}
if !input.starts_with('"') {
let end = input.find(char::is_whitespace).unwrap_or(input.len());
return Some((input[..end].to_string(), &input[end..]));
}
let bytes = input.as_bytes();
let mut decoded = Vec::new();
let mut index = 1;
while index < bytes.len() {
match bytes[index] {
b'"' => {
let path = String::from_utf8(decoded)
.unwrap_or_else(|error| String::from_utf8_lossy(&error.into_bytes()).into());
return Some((path, &input[index + 1..]));
}
b'\\' if index + 1 < bytes.len() => {
index += 1;
let escaped = bytes[index];
let byte = match escaped {
b'a' => b'\x07',
b'b' => b'\x08',
b'f' => b'\x0c',
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'v' => b'\x0b',
b'\\' | b'"' => escaped,
b'0'..=b'7' => {
let mut value = escaped - b'0';
for _ in 0..2 {
if index + 1 >= bytes.len()
|| !(b'0'..=b'7').contains(&bytes[index + 1])
{
break;
}
index += 1;
value = value * 8 + (bytes[index] - b'0');
}
value
}
_ => escaped,
};
decoded.push(byte);
}
byte => decoded.push(byte),
}
index += 1;
}
None
}
fn parse_hunk_header(line: &str) -> (u32, u32) {
let after_plus = match line.find('+') {
Some(pos) => &line[pos + 1..],
None => return (1, 0),
};
let nums_end = after_plus
.find(|c: char| c != ',' && !c.is_ascii_digit())
.unwrap_or(after_plus.len());
let nums = &after_plus[..nums_end];
let parts: Vec<&str> = nums.splitn(2, ',').collect();
let start = parts[0].parse::<u32>().unwrap_or(1);
let count = if parts.len() > 1 {
parts[1].parse::<u32>().unwrap_or(0)
} else {
1
};
(start, count)
}
pub fn reconstruct_patch(file: &DiffFile, hunk_indices: &[usize]) -> String {
let mut out = String::new();
for header_line in &file.header_lines {
out.push_str(header_line);
out.push('\n');
}
for &idx in hunk_indices {
if let Some(hunk) = file.hunks.get(idx) {
if !hunk.header.is_empty() {
out.push_str(&hunk.header);
out.push('\n');
}
for line in &hunk.lines {
out.push_str(line);
out.push('\n');
}
}
}
out
}
pub fn reconstruct_full_patch(files: &[DiffFile], selections: &[(usize, Vec<usize>)]) -> String {
let mut out = String::new();
for (file_idx, hunk_indices) in selections {
if let Some(file) = files.get(*file_idx) {
out.push_str(&reconstruct_patch(file, hunk_indices));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const SINGLE_FILE_SINGLE_HUNK: &str = "\
diff --git a/src/main.rs b/src/main.rs
index abc1234..def5678 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,4 @@
fn main() {
+ println!(\"hello\");
let x = 1;
}";
const SINGLE_FILE_MULTIPLE_HUNKS: &str = "\
diff --git a/src/lib.rs b/src/lib.rs
index 1111111..2222222 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,5 @@
use std::io;
+use std::fs;
fn foo() {
bar();
@@ -20,6 +21,7 @@
fn baz() {
let a = 1;
+ let b = 2;
let c = 3;
}";
const MULTIPLE_FILES: &str = "\
diff --git a/src/a.rs b/src/a.rs
index aaa..bbb 100644
--- a/src/a.rs
+++ b/src/a.rs
@@ -1,3 +1,4 @@
fn a() {
+ // a
}
diff --git a/src/b.rs b/src/b.rs
index ccc..ddd 100644
--- a/src/b.rs
+++ b/src/b.rs
@@ -5,3 +5,4 @@
fn b() {
+ // b
}";
const NEW_FILE: &str = "\
diff --git a/src/new.rs b/src/new.rs
new file mode 100644
index 0000000..abc1234
--- /dev/null
+++ b/src/new.rs
@@ -0,0 +1,3 @@
+fn new_func() {
+ todo!()
+}";
const QUOTED_PATHS: &str = r#"diff --git "a/quote\"name.txt" "b/quote\"name.txt"
index abc1234..def5678 100644
--- "a/quote\"name.txt"
+++ "b/quote\"name.txt"
@@ -1 +1 @@
-before
+after
diff --git "a/tab\tname.txt" "b/tab\tname.txt"
index abc1234..def5678 100644
--- "a/tab\tname.txt"
+++ "b/tab\tname.txt"
@@ -1 +1 @@
-before
+after"#;
#[test]
fn test_parse_single_file_single_hunk() {
let files = parse_diff(SINGLE_FILE_SINGLE_HUNK);
assert_eq!(files.len(), 1);
let file = &files[0];
assert_eq!(file.path, "src/main.rs");
assert!(!file.is_new);
assert!(!file.is_deleted);
assert_eq!(file.hunks.len(), 1);
let hunk = &file.hunks[0];
assert_eq!(hunk.new_start, 1);
assert_eq!(hunk.new_count, 4);
assert_eq!(hunk.lines.len(), 4);
}
#[test]
fn test_parse_multiple_hunks() {
let files = parse_diff(SINGLE_FILE_MULTIPLE_HUNKS);
assert_eq!(files.len(), 1);
let file = &files[0];
assert_eq!(file.path, "src/lib.rs");
assert_eq!(file.hunks.len(), 2);
assert_eq!(file.hunks[0].new_start, 1);
assert_eq!(file.hunks[0].new_count, 5);
assert_eq!(file.hunks[1].new_start, 21);
assert_eq!(file.hunks[1].new_count, 7);
}
#[test]
fn test_parse_multiple_files() {
let files = parse_diff(MULTIPLE_FILES);
assert_eq!(files.len(), 2);
assert_eq!(files[0].path, "src/a.rs");
assert_eq!(files[1].path, "src/b.rs");
assert_eq!(files[0].hunks.len(), 1);
assert_eq!(files[1].hunks.len(), 1);
assert_eq!(files[1].hunks[0].new_start, 5);
}
#[test]
fn test_parse_new_file() {
let files = parse_diff(NEW_FILE);
assert_eq!(files.len(), 1);
let file = &files[0];
assert_eq!(file.path, "src/new.rs");
assert!(file.is_new);
assert!(!file.is_deleted);
assert_eq!(file.hunks.len(), 1);
assert_eq!(file.hunks[0].new_start, 1);
assert_eq!(file.hunks[0].new_count, 3);
}
#[test]
fn test_parse_c_quoted_paths_for_display_without_changing_patch_headers() {
let files = parse_diff(QUOTED_PATHS);
assert_eq!(files.len(), 2);
assert_eq!(files[0].path, "quote\"name.txt");
assert_eq!(files[1].path, "tab\tname.txt");
let patch = reconstruct_full_patch(&files, &[(0, vec![0]), (1, vec![0])]);
assert!(patch.contains("+++ \"b/quote\\\"name.txt\""));
assert!(patch.contains("+++ \"b/tab\\tname.txt\""));
}
#[test]
fn test_reconstruct_patch_single_hunk() {
let files = parse_diff(SINGLE_FILE_MULTIPLE_HUNKS);
let file = &files[0];
let patch = reconstruct_patch(file, &[0]);
assert!(patch.contains("@@ -1,4 +1,5 @@"));
assert!(patch.contains("+use std::fs;"));
assert!(!patch.contains("@@ -20,6 +21,7 @@"));
assert!(!patch.contains("+ let b = 2;"));
let patch_both = reconstruct_patch(file, &[0, 1]);
assert!(patch_both.contains("@@ -1,4 +1,5 @@"));
assert!(patch_both.contains("@@ -20,6 +21,7 @@"));
}
#[test]
fn test_parse_empty_diff() {
assert!(parse_diff("").is_empty());
assert!(parse_diff("\n\n").is_empty());
}
#[test]
fn test_parse_deleted_file() {
let diff = "\
diff --git a/src/old.rs b/src/old.rs
deleted file mode 100644
index abc1234..0000000
--- a/src/old.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-fn old() {
- // removed
-}";
let files = parse_diff(diff);
assert_eq!(files.len(), 1);
assert!(files[0].is_deleted);
assert!(!files[0].is_new);
assert_eq!(files[0].hunks.len(), 1);
assert_eq!(files[0].hunks[0].lines.len(), 3);
}
#[test]
fn test_reconstruct_full_patch_multiple_files() {
let files = parse_diff(MULTIPLE_FILES);
let selections = vec![(0, vec![0]), (1, vec![0])];
let patch = reconstruct_full_patch(&files, &selections);
assert!(patch.contains("diff --git a/src/a.rs"));
assert!(patch.contains("diff --git a/src/b.rs"));
assert!(patch.contains("+ // a"));
assert!(patch.contains("+ // b"));
}
#[test]
fn test_reconstruct_full_patch_selective() {
let files = parse_diff(MULTIPLE_FILES);
let selections = vec![(0, vec![0])];
let patch = reconstruct_full_patch(&files, &selections);
assert!(patch.contains("diff --git a/src/a.rs"));
assert!(!patch.contains("diff --git a/src/b.rs"));
}
#[test]
fn test_reconstruct_full_patch_empty_selections() {
let files = parse_diff(MULTIPLE_FILES);
let selections: Vec<(usize, Vec<usize>)> = vec![];
let patch = reconstruct_full_patch(&files, &selections);
assert!(patch.is_empty());
}
}