codepulse 0.2.0

Measure the heartbeat of your codebase — visualize lines of code over time from git history
use anyhow::{Context, Result};
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
use clap::Parser;
use git2::{Repository, Sort};
use plotters::prelude::*;
use std::collections::BTreeMap;
use std::path::PathBuf;

#[derive(Parser, Debug)]
#[command(name = "git-graph-analyzer")]
#[command(about = "Analyzes a git repository and plots lines of code over time")]
struct Args {
    /// Path to the git repository to analyze (defaults to current directory)
    #[arg(short, long)]
    repo: Option<PathBuf>,

    /// Output PNG file path (defaults to <folder-name>_loc.png)
    #[arg(short, long)]
    output: Option<PathBuf>,

    /// Chart title (defaults to folder name)
    #[arg(short, long)]
    title: Option<String>,
}

/// Data for a single commit
#[derive(Debug)]
struct CommitData {
    date: DateTime<Utc>,
    insertions: usize,
}

/// Analyze a git repository and collect commit statistics
fn analyze_repository(repo_path: &PathBuf) -> Result<Vec<CommitData>> {
    let repo = Repository::open(repo_path)
        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;

    let mut revwalk = repo.revwalk()?;
    revwalk.push_head()?;
    revwalk.set_sorting(Sort::TIME | Sort::REVERSE)?;

    let mut commits = Vec::new();

    for oid in revwalk {
        let oid = oid?;
        let commit = repo.find_commit(oid)?;

        let timestamp = commit.time().seconds();
        let date = Utc.timestamp_opt(timestamp, 0).single().unwrap_or_else(Utc::now);

        let tree = commit.tree()?;
        let parent_tree = if commit.parent_count() > 0 {
            Some(commit.parent(0)?.tree()?)
        } else {
            None
        };

        let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
        let stats = diff.stats()?;

        commits.push(CommitData {
            date,
            insertions: stats.insertions(),
        });
    }

    Ok(commits)
}

/// Get the Monday of the week for a given date
fn get_week_start(date: &DateTime<Utc>) -> NaiveDate {
    let weekday = date.weekday().num_days_from_monday();
    date.date_naive() - chrono::Duration::days(weekday as i64)
}

/// Aggregate commits by week
fn aggregate_by_week(commits: &[CommitData]) -> (BTreeMap<NaiveDate, i64>, BTreeMap<NaiveDate, i64>) {
    let mut lines_added: BTreeMap<NaiveDate, i64> = BTreeMap::new();

    for commit in commits {
        let week = get_week_start(&commit.date);
        *lines_added.entry(week).or_insert(0) += commit.insertions as i64;
    }

    // Calculate running total
    let mut total_lines: BTreeMap<NaiveDate, i64> = BTreeMap::new();
    let mut running_total: i64 = 0;
    for (week, added) in &lines_added {
        running_total += added;
        total_lines.insert(*week, running_total);
    }

    (lines_added, total_lines)
}

