use tracing::warn;
use crate::util::unquote_c_style;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffFileStatus {
Modified,
Added,
Deleted,
Renamed,
Untracked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffContent {
Parseable,
Binary,
TooLarge(u64),
}
#[derive(Debug, Clone)]
pub struct DiffFile {
pub path: String,
pub old_path: Option<String>,
pub hunks: Vec<DiffHunk>,
pub status: DiffFileStatus,
pub content: DiffContent,
}
impl DiffFile {
#[must_use]
pub fn new(path: String, hunks: Vec<DiffHunk>, status: DiffFileStatus) -> Self {
Self {
path,
old_path: None,
hunks,
status,
content: DiffContent::Parseable,
}
}
#[must_use]
pub const fn placeholder(path: String, content: DiffContent) -> Self {
Self {
path,
old_path: None,
hunks: Vec::new(),
status: DiffFileStatus::Untracked,
content,
}
}
#[must_use]
pub(crate) fn has_parseable_content(&self) -> bool {
matches!(self.content, DiffContent::Parseable)
}
}
#[derive(Debug, Clone)]
pub struct DiffHunk {
pub header: String,
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Clone)]
pub struct DiffLine {
pub kind: DiffLineKind,
pub old_line_number: Option<usize>,
pub new_line_number: Option<usize>,
pub content: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineKind {
Added,
Removed,
Context,
}
impl DiffLineKind {
#[must_use]
pub const fn prefix(self) -> char {
match self {
Self::Added => '+',
Self::Removed => '-',
Self::Context => ' ',
}
}
}
#[derive(Default)]
struct DiffParser {
files: Vec<DiffFile>,
current_file: Option<DiffFile>,
current_hunk: Option<DiffHunk>,
old_counter: usize,
new_counter: usize,
}
impl DiffParser {
fn flush_hunk(&mut self) {
if let Some(hunk) = self.current_hunk.take()
&& let Some(f) = &mut self.current_file
{
f.hunks.push(hunk);
}
}
fn flush(&mut self) {
self.flush_hunk();
if let Some(file) = self.current_file.take() {
self.files.push(file);
}
}
fn handle_diff_git_header(&mut self, line: &str) {
self.flush();
self.old_counter = 0;
self.new_counter = 0;
if let Some(path) = parse_diff_git_line(line) {
self.current_file = Some(DiffFile::new(path, Vec::new(), DiffFileStatus::Modified));
}
}
fn handle_rename_from(&mut self, line: &str) {
let Some(f) = self.current_file.as_mut() else {
return;
};
let Some(raw) = line.strip_prefix("rename from ") else {
warn!(
line = %line,
"rename from: unexpected format, dropping rename info"
);
return;
};
let Some(old_path) = unquote_c_style(raw) else {
warn!(
line = %line,
"rename from: malformed C-style escape, dropping rename info"
);
return;
};
f.status = DiffFileStatus::Renamed;
f.old_path = Some(old_path);
}
fn handle_hunk_header(&mut self, line: &str) {
self.flush_hunk();
let (old_start, new_start) = parse_hunk_header(line);
self.old_counter = old_start;
self.new_counter = new_start;
self.current_hunk = Some(DiffHunk {
header: line.to_string(),
lines: Vec::new(),
});
}
fn handle_diff_content_line(&mut self, line: &str) {
let Some(hunk) = self.current_hunk.as_mut() else {
return;
};
let line_kind = if line.starts_with('+') {
DiffLineKind::Added
} else if line.starts_with('-') {
DiffLineKind::Removed
} else if line.starts_with(' ') {
DiffLineKind::Context
} else {
return;
};
let content = line[1..].trim_end_matches('\r');
let (old_num, new_num) = match line_kind {
DiffLineKind::Added => {
let n = Some(self.new_counter);
self.new_counter += 1;
(None, n)
}
DiffLineKind::Removed => {
let n = Some(self.old_counter);
self.old_counter += 1;
(n, None)
}
DiffLineKind::Context => {
let o = Some(self.old_counter);
let n = Some(self.new_counter);
self.old_counter += 1;
self.new_counter += 1;
(o, n)
}
};
hunk.lines.push(DiffLine {
kind: line_kind,
old_line_number: old_num,
new_line_number: new_num,
content: content.to_string(),
});
}
fn process_line(&mut self, line: &str) {
if line.starts_with("diff --git ") {
self.handle_diff_git_header(line);
} else if line.starts_with("index ")
|| line.starts_with("new file mode ")
|| line.starts_with("deleted file mode ")
|| line.starts_with("old mode ")
|| line.starts_with("new mode ")
|| line.starts_with("rename to ")
|| line.starts_with("\\ ")
{
} else if line.starts_with("--- ") || line.starts_with("+++ ") {
if let Some(ref mut f) = self.current_file {
if line.starts_with("--- /dev/null") {
f.status = DiffFileStatus::Added;
} else if line.starts_with("+++ /dev/null") {
f.status = DiffFileStatus::Deleted;
}
}
} else if line.starts_with("rename from ") {
self.handle_rename_from(line);
} else if line.starts_with("Binary files ") {
if let Some(ref mut f) = self.current_file {
f.content = DiffContent::Binary;
}
} else if line.starts_with("@@") {
self.handle_hunk_header(line);
} else {
self.handle_diff_content_line(line);
}
}
}
#[must_use]
pub fn parse_git_diff(diff_output: &str) -> Vec<DiffFile> {
let mut parser = DiffParser::default();
for line in diff_output.lines() {
parser.process_line(line);
}
parser.flush();
parser.files
}
#[must_use]
pub fn make_untracked_diff_file(path: &str, content: &str) -> DiffFile {
let lines: Vec<DiffLine> = content
.lines()
.enumerate()
.map(|(idx, line)| DiffLine {
kind: DiffLineKind::Added,
old_line_number: None,
new_line_number: Some(idx + 1),
content: line.trim_end_matches('\r').to_string(),
})
.collect();
let hunk = DiffHunk {
header: format!("@@ -0,0 +1,{} @@ new file", lines.len()),
lines,
};
DiffFile::new(path.to_string(), vec![hunk], DiffFileStatus::Untracked)
}
fn parse_diff_git_line(line: &str) -> Option<String> {
let rest = line.strip_prefix("diff --git ")?;
let b_part = find_b_part(rest)?;
extract_b_path(b_part)
}
fn find_b_part(rest: &str) -> Option<&str> {
let bytes = rest.as_bytes();
if bytes.first() == Some(&b'"') {
let mut i: usize = 1;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2;
} else if bytes[i] == b'"' {
i += 1; break;
} else {
i += 1;
}
}
while i < bytes.len() && bytes[i] == b' ' {
i += 1;
}
if i >= bytes.len() {
return None;
}
Some(&rest[i..])
} else {
if let Some(idx) = rest.find(" \"b/") {
Some(&rest[idx + 1..])
} else {
rest.find(" b/").map(|idx| &rest[idx + 1..])
}
}
}
fn extract_b_path(b_part: &str) -> Option<String> {
let path = unquote_c_style(b_part)?;
Some(path.strip_prefix("b/")?.to_string())
}
fn parse_range_token(part: &str, prefix: char) -> Option<usize> {
let remainder = part.strip_prefix(prefix)?;
remainder.split(',').next()?.parse::<usize>().ok()
}
fn parse_hunk_header(header: &str) -> (usize, usize) {
let after_open = header.trim_start_matches('@');
let mut old_start = 1;
let mut new_start = 1;
for part in after_open.split_whitespace() {
if part.starts_with('@') {
break;
}
if part.starts_with('-')
&& let Some(n) = parse_range_token(part, '-')
{
old_start = n;
} else if part.starts_with('+')
&& let Some(n) = parse_range_token(part, '+')
{
new_start = n;
}
}
(old_start, new_start)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_diff() {
let diff = r#"diff --git a/src/main.rs b/src/main.rs
index abc123..def456 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,4 @@
fn main() {
- println!("hello");
+ println!("hello world");
println!("goodbye");
}
"#;
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].path, "src/main.rs");
assert_eq!(output[0].hunks.len(), 1);
assert!(output[0].hunks[0].lines.len() >= 3);
assert_eq!(output[0].hunks[0].lines[0].kind, DiffLineKind::Context);
assert_eq!(output[0].hunks[0].lines[1].kind, DiffLineKind::Removed);
assert_eq!(output[0].hunks[0].lines[2].kind, DiffLineKind::Added);
}
#[test]
fn test_new_file() {
let diff = r#"diff --git a/new.rs b/new.rs
new file mode 100644
index 0000000..abc123
--- /dev/null
+++ b/new.rs
@@ -0,0 +1,2 @@
+fn hello() {
+ println!("new");
+}
"#;
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].status, DiffFileStatus::Added);
assert!(!output[0].hunks[0].lines.is_empty());
assert_eq!(output[0].hunks[0].lines[0].new_line_number, Some(1));
}
#[test]
fn test_deleted_file() {
let diff = r#"diff --git a/old.rs b/old.rs
deleted file mode 100644
index abc123..0000000
--- a/old.rs
+++ /dev/null
@@ -1,2 +0,0 @@
-fn hello() {
- println!("bye");
-}
"#;
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].status, DiffFileStatus::Deleted);
assert!(!output[0].hunks[0].lines.is_empty());
assert_eq!(output[0].hunks[0].lines[0].kind, DiffLineKind::Removed);
}
#[test]
fn test_binary() {
let diff = r"diff --git a/image.png b/image.png
index abc..def 100644
Binary files a/image.png and b/image.png differ
";
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].content, DiffContent::Binary);
}
#[test]
fn test_empty_diff() {
let output = parse_git_diff("");
assert!(output.is_empty());
}
#[test]
fn test_parse_diff_git_line() {
let cases: &[(&str, Option<&str>)] = &[
(
"diff --git a/src/main.rs b/src/main.rs",
Some("src/main.rs"),
),
(
r#"diff --git "a/file\"name.rs" "b/file\"name.rs""#,
Some("file\"name.rs"),
),
(
r#"diff --git "a/path\\with\\backslash.rs" "b/path\\with\\backslash.rs""#,
Some("path\\with\\backslash.rs"),
),
(
r#"diff --git "a/file\tname.rs" "b/file\tname.rs""#,
Some("file\tname.rs"),
),
(
r#"diff --git "a/file\t\"quote\"\\n.rs" "b/file\t\"quote\"\\n.rs""#,
Some("file\t\"quote\"\\n.rs"),
),
(
r#"diff --git "a/file\"x.rs" b/normal.rs"#,
Some("normal.rs"),
),
(
r#"diff --git a/normal.rs "b/file\"x.rs""#,
Some("file\"x.rs"),
),
(
r#"diff --git "a/file\377name.rs" "b/file\377name.rs""#,
Some("file\u{FFFD}name.rs"), ),
];
for (i, (input, expected)) in cases.iter().enumerate() {
let result = parse_diff_git_line(input);
assert_eq!(
result.as_deref(),
*expected,
"case {i}: parse_diff_git_line({input:?})"
);
}
}
#[test]
fn test_rename_from_cases() {
let cases: &[(&str, &str, Option<&str>, &str)] = &[
(
"unquoted",
r"diff --git a/old.rs b/new.rs
similarity index 100%
rename from old.rs
rename to new.rs
",
Some("old.rs"),
"new.rs",
),
(
"quoted_with_escapes",
r#"diff --git "a/old\"name.rs" "b/new\"name.rs"
similarity index 100%
rename from "old\"name.rs"
rename to "new\"name.rs"
"#,
Some("old\"name.rs"),
"new\"name.rs",
),
(
"quoted_tab_in_name",
r#"diff --git "a/old\tname.rs" "b/new\tname.rs"
similarity index 100%
rename from "old\tname.rs"
rename to "new\tname.rs"
"#,
Some("old\tname.rs"),
"new\tname.rs",
),
(
"no_trigger_chars",
r"diff --git a/old name.rs b/new name.rs
similarity index 100%
rename from old name.rs
rename to new name.rs
",
Some("old name.rs"),
"new name.rs",
),
];
for (i, (name, diff, expected_old_path, expected_path)) in cases.iter().enumerate() {
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1, "case {i} ({name})");
assert_eq!(
output[0].status,
DiffFileStatus::Renamed,
"case {i} ({name})"
);
assert_eq!(
output[0].old_path.as_deref(),
*expected_old_path,
"case {i} ({name}): old_path mismatch"
);
assert_eq!(
output[0].path, *expected_path,
"case {i} ({name}): path mismatch"
);
}
}
#[test]
fn test_rename_from_malformed_c_style_escape() {
let diff = r#"diff --git "a/old.rs" "b/new.rs"
similarity index 100%
rename from "old\xname.rs"
rename to "new.rs"
"#;
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].status, DiffFileStatus::Modified);
assert_eq!(output[0].old_path, None);
assert_eq!(output[0].path, "new.rs");
}
#[test]
fn test_untracked_file() {
let file = make_untracked_diff_file("src/untracked.rs", "fn main() {}");
assert_eq!(file.status, DiffFileStatus::Untracked);
assert_eq!(file.hunks[0].lines.len(), 1);
assert_eq!(file.hunks[0].lines[0].kind, DiffLineKind::Added);
}
#[test]
fn test_multi_hunk_diff() {
let diff = r"diff --git a/src/lib.rs b/src/lib.rs
index abc123..def456 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,3 @@
fn top() {
- old_top();
+ new_top();
}
@@ -10,3 +10,3 @@
fn bottom() {
- old_bottom();
+ new_bottom();
}
@@ -100,6 +100,6 @@
fn middle() {
- old_line1();
- old_line2();
+ new_line1();
+ new_line2();
context_line();
}
";
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].path, "src/lib.rs");
assert_eq!(output[0].hunks.len(), 3);
assert_eq!(output[0].hunks[0].header, "@@ -1,3 +1,3 @@");
assert_eq!(output[0].hunks[0].lines[1].kind, DiffLineKind::Removed);
assert_eq!(output[0].hunks[0].lines[2].kind, DiffLineKind::Added);
assert_eq!(output[0].hunks[1].header, "@@ -10,3 +10,3 @@");
assert_eq!(output[0].hunks[1].lines[1].kind, DiffLineKind::Removed);
assert_eq!(output[0].hunks[1].lines[2].kind, DiffLineKind::Added);
assert_eq!(output[0].hunks[2].header, "@@ -100,6 +100,6 @@");
assert_eq!(output[0].hunks[2].lines.len(), 7);
assert_eq!(output[0].hunks[2].lines[0].kind, DiffLineKind::Context);
assert_eq!(output[0].hunks[2].lines[1].kind, DiffLineKind::Removed);
assert_eq!(output[0].hunks[2].lines[2].kind, DiffLineKind::Removed);
assert_eq!(output[0].hunks[2].lines[3].kind, DiffLineKind::Added);
assert_eq!(output[0].hunks[2].lines[4].kind, DiffLineKind::Added);
assert_eq!(output[0].hunks[2].lines[5].kind, DiffLineKind::Context);
assert_eq!(output[0].hunks[2].lines[6].kind, DiffLineKind::Context);
}
#[test]
fn test_no_newline_annotation_skipped_and_counters_correct() {
let diff = r"diff --git a/foo.rs b/foo.rs
index abc123..def456 100644
--- a/foo.rs
+++ b/foo.rs
@@ -1,3 +1,3 @@
line1
line2
line3
\ No newline at end of file
@@ -10,2 +10,2 @@
other1
other2
";
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].hunks.len(), 2);
assert_eq!(output[0].hunks[0].lines.len(), 3);
assert_eq!(output[0].hunks[0].lines[0].old_line_number, Some(1));
assert_eq!(output[0].hunks[0].lines[2].old_line_number, Some(3));
assert_eq!(output[0].hunks[1].lines.len(), 2);
assert_eq!(output[0].hunks[1].lines[0].old_line_number, Some(10));
assert_eq!(output[0].hunks[1].lines[1].old_line_number, Some(11));
}
#[test]
fn test_parse_hunk_header_cases() {
let cases: &[(&str, &str, usize, usize)] = &[
(
"hunk_context_with_arrow",
"@@ -10,7 +10,9 @@ fn process() -> Result<()>",
10,
10,
),
(
"hunk_context_with_plus",
"@@ -5,3 +5,4 @@ fn add(a: i32, b: i32) -> i32 { let x = a + b; }",
5,
5,
),
("hunk_at_at_in_context", "@@ -1,3 +1,3 @@ @@ -this", 1, 1),
(
"hunk_plain_context",
"@@ -100,6 +200,7 @@ fn main() {",
100,
200,
),
("hunk_no_context", "@@ -0,0 +1,5 @@", 0, 1),
("hunk_no_count_first", "@@ -1 +1 @@ fn single_line()", 1, 1),
("hunk_no_count_second", "@@ -5 +3 @@ fn another()", 5, 3),
(
"hunk_negative_number_in_context",
"@@ -3,2 +3,2 @@ fn check() { if x < -1 { } }",
3,
3,
),
];
for (i, (name, input, expected_old, expected_new)) in cases.iter().enumerate() {
let (old, new) = parse_hunk_header(input);
assert_eq!(
old, *expected_old,
"case {i} ({name}): old_start mismatch. Input: {input:?}"
);
assert_eq!(
new, *expected_new,
"case {i} ({name}): new_start mismatch. Input: {input:?}"
);
}
}
}