use super::batched::is_bug_fix;
use super::blame_cache::FileBlameCache;
use crate::time_span;
use anyhow::{Context as _, Result};
use chrono::{DateTime, Utc};
use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Default)]
pub struct CommitInfo {
#[allow(dead_code)]
pub hash: String,
pub date: Option<DateTime<Utc>>,
pub message: String,
#[allow(dead_code)]
pub author: String,
}
#[derive(Debug, Clone, Default)]
pub struct FunctionHistory {
#[allow(dead_code)]
pub introduction_commit: Option<String>,
pub total_commits: usize,
pub bug_fix_count: usize,
pub authors: HashSet<String>,
#[allow(dead_code)]
pub last_modified: Option<DateTime<Utc>>,
pub introduced: Option<DateTime<Utc>>,
}
impl FunctionHistory {
pub fn bug_density(&self) -> f64 {
if self.total_commits == 0 {
return 0.0; }
self.bug_fix_count as f64 / self.total_commits as f64
}
pub fn change_frequency(&self, now: DateTime<Utc>) -> f64 {
let age_days = self.age_days(now);
if age_days == 0 || self.total_commits == 0 {
return 0.0;
}
(self.total_commits as f64 / age_days as f64) * 30.0
}
pub fn age_days(&self, now: DateTime<Utc>) -> u32 {
self.introduced
.map(|d| now.signed_duration_since(d).num_days().max(0) as u32)
.unwrap_or(0)
}
pub fn total_commits_including_introduction(&self) -> usize {
if self.introduction_commit.is_some() {
self.total_commits + 1
} else {
self.total_commits
}
}
}
pub fn parse_introduction_commit(git_output: &str) -> Option<String> {
git_output
.lines()
.next()
.map(|s| s.trim())
.filter(|line| !line.is_empty())
.map(|s| s.to_string())
}
pub fn parse_modification_commits(git_output: &str) -> Vec<CommitInfo> {
git_output
.lines()
.filter(|line| line.starts_with(":::"))
.filter_map(parse_commit_line)
.collect()
}
fn parse_commit_line(line: &str) -> Option<CommitInfo> {
let parts: Vec<&str> = line.split(":::").collect();
if parts.len() < 5 {
return None;
}
let date = DateTime::parse_from_rfc3339(parts[2])
.ok()
.map(|d| d.with_timezone(&Utc));
Some(CommitInfo {
hash: parts[1].to_string(),
date,
message: parts[3].to_string(),
author: parts[4].to_string(),
})
}
pub fn filter_bug_fix_commits(commits: &[CommitInfo]) -> Vec<&CommitInfo> {
commits.iter().filter(|c| is_bug_fix(&c.message)).collect()
}
pub fn get_function_history(
repo_root: &Path,
file_path: &Path,
function_name: &str,
line_range: (usize, usize),
blame_cache: &FileBlameCache,
now: DateTime<Utc>,
) -> Result<FunctionHistory> {
time_span!("git_function_history");
get_function_history_subprocess(
repo_root,
file_path,
function_name,
line_range,
blame_cache,
now,
)
}
fn get_function_history_subprocess(
repo_root: &Path,
file_path: &Path,
function_name: &str,
line_range: (usize, usize),
blame_cache: &FileBlameCache,
_now: DateTime<Utc>,
) -> Result<FunctionHistory> {
let intro_output = run_git_log_introduction(repo_root, file_path, function_name)?;
let intro_commit = parse_introduction_commit(&intro_output);
let Some(ref intro) = intro_commit else {
return Err(anyhow::anyhow!(
"Function '{}' not found in git history for {}",
function_name,
file_path.display()
));
};
let intro_date = get_commit_date(repo_root, intro)?;
let mods_output = run_git_log_modifications(repo_root, file_path, function_name, intro)?;
let modification_commits = parse_modification_commits(&mods_output);
let (start, end) = line_range;
let blame_authors = blame_cache.get_authors(file_path, start, end)?;
Ok(calculate_function_history_with_authors(
intro_commit,
intro_date,
&modification_commits,
blame_authors,
))
}
pub fn calculate_function_history_with_authors(
introduction_commit: Option<String>,
introduction_date: Option<DateTime<Utc>>,
modification_commits: &[CommitInfo],
authors: HashSet<String>,
) -> FunctionHistory {
let bug_fixes = filter_bug_fix_commits(modification_commits);
FunctionHistory {
introduction_commit,
total_commits: modification_commits.len(),
bug_fix_count: bug_fixes.len(),
authors,
last_modified: modification_commits.iter().filter_map(|c| c.date).max(),
introduced: introduction_date,
}
}
fn run_git_log_introduction(
repo_root: &Path,
file_path: &Path,
function_name: &str,
) -> Result<String> {
let search_pattern = format!("fn {function_name}");
let output = Command::new("git")
.args([
"log",
"-S",
&search_pattern,
"--format=%H",
"--reverse",
"--",
&file_path.to_string_lossy(),
])
.current_dir(repo_root)
.output()
.context("Failed to run git log -S for function introduction")?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn run_git_log_modifications(
repo_root: &Path,
file_path: &Path,
function_name: &str,
intro_commit: &str,
) -> Result<String> {
let range = format!("{intro_commit}..HEAD");
let output = Command::new("git")
.args([
"log",
&range,
"-G",
function_name,
"--format=:::%H:::%cI:::%s:::%ae",
"--",
&file_path.to_string_lossy(),
])
.current_dir(repo_root)
.output()
.context("Failed to run git log range for function modifications")?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn get_commit_date(repo_root: &Path, commit_hash: &str) -> Result<Option<DateTime<Utc>>> {
let output = Command::new("git")
.args(["log", "-1", "--format=%cI", commit_hash])
.current_dir(repo_root)
.output()
.context("Failed to get commit date")?;
if output.status.success() {
let date_str = String::from_utf8_lossy(&output.stdout);
let date_str = date_str.trim();
if !date_str.is_empty() {
return Ok(DateTime::parse_from_rfc3339(date_str)
.ok()
.map(|d| d.with_timezone(&Utc)));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_introduction_commit_found() {
let output = "abc123def456\n";
let result = parse_introduction_commit(output);
assert_eq!(result, Some("abc123def456".to_string()));
}
#[test]
fn test_parse_introduction_commit_multiple_lines() {
let output = "abc123def456\nxyz789xyz789\n";
let result = parse_introduction_commit(output);
assert_eq!(result, Some("abc123def456".to_string()));
}
#[test]
fn test_parse_introduction_commit_empty() {
let output = "";
let result = parse_introduction_commit(output);
assert_eq!(result, None);
}
#[test]
fn test_parse_introduction_commit_whitespace_only() {
let output = " \n\n";
let result = parse_introduction_commit(output);
assert_eq!(result, None);
}
#[test]
fn test_parse_modification_commits_multiple() {
let output = r#":::abc123:::2025-01-01T10:00:00Z:::fix: bug:::author1@example.com
:::def456:::2025-01-02T10:00:00Z:::feat: feature:::author2@example.com"#;
let commits = parse_modification_commits(output);
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].hash, "abc123");
assert_eq!(commits[0].message, "fix: bug");
assert_eq!(commits[0].author, "author1@example.com");
assert_eq!(commits[1].hash, "def456");
assert_eq!(commits[1].message, "feat: feature");
assert_eq!(commits[1].author, "author2@example.com");
}
#[test]
fn test_parse_modification_commits_empty() {
let output = "";
let commits = parse_modification_commits(output);
assert!(commits.is_empty());
}
#[test]
fn test_parse_modification_commits_invalid_lines() {
let output = r#"some garbage
:::abc123:::2025-01-01T10:00:00Z:::fix: bug:::author@example.com
more garbage"#;
let commits = parse_modification_commits(output);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].hash, "abc123");
}
#[test]
fn test_parse_modification_commits_missing_fields() {
let output = ":::abc123:::2025-01-01T10:00:00Z:::fix: bug";
let commits = parse_modification_commits(output);
assert!(commits.is_empty());
}
#[test]
fn test_filter_bug_fix_commits() {
let commits = vec![
CommitInfo {
message: "fix: bug".to_string(),
..Default::default()
},
CommitInfo {
message: "feat: feature".to_string(),
..Default::default()
},
CommitInfo {
message: "hotfix: urgent".to_string(),
..Default::default()
},
CommitInfo {
message: "chore: cleanup".to_string(),
..Default::default()
},
];
let bug_fixes = filter_bug_fix_commits(&commits);
assert_eq!(bug_fixes.len(), 2);
assert_eq!(bug_fixes[0].message, "fix: bug");
assert_eq!(bug_fixes[1].message, "hotfix: urgent");
}
#[test]
fn test_function_history_never_modified() {
let now = Utc::now();
let history = FunctionHistory {
total_commits: 0,
bug_fix_count: 0,
introduced: Some(now - chrono::Duration::days(30)),
..Default::default()
};
assert_eq!(history.bug_density(), 0.0);
assert_eq!(history.change_frequency(now), 0.0);
}
#[test]
fn test_function_history_with_modifications() {
let now = Utc::now();
let introduced = now - chrono::Duration::days(30);
let history = FunctionHistory {
introduction_commit: Some("abc123".to_string()),
total_commits: 4,
bug_fix_count: 1,
introduced: Some(introduced),
..Default::default()
};
assert_eq!(history.bug_density(), 0.25);
let freq = history.change_frequency(now);
assert!(freq > 3.5 && freq < 4.5, "Expected ~4.0, got {freq}");
}
#[test]
fn test_function_history_all_bug_fixes() {
let now = Utc::now();
let history = FunctionHistory {
total_commits: 5,
bug_fix_count: 5,
introduced: Some(now - chrono::Duration::days(10)),
..Default::default()
};
assert_eq!(history.bug_density(), 1.0);
}
#[test]
fn test_function_history_age_days() {
let now = Utc::now();
let ten_days_ago = now - chrono::Duration::days(10);
let history = FunctionHistory {
introduced: Some(ten_days_ago),
..Default::default()
};
let age = history.age_days(now);
assert!((9..=11).contains(&age), "Expected ~10 days, got {age}");
}
#[test]
fn test_calculate_function_history_with_authors() {
let introduced = Utc::now() - chrono::Duration::days(60);
let commits = vec![
CommitInfo {
hash: "abc123".to_string(),
date: Some(introduced + chrono::Duration::days(10)),
message: "fix: first bug".to_string(),
author: "dev1@example.com".to_string(),
},
CommitInfo {
hash: "def456".to_string(),
date: Some(introduced + chrono::Duration::days(20)),
message: "feat: add feature".to_string(),
author: "dev2@example.com".to_string(),
},
CommitInfo {
hash: "ghi789".to_string(),
date: Some(introduced + chrono::Duration::days(30)),
message: "fix: second bug".to_string(),
author: "dev1@example.com".to_string(),
},
];
let mut blame_authors = HashSet::new();
blame_authors.insert("Alice".to_string());
blame_authors.insert("Bob".to_string());
let history = calculate_function_history_with_authors(
Some("intro123".to_string()),
Some(introduced),
&commits,
blame_authors,
);
assert_eq!(history.introduction_commit, Some("intro123".to_string()));
assert_eq!(history.total_commits, 3);
assert_eq!(history.bug_fix_count, 2);
assert_eq!(history.authors.len(), 2);
assert!(history.authors.contains("Alice"));
assert!(history.authors.contains("Bob"));
assert!(history.last_modified.is_some());
assert!(history.introduced.is_some());
assert!((history.bug_density() - 0.666).abs() < 0.01);
}
}