#![cfg_attr(coverage_nightly, coverage(off))]
use crate::models::churn::{ChurnSummary, CodeChurnAnalysis, FileChurnMetrics};
use crate::models::error::TemplateError;
use chrono::{DateTime, Duration, Utc};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::info;
pub struct GitAnalysisService;
impl GitAnalysisService {
#[inline]
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub fn analyze_code_churn(
project_path: &Path,
period_days: u32,
) -> Result<CodeChurnAnalysis, TemplateError> {
if !project_path.join(".git").exists() {
return Err(TemplateError::NotFound(format!(
"No git repository found at {project_path:?}"
)));
}
let since_date = Utc::now() - Duration::days(i64::from(period_days));
let since_str = since_date.format("%Y-%m-%d").to_string();
info!("Analyzing code churn for last {} days", period_days);
let file_metrics = Self::get_file_metrics(project_path, &since_str)?;
let summary = Self::generate_summary(&file_metrics);
Ok(CodeChurnAnalysis {
generated_at: Utc::now(),
period_days,
repository_root: project_path.to_path_buf(),
files: file_metrics,
summary,
})
}
fn get_file_metrics(
project_path: &Path,
since_date: &str,
) -> Result<Vec<FileChurnMetrics>, TemplateError> {
let (tx, rx) = std::sync::mpsc::channel();
let project_dir = project_path.to_path_buf();
let since = since_date.to_string();
std::thread::spawn(move || {
let result = Command::new("git")
.arg("log")
.arg("--since")
.arg(&since)
.arg("--pretty=format:%H|%an|%aI")
.arg("--numstat")
.current_dir(&project_dir)
.output();
let _ = tx.send(result);
});
let timeout = std::time::Duration::from_secs(60);
let output = match rx.recv_timeout(timeout) {
Ok(result) => result.map_err(TemplateError::Io)?,
Err(_) => {
tracing::warn!(
"git log --numstat timed out after {}s for {}",
timeout.as_secs(),
project_path.display()
);
return Ok(Vec::new());
}
};
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
if error_msg.contains("does not have any commits yet") {
return Ok(Vec::new());
}
return Err(TemplateError::NotFound(format!(
"Git log failed: {error_msg}"
)));
}
let log_output = String::from_utf8_lossy(&output.stdout);
let mut file_stats: HashMap<PathBuf, FileStats> = HashMap::with_capacity(64);
let mut current_commit: Option<CommitInfo> = None;
for line in log_output.lines() {
if line.is_empty() {
continue;
}
if let Some((hash, author, date)) = Self::parse_commit_line(line) {
current_commit = Some(CommitInfo { hash, author, date });
} else if let Some(ref commit) = current_commit {
if let Some((additions, deletions, file_path)) = Self::parse_numstat_line(line) {
let path = PathBuf::from(&file_path);
let stats = file_stats.entry(path.clone()).or_insert_with(|| FileStats {
commits: Vec::new(),
authors: HashSet::with_capacity(64),
total_additions: 0,
total_deletions: 0,
first_seen: commit.date.clone(),
last_modified: commit.date.clone(),
});
stats.commits.push(commit.hash.clone());
stats.authors.insert(commit.author.clone());
stats.total_additions += additions;
stats.total_deletions += deletions;
if commit.date > stats.last_modified {
stats.last_modified = commit.date.clone();
}
if commit.date < stats.first_seen {
stats.first_seen = commit.date.clone();
}
}
}
}
let max_commits = file_stats
.values()
.map(|s| s.commits.len())
.max()
.unwrap_or(1);
let max_changes = file_stats
.values()
.map(|s| s.total_additions + s.total_deletions)
.max()
.unwrap_or(1);
let mut metrics: Vec<FileChurnMetrics> = file_stats
.into_iter()
.map(|(path, stats)| {
let mut metric = FileChurnMetrics {
path: project_path.join(&path),
relative_path: path.to_string_lossy().to_string(),
commit_count: stats.commits.len(),
unique_authors: stats.authors.into_iter().collect(),
additions: stats.total_additions,
deletions: stats.total_deletions,
churn_score: 0.0,
last_modified: DateTime::parse_from_rfc3339(&stats.last_modified)
.unwrap_or_else(|_| Utc::now().into())
.with_timezone(&Utc),
first_seen: DateTime::parse_from_rfc3339(&stats.first_seen)
.unwrap_or_else(|_| Utc::now().into())
.with_timezone(&Utc),
};
metric.calculate_churn_score(max_commits, max_changes);
metric
})
.collect();
metrics.sort_by(|a, b| {
b.churn_score
.partial_cmp(&a.churn_score)
.expect("internal error")
});
Ok(metrics)
}
fn parse_commit_line(line: &str) -> Option<(String, String, String)> {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() == 3 {
Some((
parts[0].to_string(),
parts[1].to_string(),
parts[2].to_string(),
))
} else {
None
}
}
fn parse_numstat_line(line: &str) -> Option<(usize, usize, String)> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 {
let additions = parts[0].parse::<usize>().ok()?;
let deletions = parts[1].parse::<usize>().ok()?;
let file_path = parts[2..].join(" ");
Some((additions, deletions, file_path))
} else {
None
}
}
fn generate_summary(files: &[FileChurnMetrics]) -> ChurnSummary {
let mut author_contributions: HashMap<String, usize> = HashMap::with_capacity(64);
let mut total_commits = 0;
for file in files {
total_commits += file.commit_count;
for author in &file.unique_authors {
*author_contributions.entry(author.clone()).or_insert(0) += 1;
}
}
let hotspot_files: Vec<PathBuf> = files
.iter()
.take(10)
.filter(|f| f.churn_score > 0.5)
.map(|f| f.path.clone())
.collect();
let stable_files: Vec<PathBuf> = files
.iter()
.rev()
.take(10)
.filter(|f| f.churn_score < 0.1 && f.commit_count > 0)
.map(|f| f.path.clone())
.collect();
let (mean_churn_score, variance_churn_score, stddev_churn_score) = if files.is_empty() {
(0.0, 0.0, 0.0)
} else {
let scores: Vec<f64> = files.iter().map(|f| f64::from(f.churn_score)).collect();
let n = scores.len() as f64;
let sum: f64 = scores.iter().sum();
let mean = sum / n;
let variance = scores.iter().map(|&s| (s - mean).powi(2)).sum::<f64>() / n;
let stddev = variance.sqrt();
(mean, variance, stddev)
};
ChurnSummary {
total_commits,
total_files_changed: files.len(),
hotspot_files,
stable_files,
author_contributions,
mean_churn_score,
variance_churn_score,
stddev_churn_score,
}
}
}
struct FileStats {
commits: Vec<String>,
authors: HashSet<String>,
total_additions: usize,
total_deletions: usize,
first_seen: String,
last_modified: String,
}
struct CommitInfo {
hash: String,
author: String,
date: String,
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_git_analysis_basic() {
assert_eq!(1 + 1, 2);
}
#[test]
fn test_parse_commit_line_valid() {
let line = "abc123|John Doe|2024-01-15T10:30:00Z";
let result = GitAnalysisService::parse_commit_line(line);
assert!(result.is_some());
let (hash, author, date) = result.unwrap();
assert_eq!(hash, "abc123");
assert_eq!(author, "John Doe");
assert_eq!(date, "2024-01-15T10:30:00Z");
}
#[test]
fn test_parse_commit_line_invalid_too_few() {
let line = "abc123|John Doe";
let result = GitAnalysisService::parse_commit_line(line);
assert!(result.is_none());
}
#[test]
fn test_parse_commit_line_invalid_empty() {
let line = "";
let result = GitAnalysisService::parse_commit_line(line);
assert!(result.is_none());
}
#[test]
fn test_parse_numstat_line_valid() {
let line = "10\t20\tsrc/main.rs";
let result = GitAnalysisService::parse_numstat_line(line);
assert!(result.is_some());
let (additions, deletions, path) = result.unwrap();
assert_eq!(additions, 10);
assert_eq!(deletions, 20);
assert_eq!(path, "src/main.rs");
}
#[test]
fn test_parse_numstat_line_with_spaces() {
let line = "5\t15\tpath/to file.rs";
let result = GitAnalysisService::parse_numstat_line(line);
assert!(result.is_some());
let (additions, deletions, path) = result.unwrap();
assert_eq!(additions, 5);
assert_eq!(deletions, 15);
assert_eq!(path, "path/to file.rs");
}
#[test]
fn test_parse_numstat_line_invalid() {
let line = "invalid";
let result = GitAnalysisService::parse_numstat_line(line);
assert!(result.is_none());
}
#[test]
fn test_parse_numstat_line_binary() {
let line = "-\t-\timage.png";
let result = GitAnalysisService::parse_numstat_line(line);
assert!(result.is_none()); }
#[test]
fn test_generate_summary_empty() {
let files: Vec<FileChurnMetrics> = vec![];
let summary = GitAnalysisService::generate_summary(&files);
assert_eq!(summary.total_commits, 0);
assert_eq!(summary.total_files_changed, 0);
assert!(summary.hotspot_files.is_empty());
assert!(summary.stable_files.is_empty());
assert_eq!(summary.mean_churn_score, 0.0);
}
#[test]
fn test_generate_summary_single_file() {
let files = vec![FileChurnMetrics {
path: PathBuf::from("src/main.rs"),
relative_path: "src/main.rs".to_string(),
commit_count: 5,
unique_authors: vec!["Author1".to_string()],
additions: 100,
deletions: 50,
churn_score: 0.7,
last_modified: Utc::now(),
first_seen: Utc::now(),
}];
let summary = GitAnalysisService::generate_summary(&files);
assert_eq!(summary.total_commits, 5);
assert_eq!(summary.total_files_changed, 1);
assert_eq!(summary.hotspot_files.len(), 1); }
#[test]
fn test_generate_summary_multiple_authors() {
let files = vec![
FileChurnMetrics {
path: PathBuf::from("file1.rs"),
relative_path: "file1.rs".to_string(),
commit_count: 3,
unique_authors: vec!["Author1".to_string(), "Author2".to_string()],
additions: 50,
deletions: 20,
churn_score: 0.3,
last_modified: Utc::now(),
first_seen: Utc::now(),
},
FileChurnMetrics {
path: PathBuf::from("file2.rs"),
relative_path: "file2.rs".to_string(),
commit_count: 2,
unique_authors: vec!["Author2".to_string()],
additions: 30,
deletions: 10,
churn_score: 0.05,
last_modified: Utc::now(),
first_seen: Utc::now(),
},
];
let summary = GitAnalysisService::generate_summary(&files);
assert_eq!(summary.total_commits, 5);
assert_eq!(summary.total_files_changed, 2);
assert_eq!(summary.author_contributions.len(), 2);
}
#[test]
fn test_analyze_code_churn_no_git() {
let temp_dir = TempDir::new().unwrap();
let result = GitAnalysisService::analyze_code_churn(temp_dir.path(), 30);
assert!(result.is_err());
}
#[test]
fn test_generate_summary_stable_files() {
let files = vec![FileChurnMetrics {
path: PathBuf::from("stable.rs"),
relative_path: "stable.rs".to_string(),
commit_count: 1,
unique_authors: vec!["Author".to_string()],
additions: 10,
deletions: 5,
churn_score: 0.05, last_modified: Utc::now(),
first_seen: Utc::now(),
}];
let summary = GitAnalysisService::generate_summary(&files);
assert_eq!(summary.stable_files.len(), 1);
}
#[test]
fn test_churn_summary_statistics() {
let files = vec![
FileChurnMetrics {
path: PathBuf::from("f1.rs"),
relative_path: "f1.rs".to_string(),
commit_count: 2,
unique_authors: vec![],
additions: 0,
deletions: 0,
churn_score: 0.2,
last_modified: Utc::now(),
first_seen: Utc::now(),
},
FileChurnMetrics {
path: PathBuf::from("f2.rs"),
relative_path: "f2.rs".to_string(),
commit_count: 4,
unique_authors: vec![],
additions: 0,
deletions: 0,
churn_score: 0.8,
last_modified: Utc::now(),
first_seen: Utc::now(),
},
];
let summary = GitAnalysisService::generate_summary(&files);
assert!((summary.mean_churn_score - 0.5).abs() < 0.01);
assert!(summary.variance_churn_score >= 0.0);
assert!(summary.stddev_churn_score >= 0.0);
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod property_tests {
use proptest::prelude::*;
proptest! {
#[test]
fn basic_property_stability(_input in ".*") {
prop_assert!(true);
}
#[test]
fn module_consistency_check(_x in 0u32..1000) {
prop_assert!(_x < 1001);
}
}
}