use std::collections::BTreeMap;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use chrono::{Datelike, Days, NaiveDate};
use crate::primer::{Appearance, Legibility, Palette, Season};
pub const GLYPH_ROWS: usize = 5;
pub const GLYPH_COLS: usize = 5;
const WEEKDAYS: usize = 7;
const FONT: &[(char, [&str; GLYPH_ROWS])] = &[
('A', [".###.", "#...#", "#####", "#...#", "#...#"]),
('B', ["####.", "#...#", "####.", "#...#", "####."]),
('C', [".###.", "#...#", "#....", "#...#", ".###."]),
('D', ["####.", "#...#", "#...#", "#...#", "####."]),
('E', ["#####", "#....", "####.", "#....", "#####"]),
('F', ["#####", "#....", "####.", "#....", "#...."]),
('G', [".###.", "#....", "#..##", "#...#", ".###."]),
('H', ["#...#", "#...#", "#####", "#...#", "#...#"]),
('I', ["#####", "..#..", "..#..", "..#..", "#####"]),
('J', ["....#", "....#", "....#", "#...#", ".###."]),
('K', ["#...#", "#..#.", "###..", "#..#.", "#...#"]),
('L', ["#....", "#....", "#....", "#....", "#####"]),
('M', ["#...#", "##.##", "#.#.#", "#...#", "#...#"]),
('N', ["#...#", "##..#", "#.#.#", "#..##", "#...#"]),
('O', [".###.", "#...#", "#...#", "#...#", ".###."]),
('P', ["####.", "#...#", "####.", "#....", "#...."]),
('Q', [".###.", "#...#", "#.#.#", "#..#.", ".##.#"]),
('R', ["####.", "#...#", "####.", "#..#.", "#...#"]),
('S', [".####", "#....", ".###.", "....#", "####."]),
('T', ["#####", "..#..", "..#..", "..#..", "..#.."]),
('U', ["#...#", "#...#", "#...#", "#...#", ".###."]),
('V', ["#...#", "#...#", "#...#", ".#.#.", "..#.."]),
('W', ["#...#", "#...#", "#.#.#", "##.##", "#...#"]),
('X', ["#...#", ".#.#.", "..#..", ".#.#.", "#...#"]),
('Y', ["#...#", ".#.#.", "..#..", "..#..", "..#.."]),
('Z', ["#####", "...#.", "..#..", ".#...", "#####"]),
('0', [".###.", "#..##", "#.#.#", "##..#", ".###."]),
('1', ["..#..", ".##..", "..#..", "..#..", ".###."]),
('2', [".###.", "#...#", "..##.", ".#...", "#####"]),
('3', ["####.", "....#", "..##.", "....#", "####."]),
('4', ["#..#.", "#..#.", "#####", "...#.", "...#."]),
('5', ["#####", "#....", "####.", "....#", "####."]),
('6', [".###.", "#....", "####.", "#...#", ".###."]),
('7', ["#####", "....#", "...#.", "..#..", ".#..."]),
('8', [".###.", "#...#", ".###.", "#...#", ".###."]),
('9', [".###.", "#...#", ".####", "....#", ".###."]),
(' ', [".....", ".....", ".....", ".....", "....."]),
('-', [".....", ".....", "#####", ".....", "....."]),
('.', [".....", ".....", ".....", ".....", "..#.."]),
];
const _: () = {
let mut index = 0;
while index < FONT.len() {
let (character, rows) = FONT[index];
let mut row = 0;
while row < GLYPH_ROWS {
let bytes = rows[row].as_bytes();
assert!(
bytes.len() == GLYPH_COLS,
"every glyph row must be GLYPH_COLS characters wide"
);
let mut column = 0;
while column < bytes.len() {
assert!(
bytes[column] == b'#' || bytes[column] == b'.',
"glyph rows are made of '#' and '.' only"
);
column += 1;
}
row += 1;
}
let mut other = 0;
while other < index {
assert!(
FONT[other].0 as u32 != character as u32,
"the same character is in the font twice"
);
other += 1;
}
index += 1;
}
};
pub fn glyph(character: char) -> Option<[&'static str; GLYPH_ROWS]> {
let exact = |wanted: char| {
FONT.iter()
.find(|(candidate, _)| *candidate == wanted)
.map(|(_, rows)| *rows)
};
exact(character).or_else(|| character.to_uppercase().find_map(exact))
}
pub fn alphabet() -> impl Iterator<Item = char> {
FONT.iter().map(|(character, _)| *character)
}
pub fn sunday_of(day: NaiveDate) -> NaiveDate {
day - Days::new(u64::from(day.weekday().num_days_from_sunday()))
}
#[derive(Debug, Clone, Copy)]
pub struct Grid {
pub year: i32,
pub first: NaiveDate,
pub last: NaiveDate,
pub start: NaiveDate,
pub weeks: usize,
}
impl Grid {
pub fn new(year: i32) -> Option<Self> {
let first = NaiveDate::from_ymd_opt(year, 1, 1)?;
let last = NaiveDate::from_ymd_opt(year, 12, 31)?;
let start = sunday_of(first);
Some(Self {
year,
first,
last,
start,
weeks: ((last - start).num_days() / 7 + 1) as usize,
})
}
pub fn date_at(&self, week: usize, row: usize) -> NaiveDate {
self.start + Days::new((week * WEEKDAYS + row) as u64)
}
pub fn holds(&self, day: NaiveDate) -> bool {
self.first <= day && day <= self.last
}
pub fn usable_weeks(&self) -> usize {
(0..self.weeks)
.filter(|week| (1..=5).all(|row| self.holds(self.date_at(*week, row))))
.count()
}
}
pub fn bitmap(text: &str) -> Result<Vec<[bool; GLYPH_ROWS]>, String> {
let unknown: Vec<char> = text.chars().filter(|c| glyph(*c).is_none()).collect();
if !unknown.is_empty() {
let names: Vec<String> = unknown.iter().map(|c| format!("{c:?}")).collect();
return Err(format!(
"no glyph for: {} — the font has {}",
names.join(" "),
describe_alphabet()
));
}
let mut columns = Vec::new();
for (index, character) in text.chars().enumerate() {
let rows = glyph(character).expect("checked above");
if index > 0 {
columns.push([false; GLYPH_ROWS]);
}
for column in 0..GLYPH_COLS {
let mut lit = [false; GLYPH_ROWS];
for (row, line) in rows.iter().enumerate() {
lit[row] = line.as_bytes()[column] == b'#';
}
columns.push(lit);
}
}
Ok(columns)
}
fn describe_alphabet() -> String {
let printable: String = alphabet()
.filter(|c| !c.is_whitespace())
.collect::<Vec<char>>()
.chunks(36)
.map(|chunk| chunk.iter().collect::<String>())
.collect::<Vec<String>>()
.join(" ");
format!("{printable} and space")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Shades {
pub ink: u8,
pub field: u8,
}
impl Default for Shades {
fn default() -> Self {
Self { ink: 4, field: 0 }
}
}
impl Shades {
const READERS: [(Appearance, Season); 9] = [
(Appearance::Light, Season::Default),
(Appearance::Light, Season::Winter),
(Appearance::Light, Season::Halloween),
(Appearance::Dark, Season::Default),
(Appearance::Dark, Season::Winter),
(Appearance::Dark, Season::Halloween),
(Appearance::Dimmed, Season::Default),
(Appearance::Dimmed, Season::Winter),
(Appearance::Dimmed, Season::Halloween),
];
pub fn check(self) -> Result<(), String> {
if self.ink > 4 || self.field > 4 {
return Err(format!(
"shades run 0 to 4; got ink {} and field {}",
self.ink, self.field
));
}
if self.ink == 0 {
return Err("letters cannot be drawn at level 0 — that is an empty day".to_string());
}
if self.field >= self.ink {
return Err(format!(
"the background (level {}) must be darker than the letters (level {}), \
or there is nothing to see",
self.field, self.ink
));
}
Ok(())
}
pub fn separation(self, palette: &Palette) -> f32 {
palette.separation(self.field, self.ink)
}
pub fn worst(self) -> (Legibility, f32) {
let worst = Self::READERS
.iter()
.map(|(appearance, season)| self.separation(&Palette::new(*appearance, *season, true)))
.fold(f32::INFINITY, f32::min);
(Legibility::of(worst), worst)
}
pub fn min_peak(self) -> u32 {
if self.field > 0 {
4
} else {
1
}
}
pub fn commits(self, peak: u32) -> Ink {
Ink {
lit: commits_to_reach(self.ink, peak),
field: match self.field {
0 => 0,
level => commits_to_reach(level, peak),
},
}
}
pub fn ceiling(self, peak: u32) -> Option<u32> {
if self.field == 0 {
return Some(0);
}
commits_to_reach(self.field + 1, peak).checked_sub(1)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Ink {
pub lit: u32,
pub field: u32,
}
#[derive(Debug, Clone, Default)]
pub struct Placed {
pub lit: BTreeMap<NaiveDate, u32>,
pub field: BTreeMap<NaiveDate, u32>,
pub skipped: usize,
pub start_week: usize,
}
impl Placed {
pub fn all(&self) -> BTreeMap<NaiveDate, u32> {
let mut all = self.field.clone();
all.extend(self.lit.iter().map(|(date, count)| (*date, *count)));
all
}
pub fn total(&self) -> u32 {
self.all()
.values()
.fold(0u32, |sum, count| sum.saturating_add(*count))
}
}
pub fn place(
columns: &[[bool; GLYPH_ROWS]],
grid: &Grid,
top: usize,
start: Option<usize>,
ink: Ink,
) -> Result<Placed, String> {
if top + GLYPH_ROWS > WEEKDAYS {
return Err(format!("--top {top} would push the text past row 6"));
}
if columns.len() > grid.weeks {
return Err(format!(
"{} columns needed but {} has only {}; use shorter text",
columns.len(),
grid.year,
grid.weeks
));
}
let start_week = start.unwrap_or((grid.weeks - columns.len()) / 2);
let mut lit = BTreeMap::new();
let mut skipped = 0;
for (offset, column) in columns.iter().enumerate() {
for (row, on) in column.iter().enumerate() {
if !on {
continue;
}
let day = grid.date_at(start_week + offset, top + row);
if grid.holds(day) {
lit.insert(day, ink.lit);
} else {
skipped += 1;
}
}
}
let mut field = BTreeMap::new();
if ink.field > 0 {
let mut date = grid.first;
loop {
if !lit.contains_key(&date) {
field.insert(date, ink.field);
}
match date.succ_opt() {
Some(next) if next <= grid.last => date = next,
_ => break,
}
}
}
Ok(Placed {
lit,
field,
skipped,
start_week,
})
}
pub fn level(count: u32, peak: u32) -> u8 {
if count == 0 || peak == 0 {
return 0;
}
(u64::from(count) * 4).div_ceil(u64::from(peak)).min(4) as u8
}
pub fn commits_to_reach(level: u8, peak: u32) -> u32 {
let wanted = u64::from(level.saturating_sub(1)) * u64::from(peak) / 4 + 1;
wanted.clamp(1, u64::from(u32::MAX)) as u32
}
pub fn commits_for_level(
days: &[NaiveDate],
existing: &BTreeMap<NaiveDate, u32>,
target: u8,
) -> Option<u32> {
if days.is_empty() {
return None;
}
let held = |day: &NaiveDate| existing.get(day).copied().unwrap_or(0);
let elsewhere = existing
.iter()
.filter(|(day, _)| !days.contains(day))
.map(|(_, count)| *count)
.max()
.unwrap_or(0);
let quietest = days.iter().map(held).min().unwrap_or(0);
let mut added = 0;
for _ in 0..64 {
let peak = days
.iter()
.map(|day| held(day) + added)
.chain([elsewhere])
.max()
.unwrap_or(added)
.max(1);
let wanted = commits_to_reach(target, peak).saturating_sub(quietest);
if wanted <= added {
return Some(added.max(1));
}
added = wanted;
}
None
}
pub fn snapshot(counts: &BTreeMap<NaiveDate, u32>, grid: &Grid, login: &str) -> String {
const NAMES: [&str; 5] = [
"NONE",
"FIRST_QUARTILE",
"SECOND_QUARTILE",
"THIRD_QUARTILE",
"FOURTH_QUARTILE",
];
let peak = counts.values().copied().max().unwrap_or(1);
let mut weeks = Vec::with_capacity(grid.weeks);
for week in 0..grid.weeks {
let days: Vec<serde_json::Value> = (0..WEEKDAYS)
.map(|row| grid.date_at(week, row))
.filter(|day| grid.holds(*day))
.map(|day| {
let count = counts.get(&day).copied().unwrap_or(0);
serde_json::json!({
"date": day.to_string(),
"contributionCount": count,
"contributionLevel": NAMES[level(count, peak) as usize],
})
})
.collect();
if !days.is_empty() {
weeks.push(serde_json::json!({ "contributionDays": days }));
}
}
let total = counts
.values()
.fold(0u32, |sum, count| sum.saturating_add(*count));
let payload = serde_json::json!({
"data": { "user": {
"login": login,
"contributionsCollection": {
"contributionYears": [grid.year],
"contributionCalendar": { "totalContributions": total, "weeks": weeks },
},
}},
"errors": serde_json::Value::Null,
});
serde_json::to_string_pretty(&payload).expect("a tree of numbers and strings")
}
pub fn shading(placed: &Placed, shades: Shades) -> BTreeMap<NaiveDate, u8> {
let mut out: BTreeMap<NaiveDate, u8> = placed
.field
.keys()
.map(|date| (*date, shades.field))
.collect();
out.extend(placed.lit.keys().map(|date| (*date, shades.ink)));
out
}
pub fn preview(levels: &BTreeMap<NaiveDate, u8>, grid: &Grid, palette: Option<&Palette>) -> String {
const NAMES: [&str; WEEKDAYS] = ["", "Mon", "", "Wed", "", "Fri", ""];
const RAMP: [&str; 5] = [" ", "░░", "▒▒", "▓▓", "██"];
let paint = |level: u8| {
let level = usize::from(level).min(4);
match palette {
Some(palette) => {
let colour = palette.levels[level];
format!("\x1b[38;2;{};{};{}m██\x1b[0m", colour.0, colour.1, colour.2)
}
None => RAMP[level].to_string(),
}
};
let mut label = " ".repeat(4);
let mut seen = Vec::new();
for week in 0..grid.weeks {
let Some(first) = (0..WEEKDAYS)
.map(|row| grid.date_at(week, row))
.find(|day| grid.holds(*day))
else {
continue;
};
if seen.contains(&first.month()) {
continue;
}
seen.push(first.month());
let column = 4 + week * 2;
if column >= label.chars().count() {
label.push_str(&" ".repeat(column - label.chars().count()));
label.push_str(&first.format("%b").to_string());
}
}
let mut out = vec![label];
for (row, name) in NAMES.iter().enumerate() {
let mut line = format!("{name:<4}");
for week in 0..grid.weeks {
let day = grid.date_at(week, row);
if !grid.holds(day) {
line.push_str(" ");
} else {
line.push_str(&paint(levels.get(&day).copied().unwrap_or(0)));
}
}
out.push(line);
}
out.join("\n")
}
pub fn identity() -> (String, String) {
let config = |key: &str| {
Command::new("git")
.args(["config", "--get", key])
.output()
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|value| !value.is_empty())
};
(
config("user.name").unwrap_or_else(|| "art".to_string()),
config("user.email").unwrap_or_else(|| "art@example.invalid".to_string()),
)
}
pub fn write_commits(
lit: &BTreeMap<NaiveDate, u32>,
repo: &Path,
label: &str,
name: &str,
email: &str,
) -> Result<usize, String> {
for (what, value) in [("name", name), ("email", email)] {
if value.chars().any(|c| c.is_control()) || value.contains(['<', '>']) {
return Err(format!(
"the commit {what} may not contain control characters, '<' or '>': {value:?}"
));
}
}
if !repo.join(".git").is_dir() {
std::fs::create_dir_all(repo).map_err(|e| format!("could not create {repo:?}: {e}"))?;
run(repo, &["init", "-q", "-b", "main"])?;
}
let mut stream = String::new();
let mut index = 0usize;
for (day, count) in lit {
let stamp = day
.and_hms_opt(12, 0, 0)
.expect("noon exists")
.and_utc()
.timestamp();
for _ in 0..*count {
index += 1;
let message = format!("{label} {day} #{index}\n");
let body = format!("{index}\n");
stream.push_str("commit refs/heads/main\n");
stream.push_str(&format!("mark :{index}\n"));
stream.push_str(&format!("author {name} <{email}> {stamp} +0000\n"));
stream.push_str(&format!("committer {name} <{email}> {stamp} +0000\n"));
stream.push_str(&format!("data {}\n{message}", message.len()));
if index > 1 {
stream.push_str(&format!("from :{}\n", index - 1));
}
stream.push_str(&format!(
"M 100644 inline count.txt\ndata {}\n{body}\n",
body.len()
));
}
}
let mut child = Command::new("git")
.args(["fast-import", "--quiet"])
.current_dir(repo)
.stdin(Stdio::piped())
.spawn()
.map_err(|e| format!("could not run git fast-import: {e}"))?;
child
.stdin
.take()
.expect("piped")
.write_all(stream.as_bytes())
.map_err(|e| format!("could not feed git fast-import: {e}"))?;
let status = child
.wait()
.map_err(|e| format!("git fast-import failed: {e}"))?;
if !status.success() {
return Err(format!("git fast-import exited with {status}"));
}
run(repo, &["reset", "--hard", "main"])?;
Ok(index)
}
fn run(repo: &Path, args: &[&str]) -> Result<(), String> {
let out = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.map_err(|e| format!("could not run git {}: {e}", args[0]))?;
if out.status.success() {
return Ok(());
}
Err(format!(
"git {} failed: {}",
args[0],
String::from_utf8_lossy(&out.stderr).trim()
))
}