1use crate::git::run_command;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4pub 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
17pub 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; 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 let idx = weeks - 1 - bin;
40 counts[idx] += 1;
41 }
42 }
43 counts
44}
45
46pub 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 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
60pub 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 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; let week_off = (day_index / 7) as usize; if week_off >= weeks {
83 continue;
84 }
85 let col = weeks - 1 - week_off; let day = t / DAY;
87 let weekday = ((day + 4) % 7) as usize; grid[weekday][col] += 1;
89 }
90 grid
91}
92
93pub fn render_timeline_bars(counts: &[usize]) {
96 let ramp: &[u8] = b" .:-=+*#%@"; 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
110pub fn render_heatmap_ascii(grid: [[usize; 24]; 7]) {
113 let ramp: &[u8] = b" .:-=+*#%@"; 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 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
143pub fn render_calendar_heatmap_ascii(grid: &[Vec<usize>]) {
145 let ramp: &[u8] = b" .:-=+*#%@"; 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 print!(" ");
172 for _ in 0..grid[0].len() {
173 print!("^");
174 }
175 println!();
176}
177
178fn color_for_level(level: usize) -> &'static str {
179 match level {
181 0 => "\x1b[90m", 1 => "\x1b[94m", 2 => "\x1b[96m", 3 => "\x1b[92m", 4 => "\x1b[93m", _ => "\x1b[91m", }
188}
189const ANSI_RESET: &str = "\x1b[0m";
190
191fn print_ramp_legend(color: bool, unit: &str) {
193 if color {
194 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
209pub fn render_timeline_bars_colored(counts: &[usize], color: bool) {
212 if !color {
213 render_timeline_bars(counts);
214 return;
215 }
216 let ramp: &[char] = &[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; 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; 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
234pub 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 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 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 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 let mut bars = String::with_capacity(counts.len() * 6);
290 for &c in counts {
291 let filled = ((c as usize) * h + max - 1) / max; if filled >= row {
293 if color {
294 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 println!("{}{}", line, bars);
311 }
312}
313
314fn render_timeline_axis(weeks: usize, color: bool) {
318 if weeks == 0 {
319 return;
320 }
321 let mut ticks = vec![' '; weeks];
323 for col in 0..weeks {
324 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"); }
335 println!("{}", ticks.iter().collect::<String>());
336 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 if col + s.len() <= weeks
348 && (col..col + s.len()).all(|i| !occupied[i])
349 {
350 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
365pub fn render_heatmap_ascii_colored(grid: [[usize; 24]; 7], color: bool) {
367 if !color {
368 render_heatmap_ascii(grid);
369 return;
370 }
371 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 let idx = ((c - 1) * 5) / max + 1; let code = color_for_level(idx);
392 print!(" {}█{}", code, ANSI_RESET);
393 }
394 }
395 println!();
396 }
397 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
401pub fn render_calendar_heatmap_colored(grid: &[Vec<usize>]) {
403 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; let code = color_for_level(idx);
422 print!(" {}█{}", code, ANSI_RESET);
423 }
424 }
425 println!();
426 }
427 print!(" ");
429 for _ in 0..grid[0].len() {
430 print!("^");
431 }
432 println!();
433}
434
435pub 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 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 render_timeline_multiline(&counts, 7, color);
453 render_timeline_axis(weeks, color);
455 Ok(())
456}
457
458pub fn run_timeline(weeks: usize) -> Result<(), String> {
460 run_timeline_with_options(weeks, false)
461}
462
463
464pub 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 let w = weeks.unwrap_or(52);
474 let grid = compute_calendar_heatmap(&ts_all, w, now);
475
476 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
498pub 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 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 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 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 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 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 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 let week = 604_800u64;
674 let now = 10 * week; let ts = vec![
676 now - (0 * week) + 1, now - (1 * week) + 2, now - (1 * week) + 3, now - (3 * week), ];
681 let counts = compute_timeline_weeks(&ts, 4, now);
682 assert_eq!(counts, vec![1, 0, 2, 1]);
685 }
686
687 #[test]
688 fn test_compute_heatmap_utc_known_points() {
689 let sun_00 = 3 * 86_400;
691 let sun_13 = sun_00 + 13 * 3_600;
693 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); assert_eq!(grid[0][13], 1); assert_eq!(grid[1][5], 1); }
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 let repo = TempRepo::new("git-insights-vis");
722 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);
728 repo.commit_with_epoch("Bob", "bob@example.com", "a.txt", "b\n", t2);
729
730 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 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); 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 run_timeline(4).expect("timeline ok");
751 run_heatmap().expect("heatmap ok");
752 }
753
754 #[test]
755 fn test_compute_calendar_heatmap_bins() {
756 const DAY: u64 = 86_400;
758 const WEEK: u64 = 7 * DAY;
759 let now = 10 * WEEK;
760
761 let start_of_week = now - (now % WEEK);
763 let aligned_end = start_of_week + WEEK - 1;
764
765 let t_curr1 = aligned_end - (1 * DAY); let t_curr2 = aligned_end - (2 * DAY);
768 let t_prev1 = aligned_end - (8 * DAY); 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 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 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 super::render_calendar_heatmap_ascii(&grid);
796 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}