use anyhow::{bail, Context, Result};
use clap::Args;
use colored::Colorize;
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::OutputConfig;
use crate::config::Config;
use crate::index::GitAnalyzer;
#[derive(Args)]
pub struct HistoryArgs {
file: PathBuf,
#[arg(long, short = 'n', default_value = "20")]
limit: usize,
}
#[derive(Serialize)]
struct HistoryOutput {
file: String,
entries: Vec<HistoryEntry>,
stats: HistoryStats,
}
#[derive(Serialize)]
struct HistoryEntry {
date: String,
author: String,
message: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
issues: Vec<String>,
}
#[derive(Serialize)]
struct HistoryStats {
total_commits: usize,
authors: Vec<AuthorStat>,
churn_rate: f32,
}
#[derive(Serialize)]
struct AuthorStat {
name: String,
commits: usize,
}
pub async fn run(args: HistoryArgs, output: OutputConfig) -> Result<()> {
let file_path = args
.file
.canonicalize()
.with_context(|| format!("File not found: {}", args.file.display()))?;
let repo_root = find_repo_root(&file_path)?;
let rel_path = file_path
.strip_prefix(&repo_root)
.context("File is not inside the repository")?
.to_string_lossy()
.to_string();
let analyzer = GitAnalyzer::new(&repo_root)?;
let history = analyzer.get_file_history(&rel_path, args.limit)?;
if history.is_empty() {
if output.json {
println!(
"{}",
serde_json::to_string_pretty(&HistoryOutput {
file: rel_path,
entries: vec![],
stats: HistoryStats {
total_commits: 0,
authors: vec![],
churn_rate: 0.0,
},
})?
);
} else {
bail!("No history found for: {}", rel_path);
}
return Ok(());
}
let mut author_counts: HashMap<String, usize> = HashMap::new();
for entry in &history {
*author_counts.entry(entry.author.clone()).or_insert(0) += 1;
}
let mut authors: Vec<AuthorStat> = author_counts
.into_iter()
.map(|(name, commits)| AuthorStat { name, commits })
.collect();
authors.sort_by(|a, b| b.commits.cmp(&a.commits));
let churn_rate = if history.len() >= 2 {
let first_ts = history.last().map(|e| e.timestamp).unwrap_or(0);
let last_ts = history.first().map(|e| e.timestamp).unwrap_or(0);
let days = ((last_ts - first_ts) as f32 / 86400.0).max(1.0);
(history.len() as f32 / days) * 30.0 } else {
0.0
};
let stats = HistoryStats {
total_commits: history.len(),
authors,
churn_rate,
};
let entries: Vec<HistoryEntry> = history
.into_iter()
.map(|h| HistoryEntry {
date: h.date,
author: h.author,
message: h.message,
issues: h.issues,
})
.collect();
if output.json {
let json_output = HistoryOutput {
file: rel_path,
entries,
stats,
};
println!("{}", serde_json::to_string_pretty(&json_output)?);
} else {
println!("History for {}:", rel_path.cyan());
println!();
for entry in &entries {
let issues_str = if entry.issues.is_empty() {
String::new()
} else {
format!(" ({})", entry.issues.join(", ").yellow())
};
println!(
"- {} ({}): {}{}",
entry.date.dimmed(),
entry.author.green(),
entry.message,
issues_str
);
}
println!();
println!("{}", "Statistics:".bold());
println!(" Total commits: {}", stats.total_commits);
println!(" Churn rate: {:.1} commits/month", stats.churn_rate);
println!(" Authors:");
for author in &stats.authors {
println!(" - {}: {} commits", author.name, author.commits);
}
}
Ok(())
}
fn find_repo_root(start_path: &Path) -> Result<PathBuf> {
let mut current = start_path;
if current.is_file() {
if let Some(p) = current.parent() {
current = p;
}
}
loop {
if Config::config_path(current).exists() {
return Ok(current.to_path_buf());
}
match current.parent() {
Some(p) => current = p,
None => break,
}
}
bail!("Bobbin not initialized. Run `bobbin init` first.")
}