use crate::git::run_command;
use std::time::{SystemTime, UNIX_EPOCH};
pub fn collect_commit_timestamps() -> Result<Vec<u64>, String> {
let out = run_command(&["--no-pager", "log", "--no-merges", "--format=%ct"])?;
let mut ts: Vec<u64> = Vec::new();
for line in out.lines() {
if let Ok(v) = line.trim().parse::<u64>() {
ts.push(v);
}
}
Ok(ts)
}
pub fn compute_timeline_weeks(timestamps: &[u64], weeks: usize, now: u64) -> Vec<usize> {
let mut counts = vec![0usize; weeks];
if weeks == 0 {
return counts;
}
const WEEK: u64 = 7 * 24 * 60 * 60;
let start_of_week = now - (now % WEEK);
let aligned_end = start_of_week.saturating_add(WEEK - 1);
for &t in timestamps {
if t > aligned_end {
continue;
}
let diff = aligned_end - t;
let bin = (diff / WEEK) as usize;
if bin < weeks {
let idx = weeks - 1 - bin;
counts[idx] += 1;
}
}
counts
}
pub fn compute_heatmap_utc(timestamps: &[u64]) -> [[usize; 24]; 7] {
let mut grid = [[0usize; 24]; 7];
for &t in timestamps {
let day = t / 86_400;
let weekday = ((day + 4) % 7) as usize;
let hour = ((t / 3_600) % 24) as usize;
grid[weekday][hour] += 1;
}
grid
}
pub fn compute_calendar_heatmap(timestamps: &[u64], weeks: usize, now: u64) -> Vec<Vec<usize>> {
let mut grid = vec![vec![0usize; weeks]; 7];
if weeks == 0 {
return grid;
}
const DAY: u64 = 86_400;
const WEEK: u64 = DAY * 7;
let start_of_week = now - (now % WEEK);
let aligned_end = start_of_week.saturating_add(WEEK - 1);
let span = (weeks as u64).saturating_mul(WEEK);
let min_ts = aligned_end.saturating_sub(span.saturating_sub(1));
for &t in timestamps {
if t > aligned_end || t < min_ts {
continue;
}
let day_index = (aligned_end - t) / DAY; let week_off = (day_index / 7) as usize; if week_off >= weeks {
continue;
}
let col = weeks - 1 - week_off; let day = t / DAY;
let weekday = ((day + 4) % 7) as usize; grid[weekday][col] += 1;
}
grid
}
pub fn render_timeline_bars(counts: &[usize]) {
let ramp: &[u8] = b" .:-=+*#%@"; let max = counts.iter().copied().max().unwrap_or(0);
if max == 0 {
println!("(no commits in selected window)");
return;
}
let mut line = String::with_capacity(counts.len());
for &c in counts {
let idx = (c.saturating_mul(ramp.len() - 1)) / max;
line.push(ramp[idx] as char);
}
println!("{}", line);
}
pub fn render_heatmap_ascii(grid: [[usize; 24]; 7]) {
let ramp: &[u8] = b" .:-=+*#%@"; let mut max = 0usize;
for r in 0..7 {
for h in 0..24 {
if grid[r][h] > max {
max = grid[r][h];
}
}
}
println!(" 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23");
let labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
for (r, lbl) in labels.iter().enumerate() {
print!("{:<3} ", lbl);
for h in 0..24 {
let c = grid[r][h];
let ch = if max == 0 {
' '
} else {
let idx = (c.saturating_mul(ramp.len() - 1)) / max;
ramp[idx] as char
};
print!(" {}", ch);
}
println!();
}
println!(" 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23");
}
pub fn render_calendar_heatmap_ascii(grid: &[Vec<usize>]) {
let ramp: &[u8] = b" .:-=+*#%@"; let mut max = 0usize;
for r in 0..7 {
for c in 0..grid[0].len() {
if grid[r][c] > max {
max = grid[r][c];
}
}
}
let labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
for r in 0..7 {
print!("{:<3} ", labels[r]);
for c in 0..grid[0].len() {
let v = grid[r][c];
let ch = if max == 0 {
' '
} else {
let idx = (v.saturating_mul(ramp.len() - 1)) / max;
ramp[idx] as char
};
print!(" {}", ch);
}
println!();
}
print!(" ");
for _ in 0..grid[0].len() {
print!("^");
}
println!();
}
fn color_for_level(level: usize) -> &'static str {
match level {
0 => "\x1b[90m", 1 => "\x1b[94m", 2 => "\x1b[96m", 3 => "\x1b[92m", 4 => "\x1b[93m", _ => "\x1b[91m", }
}
const ANSI_RESET: &str = "\x1b[0m";
fn print_ramp_legend(color: bool, unit: &str) {
if color {
print!("\x1b[90mLegend (low→high, blank=0 {}):\x1b[0m ", unit);
for lvl in 1..=5 {
print!(" {}█{}", color_for_level(lvl), ANSI_RESET);
}
println!();
} else {
let ramp = " .:-=+*#%@";
println!(
"Legend (low→high, blank=' ' 0 {}): {}",
unit, ramp
);
}
}
pub fn render_timeline_bars_colored(counts: &[usize], color: bool) {
if !color {
render_timeline_bars(counts);
return;
}
let ramp: &[char] = &[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; let max = counts.iter().copied().max().unwrap_or(0);
if max == 0 {
println!("(no commits in selected window)");
return;
}
let mut out = String::with_capacity(counts.len() * 6);
for &c in counts {
let idx = (c.saturating_mul(ramp.len() - 1)) / max; let color_level = if idx == 0 { 0 } else { ((idx - 1) * 5) / (ramp.len() - 2) };
out.push_str(color_for_level(color_level));
out.push(ramp[idx]);
}
out.push_str(ANSI_RESET);
println!("{}", out);
}
pub fn render_timeline_multiline(counts: &[usize], height: usize, color: bool) {
let h = height.max(1);
let max = counts.iter().copied().max().unwrap_or(0);
if max == 0 || counts.is_empty() {
println!("(no commits in selected window)");
return;
}
let top_label = max;
let mid_label = (max + 1) / 2;
let bottom_label = 0usize;
let label_width = top_label.to_string().len().max(3);
let axis_char = if color { '│' } else { '|' };
let dim_start = if color { "\x1b[90m" } else { "" };
let dim_end = if color { "\x1b[0m" } else { "" };
for row in (1..=h).rev() {
let label_val = if row == h {
Some(top_label)
} else if row == ((h + 1) / 2) {
Some(mid_label)
} else if row == 1 {
Some(bottom_label)
} else {
None
};
let mut line = String::with_capacity(label_width + 2);
match label_val {
Some(v) => {
if color {
line.push_str(dim_start);
}
line.push_str(&format!("{:>width$} {}", v, axis_char, width = label_width));
if color {
line.push_str(dim_end);
}
}
None => {
if color {
line.push_str(dim_start);
}
line.push_str(&format!("{:>width$} {}", "", axis_char, width = label_width));
if color {
line.push_str(dim_end);
}
}
}
let mut bars = String::with_capacity(counts.len() * 6);
for &c in counts {
let filled = ((c as usize) * h + max - 1) / max; if filled >= row {
if color {
let idx = if c == 0 { 0 } else { ((c - 1) as usize * 5) / max + 1 };
bars.push_str(color_for_level(idx));
bars.push('█');
} else {
bars.push('#');
}
} else {
bars.push(' ');
}
}
if color {
bars.push_str(ANSI_RESET);
}
println!("{}{}", line, bars);
}
}
fn render_timeline_axis(weeks: usize, color: bool) {
if weeks == 0 {
return;
}
let mut ticks = vec![' '; weeks];
for col in 0..weeks {
let rel = weeks - 1 - col;
if rel % 12 == 0 {
ticks[col] = if color { '┼' } else { '+' };
} else if rel % 4 == 0 {
ticks[col] = if color { '│' } else { '|' };
}
}
if color {
print!("\x1b[90m"); }
println!("{}", ticks.iter().collect::<String>());
let mut labels = vec![' '; weeks];
let label_color_start = if color { "\x1b[90m" } else { "" };
let label_color_end = if color { "\x1b[0m" } else { "" };
let mut occupied = vec![false; weeks];
for col in 0..weeks {
let rel = weeks - 1 - col;
if rel % 12 == 0 {
let s = rel.to_string();
if col + s.len() <= weeks
&& (col..col + s.len()).all(|i| !occupied[i])
{
for (i, ch) in s.chars().enumerate() {
labels[col + i] = ch;
occupied[col + i] = true;
}
}
}
}
print!("{}", label_color_start);
println!("{}", labels.iter().collect::<String>());
if color {
print!("{}", label_color_end);
}
}
pub fn render_heatmap_ascii_colored(grid: [[usize; 24]; 7], color: bool) {
if !color {
render_heatmap_ascii(grid);
return;
}
let mut max = 0usize;
for r in 0..7 {
for h in 0..24 {
if grid[r][h] > max {
max = grid[r][h];
}
}
}
println!(" 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23");
let labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
for (r, lbl) in labels.iter().enumerate() {
print!("{:<3} ", lbl);
for h in 0..24 {
let c = grid[r][h];
if max == 0 || c == 0 {
print!(" ");
} else {
let idx = ((c - 1) * 5) / max + 1; let code = color_for_level(idx);
print!(" {}█{}", code, ANSI_RESET);
}
}
println!();
}
println!(" 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23");
}
pub fn render_calendar_heatmap_colored(grid: &[Vec<usize>]) {
let mut max = 0usize;
for r in 0..7 {
for c in 0..grid[0].len() {
if grid[r][c] > max {
max = grid[r][c];
}
}
}
let labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
for r in 0..7 {
print!("{:<3} ", labels[r]);
for c in 0..grid[0].len() {
let v = grid[r][c];
if max == 0 || v == 0 {
print!(" ");
} else {
let idx = ((v - 1) * 5) / max + 1; let code = color_for_level(idx);
print!(" {}█{}", code, ANSI_RESET);
}
}
println!();
}
print!(" ");
for _ in 0..grid[0].len() {
print!("^");
}
println!();
}
pub fn run_timeline_with_options(weeks: usize, color: bool) -> Result<(), String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("clock error: {e}"))?
.as_secs();
let ts = collect_commit_timestamps()?;
let counts = compute_timeline_weeks(&ts, weeks, now);
println!("Weekly commits (old -> new), weeks={weeks}:");
let max = counts.iter().copied().max().unwrap_or(0);
let mid = (max + 1) / 2;
if color { print!("\x1b[90m"); }
println!("Y-axis: commits/week (max={}, mid≈{})", max, mid);
if color { print!("\x1b[0m"); }
print_ramp_legend(color, "commits/week");
render_timeline_multiline(&counts, 7, color);
render_timeline_axis(weeks, color);
Ok(())
}
pub fn run_timeline(weeks: usize) -> Result<(), String> {
run_timeline_with_options(weeks, false)
}
pub fn run_heatmap_with_options(weeks: Option<usize>, color: bool) -> Result<(), String> {
let ts_all = collect_commit_timestamps()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("clock error: {e}"))?
.as_secs();
let w = weeks.unwrap_or(52);
let grid = compute_calendar_heatmap(&ts_all, w, now);
let mut max = 0usize;
for r in 0..7 {
for c in 0..grid[0].len() {
if grid[r][c] > max {
max = grid[r][c];
}
}
}
if color { print!("\x1b[90m"); }
println!("Calendar heatmap (UTC) — rows: Sun..Sat, cols: weeks (old→new), unit: commits/day, window: last {} weeks, max={}", w, max);
if color { print!("\x1b[0m"); }
print_ramp_legend(color, "commits/day");
if color {
render_calendar_heatmap_colored(&grid);
} else {
render_calendar_heatmap_ascii(&grid);
}
Ok(())
}
pub fn run_heatmap() -> Result<(), String> {
run_heatmap_with_options(None, false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
use std::sync::{Mutex, OnceLock, MutexGuard};
static TEST_DIR_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
struct TempRepo {
_guard: MutexGuard<'static, ()>,
old_dir: PathBuf,
path: PathBuf,
}
impl TempRepo {
fn new(prefix: &str) -> Self {
let guard = TEST_DIR_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner());
let old_dir = env::current_dir().unwrap();
let base = env::temp_dir();
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = base.join(format!("{}-{}", prefix, ts));
fs::create_dir_all(&path).unwrap();
env::set_current_dir(&path).unwrap();
assert!(
Command::new("git")
.args(["--no-pager", "init", "-q"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success()
);
assert!(
Command::new("git")
.args(["config", "commit.gpgsign", "false"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success()
);
assert!(
Command::new("git")
.args(["config", "core.hooksPath", "/dev/null"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success()
);
assert!(
Command::new("git")
.args(["config", "user.name", "Test"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success()
);
assert!(
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success()
);
fs::write("INIT", "init\n").unwrap();
let _ = Command::new("git")
.args(["--no-pager", "add", "."])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let mut c = Command::new("git");
c.args(["-c", "commit.gpgsign=false"])
.arg("--no-pager")
.arg("commit")
.arg("--no-verify")
.arg("-q")
.arg("-m")
.arg("chore: init");
c.env("GIT_AUTHOR_NAME", "Init");
c.env("GIT_AUTHOR_EMAIL", "init@example.com");
c.env("GIT_COMMITTER_NAME", "Init");
c.env("GIT_COMMITTER_EMAIL", "init@example.com");
c.stdout(Stdio::null()).stderr(Stdio::null());
assert!(c.status().unwrap().success());
Self { _guard: guard, old_dir, path }
}
fn commit_with_epoch(&self, name: &str, email: &str, file: &str, content: &str, ts: u64) {
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(file)
.unwrap();
f.write_all(content.as_bytes()).unwrap();
let add_ok = Command::new("git")
.args(["add", "."])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
|| Command::new("git")
.args(["add", "-A", "."])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(add_ok, "git add failed in TempRepo::commit_with_epoch");
let mut c = Command::new("git");
c.args(["-c", "commit.gpgsign=false"])
.args(["-c", "core.hooksPath=/dev/null"])
.args(["-c", "user.name=Test"])
.args(["-c", "user.email=test@example.com"])
.arg("commit")
.arg("--no-verify")
.arg("-q")
.arg("--allow-empty")
.arg("-m")
.arg("test");
let date = format!("{ts} +0000");
c.env("GIT_AUTHOR_NAME", name);
c.env("GIT_AUTHOR_EMAIL", email);
c.env("GIT_COMMITTER_NAME", name);
c.env("GIT_COMMITTER_EMAIL", email);
c.env("GIT_AUTHOR_DATE", &date);
c.env("GIT_COMMITTER_DATE", &date);
c.stdout(Stdio::null()).stderr(Stdio::null());
assert!(c.status().unwrap().success());
}
}
impl Drop for TempRepo {
fn drop(&mut self) {
let _ = env::set_current_dir(&self.old_dir);
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn test_compute_timeline_weeks_simple_bins() {
let week = 604_800u64;
let now = 10 * week; let ts = vec![
now - (0 * week) + 1, now - (1 * week) + 2, now - (1 * week) + 3, now - (3 * week), ];
let counts = compute_timeline_weeks(&ts, 4, now);
assert_eq!(counts, vec![1, 0, 2, 1]);
}
#[test]
fn test_compute_heatmap_utc_known_points() {
let sun_00 = 3 * 86_400;
let sun_13 = sun_00 + 13 * 3_600;
let mon_05 = 4 * 86_400 + 5 * 3_600;
let grid = compute_heatmap_utc(&[sun_00, sun_13, mon_05]);
assert_eq!(grid[0][0], 1); assert_eq!(grid[0][13], 1); assert_eq!(grid[1][5], 1); }
#[test]
fn test_render_timeline_no_panic() {
render_timeline_bars(&[0, 1, 2, 3, 0, 5, 5, 1]);
render_timeline_bars(&[]);
render_timeline_bars(&[0, 0, 0]);
}
#[test]
fn test_render_heatmap_no_panic() {
let mut grid = [[0usize; 24]; 7];
grid[0][0] = 1;
grid[6][23] = 5;
render_heatmap_ascii(grid);
}
#[test]
#[ignore]
fn test_collect_commit_timestamps_from_temp_repo() {
let repo = TempRepo::new("git-insights-vis");
let t1 = 1_696_118_400u64; let t2 = 1_696_204_800u64;
repo.commit_with_epoch("Alice", "alice@example.com", "a.txt", "a\n", t1);
repo.commit_with_epoch("Bob", "bob@example.com", "a.txt", "b\n", t2);
let ts = collect_commit_timestamps().expect("collect timestamps");
assert!(ts.iter().any(|&x| x == t1), "missing t1");
assert!(ts.iter().any(|&x| x == t2), "missing t2");
}
#[test]
#[ignore]
fn test_run_timeline_and_heatmap_end_to_end() {
let repo = TempRepo::new("git-insights-vis-run");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let t_now = now - (now % 86_400); repo.commit_with_epoch("X", "x@example.com", "x.txt", "x\n", t_now);
repo.commit_with_epoch("Y", "y@example.com", "x.txt", "y\n", t_now + 3_600);
run_timeline(4).expect("timeline ok");
run_heatmap().expect("heatmap ok");
}
#[test]
fn test_compute_calendar_heatmap_bins() {
const DAY: u64 = 86_400;
const WEEK: u64 = 7 * DAY;
let now = 10 * WEEK;
let start_of_week = now - (now % WEEK);
let aligned_end = start_of_week + WEEK - 1;
let t_curr1 = aligned_end - (1 * DAY); let t_curr2 = aligned_end - (2 * DAY);
let t_prev1 = aligned_end - (8 * DAY); let ts = vec![t_curr1, t_curr2, t_prev1];
let grid = super::compute_calendar_heatmap(&ts, 2, now);
assert_eq!(grid.len(), 7);
assert_eq!(grid[0].len(), 2);
let mut col0 = 0usize;
let mut col1 = 0usize;
for r in 0..7 {
col0 += grid[r][0];
col1 += grid[r][1];
}
assert_eq!(col0, 1, "older week should have 1 commit");
assert_eq!(col1, 2, "current week should have 2 commits");
}
#[test]
fn test_render_calendar_heatmap_no_panic() {
let mut grid = vec![vec![0usize; 4]; 7];
grid[0][0] = 1;
grid[1][1] = 2;
grid[2][2] = 3;
grid[3][3] = 4;
super::render_calendar_heatmap_ascii(&grid);
super::render_calendar_heatmap_colored(&grid);
}
#[test]
fn test_print_legends_no_panic() {
super::print_ramp_legend(false, "commits/week");
super::print_ramp_legend(true, "commits/day");
}
}