use std::path::{Path, PathBuf};
use anyhow::Result;
use clap::Args;
use tldr_core::quality::churn::{
build_summary, check_shallow_clone, get_author_stats, get_file_churn, is_git_repository,
ChurnError, ChurnReport,
};
use crate::output::{OutputFormat, OutputWriter};
#[derive(Debug, Args)]
pub struct ChurnArgs {
#[arg(default_value = ".")]
pub path: PathBuf,
#[arg(long, default_value = "365")]
pub days: u32,
#[arg(long, default_value = "20")]
pub top: usize,
#[arg(long, short = 'e')]
pub exclude: Vec<String>,
#[arg(long)]
pub authors: bool,
#[arg(long, hide = true)]
pub hotspots: bool,
}
impl ChurnArgs {
pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
let writer = OutputWriter::new(format, quiet);
writer.progress(&format!(
"Analyzing churn in {} (last {} days)...",
self.path.display(),
self.days
));
let report = analyze_churn(
&self.path,
self.days,
self.top,
&self.exclude,
self.authors,
self.hotspots,
)?;
if writer.is_text() {
let text = format_churn_text(&report);
writer.write_text(&text)?;
} else {
writer.write(&report)?;
}
Ok(())
}
}
pub fn analyze_churn(
path: &Path,
days: u32,
top_k: usize,
exclude_patterns: &[String],
include_authors: bool,
include_hotspots: bool,
) -> Result<ChurnReport, ChurnError> {
if !is_git_repository(path)? {
return Err(ChurnError::NotGitRepository {
path: path.to_path_buf(),
});
}
let (is_shallow, shallow_depth) = check_shallow_clone(path)?;
let mut warnings = Vec::new();
if is_shallow {
let depth_info = shallow_depth
.map(|d| format!(" (~{} commits)", d))
.unwrap_or_default();
warnings.push(format!(
"Repository is a shallow clone{}. Churn analysis may be incomplete.",
depth_info
));
}
let file_stats = get_file_churn(path, days, exclude_patterns)?;
let mut files: Vec<_> = file_stats.values().cloned().collect();
files.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
files.truncate(top_k);
let authors = if include_authors {
get_author_stats(path, days, &file_stats)?
} else {
Vec::new()
};
if include_hotspots {
eprintln!("Warning: `churn --hotspots` is removed. Use `tldr hotspots` instead.");
}
let hotspots = Vec::new();
let summary = build_summary(&file_stats, days);
Ok(ChurnReport {
files,
hotspots,
authors,
summary,
is_shallow,
shallow_depth,
warnings,
})
}
fn format_churn_text(report: &ChurnReport) -> String {
use colored::Colorize;
let mut output = String::new();
output.push_str(&format!(
"Churn Analysis ({} files, {} days)\n",
report.summary.total_files.to_string().yellow(),
report.summary.time_window_days
));
output.push_str(&format!(
"Total commits: {}, Lines changed: {}\n",
report.summary.total_commits.to_string().cyan(),
report.summary.total_lines_changed.to_string().cyan()
));
output.push_str(&format!(
"Most churned: {}\n\n",
report.summary.most_churned_file.green()
));
for warning in &report.warnings {
output.push_str(&format!("{} {}\n", "Warning:".yellow(), warning));
}
if !report.warnings.is_empty() {
output.push('\n');
}
if !report.files.is_empty() {
output.push_str(&"High-Churn Files:\n".bold().to_string());
output.push_str(&format!(
" {:>3} {:>7} {:>7} {:>7} {:>4} {:>10} {}\n",
"#", "Commits", "+Lines", "-Lines", "Auth", "Last", "File"
));
for (i, file) in report.files.iter().enumerate() {
output.push_str(&format!(
" {:>3} {:>7} {:>7} {:>7} {:>4} {:>10} {}\n",
i + 1,
file.commit_count,
format!("+{}", file.lines_added),
format!("-{}", file.lines_deleted),
file.author_count,
file.last_commit.as_deref().unwrap_or("-"),
file.file
));
}
output.push('\n');
}
if !report.hotspots.is_empty() {
output.push_str(&"Hotspots (churn x complexity):\n".bold().to_string());
output.push_str(&format!(
" {:>3} {:>5} {:>7} {:>4} {}\n",
"#", "Score", "Commits", "CC", "File"
));
for (i, hotspot) in report.hotspots.iter().enumerate() {
let score_str = format!("{:.2}", hotspot.combined_score);
let score_display = if hotspot.combined_score > 0.6 {
score_str.red().to_string()
} else if hotspot.combined_score > 0.3 {
score_str.yellow().to_string()
} else {
score_str.green().to_string()
};
output.push_str(&format!(
" {:>3} {:>5} {:>7} {:>4} {}\n",
i + 1,
score_display,
hotspot.commit_count,
hotspot.cyclomatic_complexity,
hotspot.file
));
}
output.push('\n');
}
if !report.authors.is_empty() {
output.push_str(&"Author Statistics:\n".bold().to_string());
output.push_str(&format!(
" {:>3} {:>7} {:>7} {:>7} {:>5} {}\n",
"#", "Commits", "+Lines", "-Lines", "Files", "Author"
));
for (i, author) in report.authors.iter().enumerate() {
output.push_str(&format!(
" {:>3} {:>7} {:>7} {:>7} {:>5} {}\n",
i + 1,
author.commits,
format!("+{}", author.lines_added),
format!("-{}", author.lines_deleted),
author.files_touched,
author.name
));
}
}
output
}