use std::path::Path;
use crate::error::{AppError, Result};
use super::{run_git_pathspec, DiffLine, DiffLineKind};
#[must_use = "the loaded diff or error must be handled"]
pub(crate) fn load_diff(
repo_root: &Path,
commit_hash: &str,
repo_path: &Path,
) -> Result<Vec<DiffLine>> {
validate_hex_hash(commit_hash)?;
let output = run_git_pathspec(
repo_root,
"git show",
&[
"show",
"--format=",
"--color=never",
"--no-ext-diff",
commit_hash,
],
repo_path,
)?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_diff(&stdout))
}
fn parse_diff(output: &str) -> Vec<DiffLine> {
let mut lines: Vec<DiffLine> = output
.lines()
.map(|line| DiffLine {
text: line.to_string(),
kind: classify_diff_line(line),
})
.collect();
if lines.is_empty() {
lines.push(DiffLine {
text: "(empty diff)".to_string(),
kind: DiffLineKind::Context,
});
}
lines
}
fn classify_diff_line(line: &str) -> DiffLineKind {
if line.starts_with("@@") {
DiffLineKind::Hunk
} else if is_diff_metadata(line) {
DiffLineKind::Metadata
} else if line.starts_with('+') {
DiffLineKind::Add
} else if line.starts_with('-') {
DiffLineKind::Remove
} else {
DiffLineKind::Context
}
}
fn is_diff_metadata(line: &str) -> bool {
line.starts_with("diff --git")
|| line.starts_with("index ")
|| line.starts_with("--- a/")
|| line.starts_with("--- \"a/")
|| line == "--- /dev/null"
|| line.starts_with("+++ b/")
|| line.starts_with("+++ \"b/")
|| line == "+++ /dev/null"
|| 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("similarity index ")
|| line.starts_with("dissimilarity index ")
|| line.starts_with("rename from ")
|| line.starts_with("rename to ")
|| line.starts_with("copy from ")
|| line.starts_with("copy to ")
|| line.starts_with("Binary files ")
}
fn validate_hex_hash(commit_hash: &str) -> Result<()> {
let is_supported_len = matches!(commit_hash.len(), 40 | 64);
if is_supported_len && commit_hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
Ok(())
} else {
Err(AppError::message(format!(
"invalid commit hash: {commit_hash}"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_diff_lines() {
assert_eq!(classify_diff_line("+added"), DiffLineKind::Add);
assert_eq!(classify_diff_line("-removed"), DiffLineKind::Remove);
assert_eq!(classify_diff_line("@@ -1 +1 @@"), DiffLineKind::Hunk);
assert_eq!(classify_diff_line("+++ b/file.rs"), DiffLineKind::Metadata);
assert_eq!(classify_diff_line("--- a/file.rs"), DiffLineKind::Metadata);
assert_eq!(
classify_diff_line("+++ \"b/file with spaces.rs\""),
DiffLineKind::Metadata
);
assert_eq!(
classify_diff_line("--- \"a/file with spaces.rs\""),
DiffLineKind::Metadata
);
assert_eq!(classify_diff_line("++++content"), DiffLineKind::Add);
assert_eq!(classify_diff_line("----content"), DiffLineKind::Remove);
assert_eq!(
classify_diff_line("index abc..def 100644"),
DiffLineKind::Metadata
);
assert_eq!(classify_diff_line(" context"), DiffLineKind::Context);
}
#[test]
fn parse_diff_adds_empty_placeholder() {
let lines = parse_diff("");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "(empty diff)");
}
#[test]
fn parse_diff_classifies_realistic_mixed_content() {
let lines = parse_diff(
"diff --git a/file.txt b/file.txt\n\
index abc..def 100644\n\
--- a/file.txt\n\
+++ b/file.txt\n\
@@ -1,2 +1,2 @@\n\
context\n\
-old\n\
+new\n\
----removed content starts with dashes\n\
+++added content starts with pluses\n",
);
assert_eq!(lines[0].kind, DiffLineKind::Metadata);
assert_eq!(lines[4].kind, DiffLineKind::Hunk);
assert_eq!(lines[5].kind, DiffLineKind::Context);
assert_eq!(lines[6].kind, DiffLineKind::Remove);
assert_eq!(lines[7].kind, DiffLineKind::Add);
assert_eq!(lines[8].kind, DiffLineKind::Remove);
assert_eq!(lines[9].kind, DiffLineKind::Add);
}
#[test]
fn validates_supported_commit_hash_formats() {
assert!(validate_hex_hash("0123456789abcdef0123456789abcdef01234567").is_ok());
assert!(validate_hex_hash(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
)
.is_ok());
assert!(validate_hex_hash("abc1234").is_err());
assert!(validate_hex_hash("abc123").is_err());
assert!(validate_hex_hash("0123456789abcdef0123456789abcdef012345678").is_err());
assert!(validate_hex_hash("--exec=rm").is_err());
assert!(validate_hex_hash("HEAD").is_err());
assert!(validate_hex_hash("").is_err());
}
}