/// Generate the chart
fn generate_chart(
    lines_added: &BTreeMap<NaiveDate, i64>,
    total_lines: &BTreeMap<NaiveDate, i64>,
    output_path: &PathBuf,
    title: &str,
) -> Result<()> {
    if lines_added.is_empty() {
        anyhow::bail!("No data to plot");
    }

    let root = BitMapBackend::new(output_path, (1200, 800)).into_drawing_area();
    root.fill(&WHITE)?;

    let min_date = *lines_added.keys().next().unwrap();
    let max_date = *lines_added.keys().last().unwrap();
    
    // Ensure date range has at least some span (handle single data point)
    let (min_date, max_date) = if min_date == max_date {
        (min_date - chrono::Duration::days(7), max_date + chrono::Duration::days(7))
    } else {
        (min_date, max_date)
    };

    // Y-axis ranges
    let max_added = *lines_added.values().max().unwrap();
    let max_total = *total_lines.values().max().unwrap();

    let y1_max = (max_added as f64 * 1.15) as i64;
    let y2_max = (max_total as f64 * 1.15) as i64;

    let chart_title = format!("{} - Lines of Code (Weekly)", title);
    let mut chart = ChartBuilder::on(&root)
        .caption(&chart_title, ("sans-serif", 40).into_font())
        .margin(20)
        .x_label_area_size(60)
        .y_label_area_size(100)
        .right_y_label_area_size(100)
        .build_cartesian_2d(min_date..max_date, 0i64..y1_max)?
        .set_secondary_coord(min_date..max_date, 0i64..y2_max);

    // Left Y-axis: Lines Added This Week
    chart
        .configure_mesh()
        .x_labels(8)
        .y_labels(8)
        .x_label_formatter(&|d| d.format("%b %d").to_string())
        .y_label_formatter(&|v| format!("{}", v))
        .x_desc("Week")
        .y_desc("Lines Added This Week")
        .axis_desc_style(("sans-serif", 20).into_font().color(&BLUE))
        .y_label_style(("sans-serif", 14).into_font().color(&BLUE))
        .label_style(("sans-serif", 14))
        .draw()?;

    // Right Y-axis: Total Lines of Code
    chart
        .configure_secondary_axes()
        .y_desc("Total Lines of Code")
        .axis_desc_style(("sans-serif", 20).into_font().color(&RGBColor(34, 139, 34)))
        .label_style(("sans-serif", 14).into_font().color(&RGBColor(34, 139, 34)))
        .draw()?;

    // Calculate sizes based on number of data points to avoid overlap
    let num_points = lines_added.len() as i64;
    let point_size = if num_points > 100 { 3 } else if num_points > 50 { 4 } else { 6 };
    let bar_width = if num_points > 100 { 1 } else if num_points > 50 { 2 } else { 3 };

    // Draw total lines (green line in background)
    let total_points: Vec<(NaiveDate, i64)> = total_lines.iter().map(|(k, v)| (*k, *v)).collect();
    
    // Shaded area under total line
    chart.draw_secondary_series(AreaSeries::new(
        total_points.iter().cloned(),
        0,
        RGBColor(34, 139, 34).mix(0.2).filled(),
    ))?;

    // Total line
    chart
        .draw_secondary_series(LineSeries::new(
            total_points.clone(),
            RGBColor(34, 139, 34).stroke_width(3),
        ))?
        .label("Total Lines of Code")
        .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], RGBColor(34, 139, 34).stroke_width(3)));

    // Draw points on total line
    chart.draw_secondary_series(
        total_points
            .iter()
            .map(|(x, y)| Circle::new((*x, *y), point_size, RGBColor(34, 139, 34).filled())),
    )?;

    // Draw bars for lines added each week (blue)
    chart
        .draw_series(lines_added.iter().map(|(date, value)| {
            Rectangle::new(
                [
                    (*date - chrono::Duration::days(bar_width), 0),
                    (*date + chrono::Duration::days(bar_width), *value),
                ],
                BLUE.mix(0.7).filled(),
            )
        }))?
        .label("Lines Added This Week")
        .legend(|(x, y)| Rectangle::new([(x, y - 5), (x + 20, y + 5)], BLUE.mix(0.7).filled()));

    // Legend
    chart
        .configure_series_labels()
        .background_style(WHITE.mix(0.95))
        .border_style(BLACK.mix(0.5))
        .label_font(("sans-serif", 18))
        .position(SeriesLabelPosition::UpperLeft)
        .draw()?;

    root.present()?;

    Ok(())
}

/// Get the folder name from a path, with a fallback
fn get_folder_name(path: &PathBuf) -> String {
    path.canonicalize()
        .ok()
        .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
        .unwrap_or_else(|| "repository".to_string())
}

fn main() -> Result<()> {
    let args = Args::parse();

    // Default repo to current directory
    let repo_path = args.repo.unwrap_or_else(|| PathBuf::from("."));
    
    // Get folder name for defaults
    let folder_name = get_folder_name(&repo_path);
    
    // Default title to folder name
    let title = args.title.unwrap_or_else(|| folder_name.clone());
    
    // Default output to <folder-name>_loc.png
    let output_path = args.output.unwrap_or_else(|| {
        PathBuf::from(format!("{}_loc.png", folder_name))
    });

    println!("Analyzing repository: {:?}", repo_path);

    let commits = analyze_repository(&repo_path)?;
    println!("Found {} commits", commits.len());

    if commits.is_empty() {
        anyhow::bail!("No commits found in repository");
    }

    let (lines_added, total_lines) = aggregate_by_week(&commits);
    println!("Aggregated into {} weeks", lines_added.len());

    let total = total_lines.values().last().copied().unwrap_or(0);
    println!("Total lines of code: {}", total);

    generate_chart(&lines_added, &total_lines, &output_path, &title)?;
    println!("Chart saved to: {:?}", output_path);

    Ok(())
}