use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use git2::Repository;
#[derive(Debug, Clone, serde::Serialize)]
pub struct LeadTimeMetrics {
pub average_hours: f64,
pub median_hours: f64,
pub p95_hours: f64,
pub fast_merge_percent: f64,
pub slow_merge_percent: f64,
pub total_commits: usize,
pub fast_merge_count: usize,
pub slow_merge_count: usize,
}
pub fn analyze_lead_time(repo_path: &str, days: usize) -> Result<LeadTimeMetrics> {
let repo = Repository::open(repo_path).context("Failed to open git repository")?;
let cutoff_date = Utc::now() - Duration::days(days as i64);
let head = repo.head().context("Failed to get HEAD")?;
let head_oid = head
.target()
.ok_or_else(|| anyhow::anyhow!("HEAD has no target"))?;
let mut revwalk = repo.revwalk().context("Failed to create revwalk")?;
revwalk.push(head_oid).context("Failed to push HEAD")?;
let mut lead_times: Vec<f64> = Vec::new(); let mut fast_merges = 0usize;
let mut slow_merges = 0usize;
for oid in revwalk {
let oid = oid?;
let commit = repo.find_commit(oid)?;
let time = commit.time();
let commit_date = DateTime::<Utc>::from_timestamp(time.seconds(), 0).unwrap_or_default();
if commit_date < cutoff_date {
break;
}
let parent_count = commit.parent_count();
if parent_count > 0 {
let mut parent_times: Vec<DateTime<Utc>> = Vec::new();
for i in 0..parent_count {
if let Ok(parent) = commit.parent(i) {
let parent_time = parent.time();
let parent_date = DateTime::<Utc>::from_timestamp(parent_time.seconds(), 0)
.unwrap_or_default();
parent_times.push(parent_date);
}
}
if !parent_times.is_empty() {
let newest_parent = parent_times.iter().max().unwrap_or(&commit_date);
let lead_time = commit_date.signed_duration_since(*newest_parent);
let lead_time_hours = lead_time.num_seconds().abs() as f64 / 3600.0;
if lead_time_hours <= 168.0 {
lead_times.push(lead_time_hours);
if lead_time_hours < 1.0 {
fast_merges += 1;
}
if lead_time_hours > 48.0 {
slow_merges += 1;
}
}
}
}
}
if lead_times.is_empty() {
return Ok(LeadTimeMetrics {
average_hours: 0.0,
median_hours: 0.0,
p95_hours: 0.0,
fast_merge_percent: 0.0,
slow_merge_percent: 0.0,
total_commits: 0,
fast_merge_count: 0,
slow_merge_count: 0,
});
}
lead_times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let average = lead_times.iter().sum::<f64>() / lead_times.len() as f64;
let median = lead_times[lead_times.len() / 2];
let p95_index = (lead_times.len() as f64 * 0.95) as usize;
let p95 = lead_times.get(p95_index).unwrap_or(&median);
let total = lead_times.len();
let fast_percent = (fast_merges as f64 / total as f64) * 100.0;
let slow_percent = (slow_merges as f64 / total as f64) * 100.0;
Ok(LeadTimeMetrics {
average_hours: average,
median_hours: median,
p95_hours: *p95,
fast_merge_percent: fast_percent,
slow_merge_percent: slow_percent,
total_commits: total,
fast_merge_count: fast_merges,
slow_merge_count: slow_merges,
})
}
pub fn generate_report(metrics: &LeadTimeMetrics) -> String {
use colored::*;
let mut report = String::new();
report.push_str(&"\n".bold());
report.push_str(&"=== LEAD TIME ANALYSIS ===\n".bold());
report.push('\n');
report.push_str(&"Time from Commit to Merge:\n".bold());
report.push_str(&format!(
" Average: {:.2} hours (target: <24h)\n",
metrics.average_hours
));
let avg_status = if metrics.average_hours < 24.0 {
"✅".green()
} else if metrics.average_hours < 48.0 {
"⚠️".yellow()
} else {
"❌".red()
};
report.push_str(&format!(" Status: {}\n", avg_status));
report.push_str(&format!(" Median: {:.2} hours\n", metrics.median_hours));
report.push_str(&format!(
" P95: {:.2} hours (worst 5%%)\n",
metrics.p95_hours
));
report.push_str(&"\nMerge Speed:\n".bold());
report.push_str(&format!(
" Fast merges (<1h): {:.1}% ({} commits)\n",
metrics.fast_merge_percent, metrics.fast_merge_count
));
report.push_str(&format!(
" Slow merges (>48h): {:.1}% ({} commits)\n",
metrics.slow_merge_percent, metrics.slow_merge_count
));
report.push_str(&"\nLead Time Categories:\n".bold());
if metrics.average_hours < 1.0 {
report.push_str(&" • Excellent: Commits merged within 1 hour\n".green());
} else if metrics.average_hours < 24.0 {
report.push_str(&" • Good: Same-day merge average\n".green());
} else if metrics.average_hours < 48.0 {
report.push_str(&" • Fair: Multi-day merge average\n".yellow());
} else {
report.push_str(&" • Poor: Week-long or more merge delays\n".red());
}
report.push_str(&"\nKaizen Recommendations:\n".bold());
if metrics.average_hours >= 24.0 {
report.push_str(&" • Average lead time exceeds 24h. Reduce review backlog.\n".yellow());
}
if metrics.slow_merge_percent > 10.0 {
let msg = format!(
" • {:.1}% commits take >48h. Investigate blocking PRs.\n",
metrics.slow_merge_percent
);
report.push_str(&msg.yellow());
}
if metrics.fast_merge_percent < 20.0 {
report.push_str(&" • Few fast merges. Consider trunk-based development.\n".yellow());
}
if metrics.average_hours < 24.0 && metrics.slow_merge_percent < 10.0 {
report.push_str(&" • Lead time is optimal! Maintain fast review cycle.\n".green());
}
report.push('\n');
report
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_p95_index_never_out_of_bounds() {
for len in 1usize..=200 {
let p95_index = (len as f64 * 0.95) as usize;
assert!(
p95_index < len,
"p95_index {p95_index} >= len {len}: would panic on get()"
);
}
}
#[test]
fn test_p95_ge_median_for_sorted_sequence() {
let mut times = vec![0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0];
times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = times[times.len() / 2];
let p95_index = (times.len() as f64 * 0.95) as usize;
let p95 = times.get(p95_index).copied().unwrap_or(median);
assert!(
p95 >= median,
"p95 ({p95}) must be >= median ({median}) in sorted data"
);
}
#[test]
fn test_fast_and_slow_merge_are_mutually_exclusive() {
let lead_times: Vec<f64> = vec![
0.5, 1.5, 24.0, 72.0, ];
let fast = lead_times.iter().filter(|&&h| h < 1.0).count();
let slow = lead_times.iter().filter(|&&h| h > 48.0).count();
let total = lead_times.len();
assert!(
fast + slow <= total,
"fast ({fast}) + slow ({slow}) > total ({total})"
);
assert!(fast + slow < total, "expected some commits in neither category");
}
#[test]
fn test_average_is_between_min_and_max() {
let times = vec![0.1, 2.0, 5.0, 8.0, 48.0, 100.0];
let avg = times.iter().sum::<f64>() / times.len() as f64;
let min = times.iter().copied().fold(f64::INFINITY, f64::min);
let max = times.iter().copied().fold(f64::NEG_INFINITY, f64::max);
assert!(avg >= min, "average {avg} < min {min}");
assert!(avg <= max, "average {avg} > max {max}");
}
#[test]
fn test_adding_slow_outlier_raises_average_and_p95() {
let base = vec![0.5, 1.0, 2.0];
let with_outlier = {
let mut v = base.clone();
v.push(168.0); v
};
let avg_base = base.iter().sum::<f64>() / base.len() as f64;
let avg_outlier = with_outlier.iter().sum::<f64>() / with_outlier.len() as f64;
assert!(
avg_outlier > avg_base,
"avg should increase after adding outlier: {avg_base} → {avg_outlier}"
);
let mut base_sorted = base.clone();
base_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p95_base_idx = (base_sorted.len() as f64 * 0.95) as usize;
let p95_base = base_sorted[p95_base_idx];
let mut out_sorted = with_outlier.clone();
out_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p95_out_idx = (out_sorted.len() as f64 * 0.95) as usize;
let p95_out = out_sorted[p95_out_idx];
assert!(
p95_out >= p95_base,
"p95 should not decrease after adding outlier: {p95_base} → {p95_out}"
);
}
#[test]
fn test_empty_metrics_are_all_zero() {
let m = LeadTimeMetrics {
average_hours: 0.0,
median_hours: 0.0,
p95_hours: 0.0,
fast_merge_percent: 0.0,
slow_merge_percent: 0.0,
total_commits: 0,
fast_merge_count: 0,
slow_merge_count: 0,
};
assert_eq!(m.total_commits, 0);
assert_eq!(m.fast_merge_count, 0);
assert_eq!(m.slow_merge_count, 0);
assert_eq!(m.average_hours, 0.0);
assert_eq!(m.fast_merge_percent, 0.0);
assert_eq!(m.slow_merge_percent, 0.0);
}
#[test]
fn test_percentage_fields_bounded() {
let cases: Vec<(f64, usize, usize, usize)> = vec![
(0.5, 1, 1, 0),
(72.0, 1, 0, 1),
(10.0, 4, 2, 1),
];
for (_, total, fast, slow) in cases {
let fast_pct = (fast as f64 / total as f64) * 100.0;
let slow_pct = (slow as f64 / total as f64) * 100.0;
assert!((0.0..=100.0).contains(&fast_pct), "fast% {fast_pct} out of [0,100]");
assert!((0.0..=100.0).contains(&slow_pct), "slow% {slow_pct} out of [0,100]");
}
}
#[test]
fn test_seven_day_cap_filters_stale_branches() {
let raw_times = vec![1.0, 2.0, 500.0, 800.0]; let capped: Vec<f64> = raw_times.into_iter().filter(|&h| h <= 168.0).collect();
assert_eq!(capped.len(), 2, "only in-range times should be included");
let avg = capped.iter().sum::<f64>() / capped.len() as f64;
assert!(avg < 24.0, "avg of valid commits should be < 24h: {avg}");
}
}