use std::collections::HashSet;
use std::path::Path;
use tracing::warn;
#[derive(Debug, Clone)]
pub struct CommitInfo {
pub hash: String,
pub lines_added: i64,
pub lines_removed: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffFileStatus {
Modified,
Added,
Deleted,
Renamed,
Untracked,
}
#[derive(Debug, Clone)]
pub struct DiffFile {
pub path: String,
pub old_path: Option<String>,
pub hunks: Vec<DiffHunk>,
pub status: DiffFileStatus,
pub is_binary: bool,
pub too_large_size: Option<u64>,
}
impl DiffFile {
#[must_use]
pub const fn placeholder(path: String, is_binary: bool, too_large_size: Option<u64>) -> Self {
Self {
path,
old_path: None,
hunks: Vec::new(),
status: DiffFileStatus::Untracked,
is_binary,
too_large_size,
}
}
}
#[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,
pub prefix: char,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineKind {
Added,
Removed,
Context,
}
#[allow(clippy::too_many_lines)]
#[must_use]
pub fn parse_git_diff(diff_output: &str) -> Vec<DiffFile> {
fn flush_file(
current_file: &mut Option<DiffFile>,
current_hunk: &mut Option<DiffHunk>,
files: &mut Vec<DiffFile>,
) {
if let Some(mut file) = current_file.take() {
if let Some(hunk) = current_hunk.take() {
file.hunks.push(hunk);
}
files.push(file);
}
}
let mut files: Vec<DiffFile> = Vec::new();
let mut current_file: Option<DiffFile> = None;
let mut current_hunk: Option<DiffHunk> = None;
let mut old_counter: usize = 0;
let mut new_counter: usize = 0;
for line in diff_output.lines() {
if line.starts_with("diff --git ") {
flush_file(&mut current_file, &mut current_hunk, &mut files);
current_hunk = None;
old_counter = 0;
new_counter = 0;
if let Some(path) = parse_diff_git_line(line) {
current_file = Some(DiffFile {
path,
old_path: None,
hunks: Vec::new(),
status: DiffFileStatus::Modified,
is_binary: false,
too_large_size: None,
});
}
} 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 ")
{
} else if line.starts_with("--- ") || line.starts_with("+++ ") {
if let Some(ref mut f) = current_file {
if line.starts_with("--- /dev/null") && f.status != DiffFileStatus::Renamed {
f.status = DiffFileStatus::Added;
} else if line.starts_with("+++ /dev/null") && f.status != DiffFileStatus::Renamed {
f.status = DiffFileStatus::Deleted;
}
}
} else if line.starts_with("rename from ") {
if let Some(ref mut f) = current_file {
f.status = DiffFileStatus::Renamed;
let raw = line.strip_prefix("rename from ").unwrap_or("");
let Some(old_path) = unquote_c_style(raw) else {
warn!(
line = %line,
"rename from: malformed C-style escape, dropping rename info"
);
f.status = DiffFileStatus::Modified;
continue;
};
f.old_path = Some(old_path);
}
} else if line.starts_with("rename to ") {
} else if line.starts_with("Binary files ") {
if let Some(ref mut f) = current_file {
f.is_binary = true;
}
} else if line.starts_with("@@") {
if let Some(hunk) = current_hunk.take()
&& let Some(ref mut f) = current_file
{
f.hunks.push(hunk);
}
let (old_start, new_start) = parse_hunk_header(line);
old_counter = old_start;
new_counter = new_start;
current_hunk = Some(DiffHunk {
header: line.to_string(),
lines: Vec::new(),
});
} else if let Some(hunk) = &mut current_hunk {
let (line_kind, prefix) = if line.starts_with('+') {
(DiffLineKind::Added, '+')
} else if line.starts_with('-') {
(DiffLineKind::Removed, '-')
} else if line.starts_with(' ') {
(DiffLineKind::Context, ' ')
} else if line == r"\ No newline at end of file" {
continue;
} else {
continue;
};
let content = line[1..].trim_end_matches('\r');
let (old_num, new_num) = match line_kind {
DiffLineKind::Added => {
let n = Some(new_counter);
new_counter += 1;
(None, n)
}
DiffLineKind::Removed => {
let n = Some(old_counter);
old_counter += 1;
(n, None)
}
DiffLineKind::Context => {
let o = Some(old_counter);
let n = Some(new_counter);
old_counter += 1;
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(),
prefix,
});
}
}
flush_file(&mut current_file, &mut current_hunk, &mut files);
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(),
prefix: '+',
})
.collect();
let hunk = DiffHunk {
header: format!("@@ -0,0 +1,{} @@ new file", lines.len()),
lines,
};
DiffFile {
path: path.to_string(),
old_path: None,
hunks: vec![hunk],
status: DiffFileStatus::Untracked,
is_binary: false,
too_large_size: None,
}
}
#[must_use]
pub fn unquote_c_style(raw: &str) -> Option<String> {
if let Some(inner) = raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
unescape_c_style(inner)
} else {
Some(raw.to_string())
}
}
pub fn unescape_c_style(input: &str) -> Option<String> {
let mut result = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i: usize = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 1; if i >= bytes.len() {
warn!(
input = %input,
"unescape_c_style: dangling backslash at end of string"
);
return None;
}
match bytes[i] {
b'"' => result.push('"'),
b'\\' => result.push('\\'),
b't' => result.push('\t'),
b'n' => result.push('\n'),
b'a' => result.push('\x07'),
b'b' => result.push('\x08'),
b'f' => result.push('\x0c'),
b'r' => result.push('\r'),
b'v' => result.push('\x0b'),
b'0'..=b'3' => {
let digits_start = i;
i += 1;
let mut digit_count = 1;
while digit_count < 3 && i < bytes.len() && bytes[i].is_ascii_digit() {
if !(b'0'..=b'7').contains(&bytes[i]) {
break;
}
i += 1;
digit_count += 1;
}
let octal_str = std::str::from_utf8(&bytes[digits_start..i]).ok()?;
let Ok(byte_val) = u8::from_str_radix(octal_str, 8) else {
warn!(
input = %input, octal = %octal_str,
"unescape_c_style: invalid octal escape"
);
return None;
};
result.push_str(&String::from_utf8_lossy(&[byte_val]));
continue; }
b'4'..=b'7' => {
if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
warn!(
input = %input,
ch = %(bytes[i] as char),
"unescape_c_style: invalid octal prefix \\4–\\7 followed by digit"
);
return None;
}
result.push(bytes[i] as char);
}
_ => {
warn!(
input = %input,
ch = %(bytes[i] as char),
"unescape_c_style: unrecognized escape sequence"
);
return None;
}
}
i += 1;
} else {
result.push(bytes[i] as char);
i += 1;
}
}
Some(result)
}
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_hunk_header(header: &str) -> (usize, usize) {
let after_open = header.trim_start_matches('@');
let parts: Vec<&str> = after_open.split_whitespace().collect();
let mut old_start = 1;
let mut new_start = 1;
for part in &parts {
if part.starts_with('@') {
break;
}
if part.starts_with('-') {
let remainder = part.strip_prefix('-').unwrap_or("");
if remainder.is_empty() || !remainder.chars().all(|c| c.is_ascii_digit() || c == ',') {
warn!(
"Skipping non-numeric '-' token {:?} in hunk header {:?}",
part, header
);
continue;
}
match remainder
.split(',')
.next()
.and_then(|s| s.parse::<usize>().ok())
{
Some(n) => old_start = n,
None => {
warn!(
"Failed to parse old line number from {:?} in hunk header {:?}",
part, header
);
}
}
} else if part.starts_with('+') {
let remainder = part.strip_prefix('+').unwrap_or("");
if remainder.is_empty() || !remainder.chars().all(|c| c.is_ascii_digit() || c == ',') {
warn!(
"Skipping non-numeric '+' token {:?} in hunk header {:?}",
part, header
);
continue;
}
match remainder
.split(',')
.next()
.and_then(|s| s.parse::<usize>().ok())
{
Some(n) => new_start = n,
None => {
warn!(
"Failed to parse new line number from {:?} in hunk header {:?}",
part, header
);
}
}
}
}
(old_start, new_start)
}
#[must_use]
pub async fn is_git_repo(path: &Path) -> bool {
path.join(".git").exists()
}
pub async fn run_git_diff(repo_path: &Path, commit_ref: Option<&str>) -> Result<String, String> {
if let Some(hash) = commit_ref {
run_git_command(
repo_path,
&[
"show",
"-m",
hash,
"--no-color",
"--find-renames",
"--format=",
],
)
.await
} else {
run_git_command(repo_path, &["diff", "HEAD", "--no-color", "--find-renames"]).await
}
}
pub async fn run_git_status(repo_path: &Path) -> Result<String, String> {
run_git_command(repo_path, &["status", "--porcelain"]).await
}
pub async fn run_git_show(
repo_path: &Path,
file_path: &str,
commit_ref: Option<&str>,
) -> Result<Option<String>, String> {
let show_arg = if let Some(hash) = commit_ref {
format!("{hash}:{file_path}")
} else {
format!("HEAD:{file_path}")
};
match run_git_command(repo_path, &["show", &show_arg]).await {
Ok(output) => Ok(Some(output)),
Err(_) => Ok(None),
}
}
pub async fn run_git_command(repo_path: &Path, args: &[&str]) -> Result<String, String> {
let output = tokio::process::Command::new("git")
.args(args)
.current_dir(repo_path)
.env("LC_ALL", "C")
.output()
.await
.map_err(|e| format!("Failed to run git: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(format!("Git command failed: {stderr}"));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub async fn run_git_check_ignore(
repo_path: &Path,
paths: &[String],
) -> Result<HashSet<String>, String> {
use std::process::Stdio;
let mut child = tokio::process::Command::new("git")
.args(["check-ignore", "--stdin"])
.current_dir(repo_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn git check-ignore: {e}"))?;
let mut stdin = child.stdin.take().expect("stdin not captured");
for path in paths {
use tokio::io::AsyncWriteExt;
stdin
.write_all(path.as_bytes())
.await
.map_err(|e| format!("Failed to write to git stdin: {e}"))?;
stdin
.write_all(b"\n")
.await
.map_err(|e| format!("Failed to write newline to git stdin: {e}"))?;
}
drop(stdin);
let output = child
.wait_with_output()
.await
.map_err(|e| format!("Failed to wait for git check-ignore: {e}"))?;
if output.status.code() == Some(1) {
return Ok(HashSet::new());
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(format!("Git check-ignore failed: {stderr}"));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let ignored: HashSet<String> = stdout.lines().map(ToString::to_string).collect();
Ok(ignored)
}
pub async fn run_git_commit(repo_path: &Path, message: &str) -> Result<CommitInfo, String> {
run_git_command(repo_path, &["add", "-A"]).await?;
run_git_command(repo_path, &["commit", "-m", message])
.await
.map_err(|e| format!("Commit failed: {}", e.trim()))?;
let hash = match run_git_command(repo_path, &["rev-parse", "HEAD"]).await {
Ok(out) => out.trim().to_string(),
Err(e) => {
warn!(
error = %e,
"git rev-parse HEAD failed after successful commit — commit exists, returning unknown hash"
);
return Ok(CommitInfo {
hash: "unknown".into(),
lines_added: 0,
lines_removed: 0,
});
}
};
if let Some((lines_added, lines_removed)) =
parse_numstat(repo_path, &["diff", "--numstat", "HEAD~1..HEAD"]).await
{
Ok(CommitInfo {
hash,
lines_added,
lines_removed,
})
} else {
let (lines_added, lines_removed) = parse_numstat(
repo_path,
&[
"diff",
"--numstat",
"4b825dc642cb6eb9a060e54bf899dcee6a7b9e2a",
"HEAD",
],
)
.await
.unwrap_or((0, 0));
Ok(CommitInfo {
hash,
lines_added,
lines_removed,
})
}
}
async fn parse_numstat(repo_path: &Path, args: &[&str]) -> Option<(i64, i64)> {
let stdout = match run_git_command(repo_path, args).await {
Ok(out) => out,
Err(e) => {
warn!(args = ?args, error = %e, "git diff --numstat failed");
return None;
}
};
let mut lines_added: i64 = 0;
let mut lines_removed: i64 = 0;
for line in stdout.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 {
lines_added += parts[0].parse::<i64>().unwrap_or(0);
lines_removed += parts[1].parse::<i64>().unwrap_or(0);
}
}
Some((lines_added, lines_removed))
}
pub async fn git_is_installed() -> bool {
tokio::process::Command::new("git")
.arg("--version")
.output()
.await
.is_ok_and(|o| o.status.success())
}
pub async fn git_has_commits(repo_path: &Path) -> Result<bool, String> {
let output = tokio::process::Command::new("git")
.args(["rev-list", "-n", "1", "HEAD"])
.current_dir(repo_path)
.output()
.await
.map_err(|e| format!("Failed to run git: {e}"))?;
if !output.status.success() {
return Ok(false);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(!stdout.trim().is_empty())
}
#[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_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!(output[0].is_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"),
),
];
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_unescape_c_style() {
let cases: &[(&str, Option<&str>)] = &[
(
r#"hello\"world\\test\nline\there"#,
Some("hello\"world\\test\nline\there"),
),
(r"\a\b\f\r\v", Some("\x07\x08\x0c\r\x0b")),
(r"\0\1", Some("\0\x01")),
(r"\12\37", Some("\n\x1f")),
(r"\101\377", Some("A\u{FFFD}")),
(r"\12x", Some("\nx")),
(r"\18", Some("\x018")),
("plain/path.rs", Some("plain/path.rs")),
("", Some("")),
(r"path\", None),
(r"\x", None),
(r"\q", None),
(r"\40", None),
(r"\77", None),
(r"\70", None),
(r"\4", Some("4")),
(r"\7x", Some("7x")),
];
for (i, (input, expected)) in cases.iter().enumerate() {
let result = unescape_c_style(input);
assert_eq!(
result.as_deref(),
*expected,
"case {i}: unescape_c_style({input:?})"
);
}
}
#[test]
fn test_rename_from_unquoted() {
let diff = r"diff --git a/old.rs b/new.rs
similarity index 100%
rename from old.rs
rename to new.rs
";
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].status, DiffFileStatus::Renamed);
assert_eq!(output[0].old_path, Some("old.rs".to_string()));
assert_eq!(output[0].path, "new.rs");
}
#[test]
fn test_rename_from_quoted_with_escapes() {
let diff = r#"diff --git "a/old\"name.rs" "b/new\"name.rs"
similarity index 100%
rename from "old\"name.rs"
rename to "new\"name.rs"
"#;
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].status, DiffFileStatus::Renamed);
assert_eq!(output[0].old_path, Some("old\"name.rs".to_string()));
assert_eq!(output[0].path, "new\"name.rs");
}
#[test]
fn test_rename_from_quoted_tab_in_name() {
let diff = "diff --git \"a/old\\tname.rs\" \"b/new\\tname.rs\"\n\
similarity index 100%\n\
rename from \"old\\tname.rs\"\n\
rename to \"new\\tname.rs\"\n";
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].status, DiffFileStatus::Renamed);
assert_eq!(output[0].old_path, Some("old\tname.rs".to_string()));
}
#[test]
fn test_rename_from_no_trigger_chars() {
let diff = r"diff --git a/old name.rs b/new name.rs
similarity index 100%
rename from old name.rs
rename to new name.rs
";
let output = parse_git_diff(diff);
assert_eq!(output.len(), 1);
assert_eq!(output[0].status, DiffFileStatus::Renamed);
assert_eq!(output[0].old_path, Some("old name.rs".to_string()));
assert_eq!(output[0].path, "new name.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_combined_diff",
"@@@ -10,7 -10,12 +10,9 @@@ fn main()",
10,
10,
),
(
"hunk_combined_diff_no_context",
"@@@ -1,5 -1,7 +1,8 @@@",
1,
1,
),
(
"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:?}"
);
}
}
}