git-heatmap/src/heatmap.rs

331 lines
7.1 KiB
Rust

use std::{
collections::{BTreeMap, HashMap},
fmt::{Display, Write},
};
use chrono::{Datelike, Duration, NaiveDate};
use clap::ValueEnum;
use itertools::Itertools;
use crate::{Commit, DAYS, RESET, get_char, get_color, get_color_map};
pub struct Heatmap {
since: NaiveDate,
until: NaiveDate,
commits: Vec<Commit>,
repo_commits: Vec<(String, u64)>,
highest_count: i32,
branches: usize,
repos: usize,
chunks: Vec<Chunk>,
streak: u32,
max_streak: u32,
format: Format,
list_repos: bool,
list_days: bool,
}
#[allow(clippy::too_many_arguments)]
impl Heatmap {
pub fn new(
since: NaiveDate,
until: NaiveDate,
repos: usize,
branches: usize,
commits: Vec<Commit>,
split_months: bool,
months_per_row: u16,
format: Format,
list_repos: bool,
list_days: bool,
) -> Self {
let mut chunks = vec![];
let mut highest_count: i32 = 0;
let mut grouped_commits = BTreeMap::new();
let mut repo_commits_map: HashMap<String, u64> = HashMap::new();
let mut repo_commits = Vec::<(String, u64)>::new();
for commit in &commits {
let commit_day = commit.time.date_naive();
let record = grouped_commits.entry(commit_day).or_insert(0);
*record += 1;
if list_repos {
let commits = repo_commits_map.entry(commit.repo.clone()).or_insert(0);
*commits += 1;
}
}
if list_repos {
repo_commits.extend(
repo_commits_map
.into_iter()
.sorted_by(|c1, c2| c2.1.cmp(&c1.1))
.map(|e| (e.0, e.1)),
);
}
let mut current_day = since;
let mut day_of_week = current_day.weekday().num_days_from_monday() % 7;
let mut chunk = Chunk::new(day_of_week);
let mut chunk_idx = 0;
let mut streak = 0;
let mut max_streak = 0;
// Track the very first day of the heatmap, as we don't want the extra spacing in front of
// those.
let mut first_day = true;
while current_day <= until {
if split_months {
// If current day is the first of the month, but not the first day of the heatmap
// we add 2 weeks worth of empty space so months are more visible
if !first_day && current_day.day0() == 0 {
for i in 0..14 {
chunk.data[(i as usize) % 7].push(-1);
}
}
first_day = false;
}
let month_name = current_day.format("%b").to_string();
if current_day == since {
chunk.months.push((0, month_name));
}
else if current_day.day0() == 0 {
chunk_idx += 1;
if chunk_idx > months_per_row - 1 {
chunks.push(chunk);
chunk = Chunk::new(day_of_week);
chunk_idx = 0;
}
chunk
.months
.push((chunk.data[day_of_week as usize].len(), month_name));
}
let value = grouped_commits.get(&current_day);
match value {
Some(val) => {
streak += 1;
if streak > max_streak {
max_streak = streak;
}
chunk.data[day_of_week as usize].push(*val);
if *val > highest_count {
highest_count = *val;
}
}
None => {
streak = 0;
chunk.data[day_of_week as usize].push(0);
}
}
current_day += Duration::days(1);
day_of_week = current_day.weekday().num_days_from_monday() % 7;
}
if chunk_idx <= months_per_row {
chunks.push(chunk);
}
Self {
since,
until,
commits,
repo_commits,
highest_count,
branches,
repos,
chunks,
streak,
max_streak,
format,
list_repos,
list_days,
}
}
}
impl Display for Heatmap {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let start_date = self.since.format("%Y-%b-%d").to_string();
let end_date = self.until.format("%Y-%b-%d").to_string();
let commits = self.commits.len();
let authors = self.commits.iter().unique_by(|c| &c.author.name).count();
let repos_label = if self.repos == 1 { "repo" } else { "repos" };
let branches_label = if self.branches == 1 {
"branch"
}
else {
"branches"
};
let authors_label = if authors == 1 { "author" } else { "authors" };
let commits_label = if commits == 1 { "commit" } else { "commits" };
writeln!(f, "{} - {}", start_date, end_date).unwrap();
writeln!(
f,
"{} {} | {} {}",
self.repos, repos_label, self.branches, branches_label
)
.unwrap();
writeln!(f, "{} {}", authors, authors_label).unwrap();
writeln!(f, "{} {}", commits, commits_label).unwrap();
if self.list_repos {
for (repo, repo_commits) in &self.repo_commits {
let commits_label = if *repo_commits == 1 {
"commit"
}
else {
"commits"
};
writeln!(f, " {}: {} {}", repo, repo_commits, commits_label).unwrap();
}
}
writeln!(
f,
"{} current streak | {} longest streak",
self.streak, self.max_streak
)
.unwrap();
writeln!(f).unwrap();
let mut per_day_commits: [i32; 7] = [0, 0, 0, 0, 0, 0, 0];
for chunk in &self.chunks {
chunk.display(self, f);
writeln!(f).unwrap();
if self.list_days {
per_day_commits
.iter_mut()
.enumerate()
.for_each(|(i, v)| *v += chunk.data[i].iter().sum::<i32>());
}
}
write!(f, "\nLess ").unwrap();
for color in get_color_map() {
write!(f, "{color}{}{RESET} ", get_char()).unwrap();
}
writeln!(f, " More").unwrap();
if self.list_days {
writeln!(f).unwrap();
for day in 0..DAYS.len() {
writeln!(f, "{}: {}", DAYS[day], per_day_commits[day]).unwrap();
}
}
Ok(())
}
}
struct Chunk {
data: [Vec<i32>; 7],
months: Vec<(usize, String)>,
}
impl Chunk {
pub fn new(day_of_week: u32) -> Self {
let mut chunk = Self {
data: [vec![], vec![], vec![], vec![], vec![], vec![], vec![]],
months: vec![],
};
if day_of_week != 0 {
for i in 0..day_of_week {
chunk.data[i as usize].push(-1);
}
}
chunk
}
pub fn display<T: Write>(&self, heatmap: &Heatmap, f: &mut T) {
writeln!(f, "{}", self.months_row(heatmap)).unwrap();
for (day, row) in DAYS.iter().zip(&self.data) {
write!(f, "{day} ").unwrap();
for val in row {
match val {
x if *x >= 0 => {
let color = &get_color_map()[get_color(*val, heatmap.highest_count)];
match heatmap.format {
Format::Chars => write!(f, "{color}{}{RESET} ", get_char()).unwrap(),
Format::Numbers => {
let val = val.min(&99);
write!(f, "{color}{:0>2}{RESET} ", val).unwrap();
}
}
}
x if *x < 0 => match heatmap.format {
Format::Chars => write!(f, "{RESET} ").unwrap(),
Format::Numbers => write!(f, "{RESET} ").unwrap(),
},
_ => {}
}
}
writeln!(f).unwrap();
}
}
fn months_row(&self, heatmap: &Heatmap) -> String {
let mut row = " ".to_string();
let mut last_index = 0;
let mul = match heatmap.format {
Format::Chars => 2,
Format::Numbers => 3,
};
for (index, month) in &self.months {
let range_size = (index * mul)
.saturating_sub(last_index * mul)
.saturating_sub(3);
for _i in 0..range_size {
row.push(' ');
}
last_index = *index;
row.push_str(month);
}
row
}
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, ValueEnum)]
pub enum HeatmapColors {
#[default]
Green,
Red,
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, ValueEnum)]
pub enum ColorLogic {
#[default]
ByAmount,
ByWeight,
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, ValueEnum)]
pub enum Format {
#[default]
Chars,
Numbers,
}