git_insights/
visualize.rs

1use crate::git::run_command;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4/// Collect commit timestamps (unix epoch seconds) in reverse chronological order.
5/// Uses clean git invocation with --no-pager and no merges.
6pub fn collect_commit_timestamps() -> Result<Vec<u64>, String> {
7    let out = run_command(&["--no-pager", "log", "--no-merges", "--format=%ct"])?;
8    let mut ts: Vec<u64> = Vec::new();
9    for line in out.lines() {
10        if let Ok(v) = line.trim().parse::<u64>() {
11            ts.push(v);
12        }
13    }
14    Ok(ts)
15}
16
17/// Bucket commit timestamps into week bins (7-day windows) ending at `now`.
18/// Returns `weeks` bins, oldest -> newest (counts.len() == weeks).
19pub fn compute_timeline_weeks(timestamps: &[u64], weeks: usize, now: u64) -> Vec<usize> {
20    let mut counts = vec![0usize; weeks];
21    if weeks == 0 {
22        return counts;
23    }
24    const WEEK: u64 = 7 * 24 * 60 * 60; // 604800
25
26    // Align to the end of the current epoch-week so bins are week-aligned, not relative to "now".
27    // Current week is [start_of_week .. start_of_week+WEEK-1]; use that end boundary.
28    let start_of_week = now - (now % WEEK);
29    let aligned_end = start_of_week.saturating_add(WEEK - 1);
30
31    for &t in timestamps {
32        if t > aligned_end {
33            continue;
34        }
35        let diff = aligned_end - t;
36        let bin = (diff / WEEK) as usize;
37        if bin < weeks {
38            // newest bin is at the end
39            let idx = weeks - 1 - bin;
40            counts[idx] += 1;
41        }
42    }
43    counts
44}
45
46/// Compute a 7x24 (weekday x hour) heatmap in UTC (kept for internal/tests).
47/// Weekday index: 0=Sun,1=Mon,...,6=Sat
48pub fn compute_heatmap_utc(timestamps: &[u64]) -> [[usize; 24]; 7] {
49    let mut grid = [[0usize; 24]; 7];
50    for &t in timestamps {
51        let day = t / 86_400;
52        // 1970-01-01 was a Thursday. With 0=Sun..6=Sat, Thursday corresponds to 4.
53        let weekday = ((day + 4) % 7) as usize;
54        let hour = ((t / 3_600) % 24) as usize;
55        grid[weekday][hour] += 1;
56    }
57    grid
58}
59
60/// Compute a GitHub-style calendar heatmap (weekday x week-column).
61/// Returns grid[7][weeks] as Vec<Vec<usize>> with rows=Sun..Sat, cols=old->new (weeks).
62pub fn compute_calendar_heatmap(timestamps: &[u64], weeks: usize, now: u64) -> Vec<Vec<usize>> {
63    let mut grid = vec![vec![0usize; weeks]; 7];
64    if weeks == 0 {
65        return grid;
66    }
67    const DAY: u64 = 86_400;
68    const WEEK: u64 = DAY * 7;
69
70    // Align to end of current week
71    let start_of_week = now - (now % WEEK);
72    let aligned_end = start_of_week.saturating_add(WEEK - 1);
73    let span = (weeks as u64).saturating_mul(WEEK);
74    let min_ts = aligned_end.saturating_sub(span.saturating_sub(1));
75
76    for &t in timestamps {
77        if t > aligned_end || t < min_ts {
78            continue;
79        }
80        let day_index = (aligned_end - t) / DAY;      // 0.. spanning days
81        let week_off = (day_index / 7) as usize;      // 0 = current week
82        if week_off >= weeks {
83            continue;
84        }
85        let col = weeks - 1 - week_off;               // oldest..newest left->right
86        let day = t / DAY;
87        let weekday = ((day + 4) % 7) as usize;       // 0=Sun..6=Sat
88        grid[weekday][col] += 1;
89    }
90    grid
91}
92
93/// Render a compact single-line timeline using an ASCII ramp per bin.
94/// Uses a small 10-char ramp to visualize relative intensity within the provided counts.
95pub fn render_timeline_bars(counts: &[usize]) {
96    let ramp: &[u8] = b" .:-=+*#%@"; // 10 levels
97    let max = counts.iter().copied().max().unwrap_or(0);
98    if max == 0 {
99        println!("(no commits in selected window)");
100        return;
101    }
102    let mut line = String::with_capacity(counts.len());
103    for &c in counts {
104        let idx = (c.saturating_mul(ramp.len() - 1)) / max;
105        line.push(ramp[idx] as char);
106    }
107    println!("{}", line);
108}
109
110/// Render a 7x24 heatmap using an ASCII ramp. 0=Sun ... 6=Sat as rows.
111/// Header shows hours 00..23; each cell is a character denoting relative intensity.
112pub fn render_heatmap_ascii(grid: [[usize; 24]; 7]) {
113    let ramp: &[u8] = b" .:-=+*#%@"; // 10 levels
114    // Find global max for scaling
115    let mut max = 0usize;
116    for r in 0..7 {
117        for h in 0..24 {
118            if grid[r][h] > max {
119                max = grid[r][h];
120            }
121        }
122    }
123    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");
124    let labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
125    for (r, lbl) in labels.iter().enumerate() {
126        print!("{:<3} ", lbl);
127        for h in 0..24 {
128            let c = grid[r][h];
129            let ch = if max == 0 {
130                ' '
131            } else {
132                let idx = (c.saturating_mul(ramp.len() - 1)) / max;
133                ramp[idx] as char
134            };
135            print!(" {}", ch);
136        }
137        println!();
138    }
139    // Bottom hour axis for reference
140    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");
141}
142
143/// Render GitHub-style calendar heatmap (ASCII ramp)
144pub fn render_calendar_heatmap_ascii(grid: &[Vec<usize>]) {
145    let ramp: &[u8] = b" .:-=+*#%@"; // 10 levels
146    // global max
147    let mut max = 0usize;
148    for r in 0..7 {
149        for c in 0..grid[0].len() {
150            if grid[r][c] > max {
151                max = grid[r][c];
152            }
153        }
154    }
155    let labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
156    for r in 0..7 {
157        print!("{:<3} ", labels[r]);
158        for c in 0..grid[0].len() {
159            let v = grid[r][c];
160            let ch = if max == 0 {
161                ' '
162            } else {
163                let idx = (v.saturating_mul(ramp.len() - 1)) / max;
164                ramp[idx] as char
165            };
166            print!(" {}", ch);
167        }
168        println!();
169    }
170    // bottom reference: week columns count
171    print!("     ");
172    for _ in 0..grid[0].len() {
173        print!("^");
174    }
175    println!();
176}
177
178fn color_for_level(level: usize) -> &'static str {
179    // Simple 6-step ANSI 8-color ramp (foreground)
180    match level {
181        0 => "\x1b[90m", // bright black / low intensity
182        1 => "\x1b[94m", // blue
183        2 => "\x1b[96m", // cyan
184        3 => "\x1b[92m", // green
185        4 => "\x1b[93m", // yellow
186        _ => "\x1b[91m", // red (highest)
187    }
188}
189const ANSI_RESET: &str = "\x1b[0m";
190
191/// Print a color/ASCII legend showing low→high intensity and the meaning of blank.
192fn print_ramp_legend(color: bool, unit: &str) {
193    if color {
194        // Levels 1..5 colored blocks; blank = 0
195        print!("\x1b[90mLegend (low→high, blank=0 {}):\x1b[0m ", unit);
196        for lvl in 1..=5 {
197            print!(" {}█{}", color_for_level(lvl), ANSI_RESET);
198        }
199        println!();
200    } else {
201        let ramp = " .:-=+*#%@";
202        println!(
203            "Legend (low→high, blank=' ' 0 {}): {}",
204            unit, ramp
205        );
206    }
207}
208
209/// Render timeline as Unicode bars with optional color.
210/// Uses unicode ramp " ▁▂▃▄▅▆▇█" (9 levels) + color ramp.
211pub fn render_timeline_bars_colored(counts: &[usize], color: bool) {
212    if !color {
213        render_timeline_bars(counts);
214        return;
215    }
216    let ramp: &[char] = &[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; // 9 levels
217    let max = counts.iter().copied().max().unwrap_or(0);
218    if max == 0 {
219        println!("(no commits in selected window)");
220        return;
221    }
222    let mut out = String::with_capacity(counts.len() * 6);
223    for &c in counts {
224        let idx = (c.saturating_mul(ramp.len() - 1)) / max; // 0..=8
225        // map intensity 0..=8 to 0..=5 color levels
226        let color_level = if idx == 0 { 0 } else { ((idx - 1) * 5) / (ramp.len() - 2) };
227        out.push_str(color_for_level(color_level));
228        out.push(ramp[idx]);
229    }
230    out.push_str(ANSI_RESET);
231    println!("{}", out);
232}
233
234/// Render timeline as multi-row bars with optional color.
235/// height must be >= 1. Uses '█' for color mode and '#' for ASCII.
236pub fn render_timeline_multiline(counts: &[usize], height: usize, color: bool) {
237    let h = height.max(1);
238    let max = counts.iter().copied().max().unwrap_or(0);
239    if max == 0 || counts.is_empty() {
240        println!("(no commits in selected window)");
241        return;
242    }
243
244    // Y-axis reference: show labels at top (max), middle (~max/2), and bottom (0)
245    let top_label = max;
246    let mid_label = (max + 1) / 2;
247    let bottom_label = 0usize;
248    let label_width = top_label.to_string().len().max(3);
249    let axis_char = if color { '│' } else { '|' };
250    let dim_start = if color { "\x1b[90m" } else { "" };
251    let dim_end = if color { "\x1b[0m" } else { "" };
252
253    for row in (1..=h).rev() {
254        // Determine label for this row
255        let label_val = if row == h {
256            Some(top_label)
257        } else if row == ((h + 1) / 2) {
258            Some(mid_label)
259        } else if row == 1 {
260            Some(bottom_label)
261        } else {
262            None
263        };
264
265        // Build left y-axis prefix " 123 |"
266        let mut line = String::with_capacity(label_width + 2);
267        match label_val {
268            Some(v) => {
269                if color {
270                    line.push_str(dim_start);
271                }
272                line.push_str(&format!("{:>width$} {}", v, axis_char, width = label_width));
273                if color {
274                    line.push_str(dim_end);
275                }
276            }
277            None => {
278                if color {
279                    line.push_str(dim_start);
280                }
281                line.push_str(&format!("{:>width$} {}", "", axis_char, width = label_width));
282                if color {
283                    line.push_str(dim_end);
284                }
285            }
286        }
287
288        // Build bars for this row
289        let mut bars = String::with_capacity(counts.len() * 6);
290        for &c in counts {
291            let filled = ((c as usize) * h + max - 1) / max; // ceil to 1..=h
292            if filled >= row {
293                if color {
294                    // map count to 0..=5 color level
295                    let idx = if c == 0 { 0 } else { ((c - 1) as usize * 5) / max + 1 };
296                    bars.push_str(color_for_level(idx));
297                    bars.push('█');
298                } else {
299                    bars.push('#');
300                }
301            } else {
302                bars.push(' ');
303            }
304        }
305        if color {
306            bars.push_str(ANSI_RESET);
307        }
308
309        // Print y-axis + bars
310        println!("{}{}", line, bars);
311    }
312}
313
314/// Render a compact reference axis below the timeline:
315/// - Minor ticks every 4 weeks
316/// - Major ticks every 12 weeks (labeled with remaining weeks from newest: 48,36,24,12,0)
317fn render_timeline_axis(weeks: usize, color: bool) {
318    if weeks == 0 {
319        return;
320    }
321    // Ticks line
322    let mut ticks = vec![' '; weeks];
323    for col in 0..weeks {
324        // rel=0 at newest (rightmost), rel=weeks-1 at oldest (leftmost)
325        let rel = weeks - 1 - col;
326        if rel % 12 == 0 {
327            ticks[col] = if color { '┼' } else { '+' };
328        } else if rel % 4 == 0 {
329            ticks[col] = if color { '│' } else { '|' };
330        }
331    }
332    if color {
333        print!("\x1b[90m"); // dim
334    }
335    println!("{}", ticks.iter().collect::<String>());
336    // Labels line (major ticks only). Place numbers without overlaps.
337    let mut labels = vec![' '; weeks];
338    let label_color_start = if color { "\x1b[90m" } else { "" };
339    let label_color_end = if color { "\x1b[0m" } else { "" };
340
341    let mut occupied = vec![false; weeks];
342    for col in 0..weeks {
343        let rel = weeks - 1 - col;
344        if rel % 12 == 0 {
345            let s = rel.to_string();
346            // avoid overlap: ensure s fits starting at `col`
347            if col + s.len() <= weeks
348                && (col..col + s.len()).all(|i| !occupied[i])
349            {
350                // write digits
351                for (i, ch) in s.chars().enumerate() {
352                    labels[col + i] = ch;
353                    occupied[col + i] = true;
354                }
355            }
356        }
357    }
358    print!("{}", label_color_start);
359    println!("{}", labels.iter().collect::<String>());
360    if color {
361        print!("{}", label_color_end);
362    }
363}
364
365/// Render heatmap with optional color using '█' blocks (space for zero).
366pub fn render_heatmap_ascii_colored(grid: [[usize; 24]; 7], color: bool) {
367    if !color {
368        render_heatmap_ascii(grid);
369        return;
370    }
371    // global max for scaling
372    let mut max = 0usize;
373    for r in 0..7 {
374        for h in 0..24 {
375            if grid[r][h] > max {
376                max = grid[r][h];
377            }
378        }
379    }
380    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");
381    let labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
382    for (r, lbl) in labels.iter().enumerate() {
383        print!("{:<3} ", lbl);
384        for h in 0..24 {
385            let c = grid[r][h];
386            if max == 0 || c == 0 {
387                print!("  ");
388            } else {
389                // build 6 buckets for color
390                let idx = ((c - 1) * 5) / max + 1; // 1..=5 approx
391                let code = color_for_level(idx);
392                print!(" {}█{}", code, ANSI_RESET);
393            }
394        }
395        println!();
396    }
397    // Bottom hour axis for reference
398    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");
399}
400
401/// Render GitHub-style calendar heatmap (colored)
402pub fn render_calendar_heatmap_colored(grid: &[Vec<usize>]) {
403    // global max
404    let mut max = 0usize;
405    for r in 0..7 {
406        for c in 0..grid[0].len() {
407            if grid[r][c] > max {
408                max = grid[r][c];
409            }
410        }
411    }
412    let labels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
413    for r in 0..7 {
414        print!("{:<3} ", labels[r]);
415        for c in 0..grid[0].len() {
416            let v = grid[r][c];
417            if max == 0 || v == 0 {
418                print!("  ");
419            } else {
420                let idx = ((v - 1) * 5) / max + 1; // 1..=5 approx
421                let code = color_for_level(idx);
422                print!(" {}█{}", code, ANSI_RESET);
423            }
424        }
425        println!();
426    }
427    // bottom week columns
428    print!("     ");
429    for _ in 0..grid[0].len() {
430        print!("^");
431    }
432    println!();
433}
434
435/// Run the timeline visualization with options.
436pub fn run_timeline_with_options(weeks: usize, color: bool) -> Result<(), String> {
437    let now = SystemTime::now()
438        .duration_since(UNIX_EPOCH)
439        .map_err(|e| format!("clock error: {e}"))?
440        .as_secs();
441    let ts = collect_commit_timestamps()?;
442    let counts = compute_timeline_weeks(&ts, weeks, now);
443    println!("Weekly commits (old -> new), weeks={weeks}:");
444    // Print Y-axis unit/scale reference
445    let max = counts.iter().copied().max().unwrap_or(0);
446    let mid = (max + 1) / 2;
447    if color { print!("\x1b[90m"); }
448    println!("Y-axis: commits/week (max={}, mid≈{})", max, mid);
449    if color { print!("\x1b[0m"); }
450    print_ramp_legend(color, "commits/week");
451    // Default to a 7-line tall chart for better readability without flooding the screen
452    render_timeline_multiline(&counts, 7, color);
453    // Add axis reference (minor tick=4 weeks, major tick=12 weeks)
454    render_timeline_axis(weeks, color);
455    Ok(())
456}
457
458/// Run the timeline visualization end-to-end with default `weeks` if needed.
459pub fn run_timeline(weeks: usize) -> Result<(), String> {
460    run_timeline_with_options(weeks, false)
461}
462
463
464/// Run the heatmap visualization with options.
465pub fn run_heatmap_with_options(weeks: Option<usize>, color: bool) -> Result<(), String> {
466    let ts_all = collect_commit_timestamps()?;
467    let now = SystemTime::now()
468        .duration_since(UNIX_EPOCH)
469        .map_err(|e| format!("clock error: {e}"))?
470        .as_secs();
471
472    // Default to 52 weeks if not specified to keep a reasonable width like GitHub
473    let w = weeks.unwrap_or(52);
474    let grid = compute_calendar_heatmap(&ts_all, w, now);
475
476    // Unit and window line
477    let mut max = 0usize;
478    for r in 0..7 {
479        for c in 0..grid[0].len() {
480            if grid[r][c] > max {
481                max = grid[r][c];
482            }
483        }
484    }
485    if color { print!("\x1b[90m"); }
486    println!("Calendar heatmap (UTC) — rows: Sun..Sat, cols: weeks (old→new), unit: commits/day, window: last {} weeks, max={}", w, max);
487    if color { print!("\x1b[0m"); }
488    print_ramp_legend(color, "commits/day");
489
490    if color {
491        render_calendar_heatmap_colored(&grid);
492    } else {
493        render_calendar_heatmap_ascii(&grid);
494    }
495    Ok(())
496}
497
498/// Run the heatmap visualization end-to-end.
499pub fn run_heatmap() -> Result<(), String> {
500    run_heatmap_with_options(None, false)
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use std::env;
507    use std::fs;
508    use std::io::Write;
509    use std::path::PathBuf;
510    use std::process::{Command, Stdio};
511    use std::time::{SystemTime, UNIX_EPOCH};
512    use std::sync::{Mutex, OnceLock, MutexGuard};
513
514    static TEST_DIR_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
515
516    // Simple temp repo that lives under OS temp dir and is cleaned up on Drop.
517    struct TempRepo {
518        _guard: MutexGuard<'static, ()>,
519        old_dir: PathBuf,
520        path: PathBuf,
521    }
522
523    impl TempRepo {
524        fn new(prefix: &str) -> Self {
525            // Serialize temp repo creation and chdir to avoid races across parallel tests
526            let guard = TEST_DIR_LOCK
527                .get_or_init(|| Mutex::new(()))
528                .lock()
529                .unwrap_or_else(|e| e.into_inner());
530
531            let old_dir = env::current_dir().unwrap();
532            let base = env::temp_dir();
533            let ts = SystemTime::now()
534                .duration_since(UNIX_EPOCH)
535                .unwrap()
536                .as_nanos();
537            let path = base.join(format!("{}-{}", prefix, ts));
538            fs::create_dir_all(&path).unwrap();
539            env::set_current_dir(&path).unwrap();
540
541            assert!(
542                Command::new("git")
543                    .args(["--no-pager", "init", "-q"])
544                    .stdout(Stdio::null())
545                    .stderr(Stdio::null())
546                    .status()
547                    .unwrap()
548                    .success()
549            );
550            // Keep commands clean
551            assert!(
552                Command::new("git")
553                    .args(["config", "commit.gpgsign", "false"])
554                    .stdout(Stdio::null())
555                    .stderr(Stdio::null())
556                    .status()
557                    .unwrap()
558                    .success()
559            );
560            assert!(
561                Command::new("git")
562                    .args(["config", "core.hooksPath", "/dev/null"])
563                    .stdout(Stdio::null())
564                    .stderr(Stdio::null())
565                    .status()
566                    .unwrap()
567                    .success()
568            );
569            assert!(
570                Command::new("git")
571                    .args(["config", "user.name", "Test"])
572                    .stdout(Stdio::null())
573                    .stderr(Stdio::null())
574                    .status()
575                    .unwrap()
576                    .success()
577            );
578            assert!(
579                Command::new("git")
580                    .args(["config", "user.email", "test@example.com"])
581                    .stdout(Stdio::null())
582                    .stderr(Stdio::null())
583                    .status()
584                    .unwrap()
585                    .success()
586            );
587
588            // Initial file and commit (for a valid repo)
589            fs::write("INIT", "init\n").unwrap();
590            let _ = Command::new("git")
591                .args(["--no-pager", "add", "."])
592                .stdout(Stdio::null())
593                .stderr(Stdio::null())
594                .status();
595
596            let mut c = Command::new("git");
597            c.args(["-c", "commit.gpgsign=false"])
598                .arg("--no-pager")
599                .arg("commit")
600                .arg("--no-verify")
601                .arg("-q")
602                .arg("-m")
603                .arg("chore: init");
604            c.env("GIT_AUTHOR_NAME", "Init");
605            c.env("GIT_AUTHOR_EMAIL", "init@example.com");
606            c.env("GIT_COMMITTER_NAME", "Init");
607            c.env("GIT_COMMITTER_EMAIL", "init@example.com");
608            c.stdout(Stdio::null()).stderr(Stdio::null());
609            assert!(c.status().unwrap().success());
610
611            Self { _guard: guard, old_dir, path }
612        }
613
614        fn commit_with_epoch(&self, name: &str, email: &str, file: &str, content: &str, ts: u64) {
615            // write/append file
616            let mut f = fs::OpenOptions::new()
617                .create(true)
618                .append(true)
619                .open(file)
620                .unwrap();
621            f.write_all(content.as_bytes()).unwrap();
622
623            // add and commit with explicit dates
624            let add_ok = Command::new("git")
625                .args(["add", "."])
626                .stdout(Stdio::null())
627                .stderr(Stdio::null())
628                .status()
629                .map(|s| s.success())
630                .unwrap_or(false)
631                || Command::new("git")
632                    .args(["add", "-A", "."])
633                    .stdout(Stdio::null())
634                    .stderr(Stdio::null())
635                    .status()
636                    .map(|s| s.success())
637                    .unwrap_or(false);
638            assert!(add_ok, "git add failed in TempRepo::commit_with_epoch");
639
640            let mut c = Command::new("git");
641            c.args(["-c", "commit.gpgsign=false"])
642                .args(["-c", "core.hooksPath=/dev/null"])
643                .args(["-c", "user.name=Test"])
644                .args(["-c", "user.email=test@example.com"])
645                .arg("commit")
646                .arg("--no-verify")
647                .arg("-q")
648                .arg("--allow-empty")
649                .arg("-m")
650                .arg("test");
651            let date = format!("{ts} +0000");
652            c.env("GIT_AUTHOR_NAME", name);
653            c.env("GIT_AUTHOR_EMAIL", email);
654            c.env("GIT_COMMITTER_NAME", name);
655            c.env("GIT_COMMITTER_EMAIL", email);
656            c.env("GIT_AUTHOR_DATE", &date);
657            c.env("GIT_COMMITTER_DATE", &date);
658            c.stdout(Stdio::null()).stderr(Stdio::null());
659            assert!(c.status().unwrap().success());
660        }
661    }
662
663    impl Drop for TempRepo {
664        fn drop(&mut self) {
665            let _ = env::set_current_dir(&self.old_dir);
666            let _ = fs::remove_dir_all(&self.path);
667        }
668    }
669
670    #[test]
671    fn test_compute_timeline_weeks_simple_bins() {
672        // Choose a fixed "now" and create timestamps in two recent weeks.
673        let week = 604_800u64;
674        let now = 10 * week; // arbitrary multiple
675        let ts = vec![
676            now - (0 * week) + 1, // this week
677            now - (1 * week) + 2, // last week
678            now - (1 * week) + 3, // last week
679            now - (3 * week),     // 3 weeks ago
680        ];
681        let counts = compute_timeline_weeks(&ts, 4, now);
682        // oldest -> newest bins: weeks=4 => [3w,2w,1w,0w]
683        // 3w: 1, 2w:0, 1w:2, 0w:1
684        assert_eq!(counts, vec![1, 0, 2, 1]);
685    }
686
687    #[test]
688    fn test_compute_heatmap_utc_known_points() {
689        // 1970-01-04 00:00:00 UTC is a Sunday 00h -> index 0, hour 0
690        let sun_00 = 3 * 86_400;
691        // 1970-01-04 13:00:00 UTC Sunday 13h
692        let sun_13 = sun_00 + 13 * 3_600;
693        // 1970-01-05 05:00:00 UTC Monday 05h -> day=4 -> ((4+4)%7)=1 (Mon)
694        let mon_05 = 4 * 86_400 + 5 * 3_600;
695        let grid = compute_heatmap_utc(&[sun_00, sun_13, mon_05]);
696        assert_eq!(grid[0][0], 1);  // Sun 00
697        assert_eq!(grid[0][13], 1); // Sun 13
698        assert_eq!(grid[1][5], 1);  // Mon 05
699    }
700
701    #[test]
702    fn test_render_timeline_no_panic() {
703        render_timeline_bars(&[0, 1, 2, 3, 0, 5, 5, 1]);
704        render_timeline_bars(&[]);
705        render_timeline_bars(&[0, 0, 0]);
706    }
707
708    #[test]
709    fn test_render_heatmap_no_panic() {
710        let mut grid = [[0usize; 24]; 7];
711        grid[0][0] = 1;
712        grid[6][23] = 5;
713        render_heatmap_ascii(grid);
714    }
715
716    #[test]
717    #[ignore]
718    fn test_collect_commit_timestamps_from_temp_repo() {
719        // Create one temp repo and keep it the current working directory
720        // while collecting timestamps.
721        let repo = TempRepo::new("git-insights-vis");
722        // two commits with known epochs
723        let t1 = 1_696_118_400u64; // 2023-10-01 00:00:00 UTC
724        let t2 = 1_696_204_800u64; // 2023-10-02 00:00:00 UTC
725
726        // Make commits in this repo
727        repo.commit_with_epoch("Alice", "alice@example.com", "a.txt", "a\n", t1);
728        repo.commit_with_epoch("Bob", "bob@example.com", "a.txt", "b\n", t2);
729
730        // Validate via our collector (runs in CWD = repo)
731        let ts = collect_commit_timestamps().expect("collect timestamps");
732        assert!(ts.iter().any(|&x| x == t1), "missing t1");
733        assert!(ts.iter().any(|&x| x == t2), "missing t2");
734    }
735
736    #[test]
737    #[ignore]
738    fn test_run_timeline_and_heatmap_end_to_end() {
739        // Create a repo and ensure both runners do not error.
740        let repo = TempRepo::new("git-insights-vis-run");
741        let now = SystemTime::now()
742            .duration_since(UNIX_EPOCH)
743            .unwrap()
744            .as_secs();
745        let t_now = now - (now % 86_400); // align to midnight
746        repo.commit_with_epoch("X", "x@example.com", "x.txt", "x\n", t_now);
747        repo.commit_with_epoch("Y", "y@example.com", "x.txt", "y\n", t_now + 3_600);
748
749        // These call git under the hood; should be fine
750        run_timeline(4).expect("timeline ok");
751        run_heatmap().expect("heatmap ok");
752    }
753
754    #[test]
755    fn test_compute_calendar_heatmap_bins() {
756        // Use a synthetic "now" for stable alignment
757        const DAY: u64 = 86_400;
758        const WEEK: u64 = 7 * DAY;
759        let now = 10 * WEEK;
760
761        // aligned_end computed same as production logic
762        let start_of_week = now - (now % WEEK);
763        let aligned_end = start_of_week + WEEK - 1;
764
765        // Place 2 commits in current week, 1 commit in previous week
766        let t_curr1 = aligned_end - (1 * DAY); // within current week
767        let t_curr2 = aligned_end - (2 * DAY);
768        let t_prev1 = aligned_end - (8 * DAY); // previous week
769        let ts = vec![t_curr1, t_curr2, t_prev1];
770
771        let grid = super::compute_calendar_heatmap(&ts, 2, now);
772        assert_eq!(grid.len(), 7);
773        assert_eq!(grid[0].len(), 2);
774
775        // Sum per column: col 0 = older week, col 1 = current week
776        let mut col0 = 0usize;
777        let mut col1 = 0usize;
778        for r in 0..7 {
779            col0 += grid[r][0];
780            col1 += grid[r][1];
781        }
782        assert_eq!(col0, 1, "older week should have 1 commit");
783        assert_eq!(col1, 2, "current week should have 2 commits");
784    }
785
786    #[test]
787    fn test_render_calendar_heatmap_no_panic() {
788        // Build a small 7 x 4 grid with increasing intensity
789        let mut grid = vec![vec![0usize; 4]; 7];
790        grid[0][0] = 1;
791        grid[1][1] = 2;
792        grid[2][2] = 3;
793        grid[3][3] = 4;
794        // Should not panic in ASCII
795        super::render_calendar_heatmap_ascii(&grid);
796        // Should not panic in "colored" version (uses ANSI)
797        super::render_calendar_heatmap_colored(&grid);
798    }
799
800    #[test]
801    fn test_print_legends_no_panic() {
802        super::print_ramp_legend(false, "commits/week");
803        super::print_ramp_legend(true, "commits/day");
804    }
805}