use std::collections::HashMap;
use std::process::Command;
use crate::error::ReviewError;
#[derive(Debug)]
pub struct GitDiff {
pub files: HashMap<String, String>,
}
impl GitDiff {
pub fn extract(
base_ref: &str,
target_ref: &str,
extensions: &[String],
) -> Result<Self, ReviewError> {
validate_ref("base_ref", base_ref)?;
validate_ref("target_ref", target_ref)?;
let range = format!("{}...{}", base_ref, target_ref);
let output = Command::new("git")
.args(["diff", "--name-only", &range])
.output()
.map_err(|e| ReviewError::GitDiff(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ReviewError::GitDiff(format!(
"git diff --name-only failed: {}",
stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let all_files: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
let filtered = filter_by_extensions(&all_files, extensions);
let mut files = HashMap::new();
for file in &filtered {
let diff = get_file_diff(base_ref, target_ref, file)?;
if !diff.is_empty() {
files.insert(file.clone(), diff);
}
}
Ok(Self { files })
}
}
pub fn filter_by_extensions(files: &[&str], extensions: &[String]) -> Vec<String> {
if extensions.is_empty() || extensions.iter().any(|e| e == "*") {
return files.iter().map(|f| f.to_string()).collect();
}
files
.iter()
.filter(|f| {
extensions.iter().any(|ext| {
let suffix = ext.trim_start_matches('*');
f.ends_with(suffix)
})
})
.map(|f| f.to_string())
.collect()
}
fn validate_ref(label: &str, git_ref: &str) -> Result<(), ReviewError> {
if git_ref.is_empty() {
return Err(ReviewError::Config(format!("{} must not be empty", label)));
}
if git_ref.starts_with('-') {
return Err(ReviewError::Config(format!(
"{} '{}' must not start with '-'",
label, git_ref
)));
}
if git_ref.contains([';', '|', '&', '$', '`', '\n', '\r']) {
return Err(ReviewError::Config(format!(
"{} '{}' contains invalid characters",
label, git_ref
)));
}
Ok(())
}
fn get_file_diff(base_ref: &str, target_ref: &str, file: &str) -> Result<String, ReviewError> {
let range = format!("{}...{}", base_ref, target_ref);
let output = Command::new("git")
.args(["diff", &range, "--", file])
.output()
.map_err(|e| ReviewError::GitDiff(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ReviewError::GitDiff(format!(
"git diff failed for {}: {}",
file, stderr
)));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_empty_extensions_includes_all() {
let files = vec!["main.c", "utils.h", "README.md", "Makefile"];
let result = filter_by_extensions(&files, &[]);
assert_eq!(result.len(), 4, "Empty extensions should include all files");
}
#[test]
fn filter_wildcard_includes_all() {
let files = vec!["main.c", "utils.h", "README.md"];
let extensions = vec!["*".to_string()];
let result = filter_by_extensions(&files, &extensions);
assert_eq!(result.len(), 3, "Wildcard '*' should include all files");
}
#[test]
fn filter_single_extension() {
let files = vec!["main.c", "utils.h", "test.c", "README.md"];
let extensions = vec!["*.c".to_string()];
let result = filter_by_extensions(&files, &extensions);
assert_eq!(result.len(), 2, "*.c should match 2 files");
assert!(result.contains(&"main.c".to_string()));
assert!(result.contains(&"test.c".to_string()));
}
#[test]
fn filter_multiple_extensions() {
let files = vec!["main.c", "utils.h", "test.py", "README.md"];
let extensions = vec!["*.c".to_string(), "*.h".to_string()];
let result = filter_by_extensions(&files, &extensions);
assert_eq!(result.len(), 2, "*.c and *.h should match 2 files");
assert!(result.contains(&"main.c".to_string()));
assert!(result.contains(&"utils.h".to_string()));
}
#[test]
fn filter_no_matches_returns_empty() {
let files = vec!["main.c", "utils.h"];
let extensions = vec!["*.rs".to_string()];
let result = filter_by_extensions(&files, &extensions);
assert!(result.is_empty(), "No matches should return empty");
}
#[test]
fn filter_empty_file_list_returns_empty() {
let files: Vec<&str> = vec![];
let extensions = vec!["*.c".to_string()];
let result = filter_by_extensions(&files, &extensions);
assert!(result.is_empty(), "Empty file list should return empty");
}
#[test]
fn filter_nested_paths_match_extension() {
let files = vec![
"src/main.c",
"src/lib/utils.h",
"docs/README.md",
"tests/test_main.c",
];
let extensions = vec!["*.c".to_string()];
let result = filter_by_extensions(&files, &extensions);
assert_eq!(result.len(), 2, "*.c should match files in nested paths");
assert!(result.contains(&"src/main.c".to_string()));
assert!(result.contains(&"tests/test_main.c".to_string()));
}
#[test]
fn filter_case_sensitive() {
let files = vec!["main.C", "main.c", "main.CPP"];
let extensions = vec!["*.c".to_string()];
let result = filter_by_extensions(&files, &extensions);
assert_eq!(
result.len(),
1,
"Extension matching should be case-sensitive"
);
assert!(result.contains(&"main.c".to_string()));
}
#[test]
fn validate_ref_accepts_normal_ref() {
assert!(validate_ref("base_ref", "origin/main").is_ok());
}
#[test]
fn validate_ref_accepts_commit_hash() {
assert!(validate_ref("base_ref", "abc123def").is_ok());
}
#[test]
fn validate_ref_rejects_empty() {
assert!(validate_ref("base_ref", "").is_err());
}
#[test]
fn validate_ref_rejects_leading_dash() {
let result = validate_ref("base_ref", "--help");
assert!(result.is_err());
assert!(format!("{}", result.unwrap_err()).contains("must not start with '-'"));
}
#[test]
fn validate_ref_rejects_shell_metacharacters() {
assert!(validate_ref("base_ref", "origin/main; rm -rf /").is_err());
assert!(validate_ref("base_ref", "origin/main|cat").is_err());
assert!(validate_ref("base_ref", "ref&background").is_err());
assert!(validate_ref("base_ref", "$VAR").is_err());
assert!(validate_ref("base_ref", "`cmd`").is_err());
}
#[test]
fn validate_ref_label_in_empty_error() {
let result = validate_ref("target_ref", "");
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("target_ref"),
"Error should include the label 'target_ref': {}",
msg
);
}
#[test]
fn validate_ref_label_in_dash_error() {
let result = validate_ref("target_ref", "--exec");
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("target_ref"),
"Error should include the label 'target_ref': {}",
msg
);
}
#[test]
fn validate_ref_label_in_metachar_error() {
let result = validate_ref("target_ref", "ref;evil");
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("target_ref"),
"Error should include the label 'target_ref': {}",
msg
);
}
#[test]
fn git_diff_files_is_hashmap() {
let diff = GitDiff {
files: HashMap::new(),
};
assert!(
diff.files.is_empty(),
"New GitDiff should have empty files map"
);
}
}