use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use chrono::{Datelike, Local, NaiveDate};
use mossaic::art::{self, Grid};
use mossaic::cli::{Args, YEARS};
use mossaic::primer::{Appearance, Palette, Season};
use mossaic::{github, graphics, plan, plural, png, primer, templates, thousands, Colour};
const HELP: &str = r#"mossaic-art — draw on a GitHub contribution graph by dating commits
usage:
mossaic-art TEXT [options] write words through a 5x5 font
mossaic-art --template NAME draw a picture from the catalogue
mossaic-art --matrix FILE draw a picture of your own
mossaic-art --draw draw one by hand, in an editor
examples:
mossaic-art --draw a blank year, by hand
mossaic-art --list-templates the pictures on offer
mossaic-art --template dragon --year 2027 a picture across next year
mossaic-art VYNCINT --year 2027 what it looks like, and costs
mossaic-art VYNCINT --year 2027 --save remember it
mossaic-art --track am I on pace?
mossaic-art --backfill --repo ../art --write commit what the plan is short
WHAT TO DRAW
TEXT letters, digits, punctuation and shapes: `I :heart: RUST`.
Drawn Mon-Fri through a 5x5 font; --font lists every glyph
--template NAME a picture from the catalogue
--list-templates list the templates, with a thumbnail of each, and exit
--matrix FILE (--file) a .art file: 7 rows of the shades 0-4, or of the
blocks ░ ▒ ▓ █ with a space for 0. `#` starts a comment
--image FILE a PNG, shrunk to fit the year and quantised to five shades.
A dark pixel is a busy day, the way ink reads on paper
--invert with --image, bright pixels become the busy days instead
--dither with --image, spread the rounding error into neighbouring
days (Floyd-Steinberg), so a gradient reads as one
--draw open the editor: arrows or hjkl to move, 0-4 to paint a
shade, space to cycle one, the mouse to paint, u to undo,
s to save. With --template or --matrix it opens that picture
--output FILE (-o) where --draw saves (default: the picture's name)
A picture is seven rows by up to 53 columns, any of GitHub's five shades per
day, using the whole week and the whole year. Text uses Mon-Fri and one shade.
WHERE IT GOES
--year YEAR which year's calendar (default: this one)
--start-week N left edge, in weeks (default: centred)
--commits N commits per lit day (default 4)
--top ROW first calendar row, 0 = Sunday (default 1, so Mon-Fri).
Text only; a picture uses all seven rows
--background LEVEL (--bg) draw the background as a shade 0-3 rather than
leaving it empty, so the whole year stays green. Text
only. Keep it two levels clear of the letters, or the
two greens are too close to tell apart
TRACKING
--track [USER] how far along, what today owes, and whether the picture can
still be drawn at all. Reads --merge if given, otherwise
asks gh for USER (default: whoever gh is)
--today DATE what counts as today, as YYYY-MM-DD (default: the clock),
so a report is repeatable and a future day can be asked
--merge PATH a saved calendar to draw on top of, so the preview accounts
for contributions already there
--plan PATH where the plan lives (default: mossaic-plan.json)
--save write the plan, so later runs need no flags at all
MAKING THE COMMITS
--repo DIR the repository --write commits into (local only)
--write actually create the commits
--backfill commit only what each past day is still short of the plan,
never adding to a day that is already bright, and leaving
days still to come alone. Needs --repo, and --write to commit
--name NAME commit author name (default: git config)
--email ADDRESS commit author email (default: git config)
OUTPUT
--snapshot PATH write a calendar file for `mossaic --file PATH`
--login NAME the name that snapshot carries (default: preview)
--format F text (default), json or markdown — what --track prints.
The GitHub Action reads json and markdown
--color WHEN (--colour) auto (default), always or never. auto means
colour when stdout is a terminal and NO_COLOR is unset
--no-color (--no-colour) the same as --color never
--font print every glyph the font has, and exit
--png PATH with --font, write the glyph sheet to a PNG instead
-V, --version print the version
-h, --help show this help
Save the plan once and later runs need no flags at all:
mossaic-art VYNCINT --year 2027 --start-week 6 --save
mossaic-art --track # reads mossaic-plan.json
Pictures, templates and the .art format: docs/ART.md
https://github.com/vyncint/mossaic/blob/main/docs/ART.md
also installed:
mossaic the chart itself
mossaic-glyphs what this terminal makes of the fallback cells"#;
fn main() {
let Some(options) = parse_args() else {
return;
};
let grid = Grid::new(options.year).unwrap_or_else(|| {
fail(&format!(
"{} is not a year a calendar can hold",
options.year
))
});
if let Some((name, canvas)) = options.canvas.clone() {
if options.draw {
run_editor(&options, &grid, &name, canvas);
} else {
run_canvas(&options, &grid, &name, &canvas);
}
return;
}
if options.draw {
fail("--draw draws a picture; it has nothing to do with TEXT");
}
let columns = art::bitmap(&options.text).unwrap_or_else(|error| fail(&error));
let shades = art::Shades {
ink: 4,
field: options.background,
};
shades.check().unwrap_or_else(|error| fail(&error));
let existing = match (&options.merge, options.track || options.backfill) {
(Some(path), false) => load(path, &grid),
_ => BTreeMap::new(),
};
let peak = existing
.values()
.copied()
.max()
.unwrap_or(0)
.max(options.commits)
.max(shades.min_peak());
let ink = art::Ink {
lit: options.commits,
field: shades.commits(peak).field,
};
let drawn = art::Shades {
ink: art::level(ink.lit, peak),
field: art::level(ink.field, peak),
};
if !options.track && !options.backfill && shades.field > 0 && drawn.field >= drawn.ink {
fail(&format!(
"--commits {} puts the letters at level {} and the background at level {}, \
so the letters would not show.\n \
In this year a letter day needs at least {} commits to sit above a \
level-{} background.",
options.commits,
drawn.ink,
drawn.field,
thousands(art::commits_to_reach(shades.field + 1, peak)),
shades.field,
));
}
let placed = art::place(&columns, &grid, options.top, options.start_week, ink)
.unwrap_or_else(|error| fail(&error));
if placed.skipped > 0 {
eprintln!(
"note: {} lit {} fell outside {} and {} dropped — the first and \
last calendar columns are partial weeks, so {} of {} columns hold a \
whole letter",
placed.skipped,
plural(placed.skipped, "pixel", "pixels"),
grid.year,
plural(placed.skipped, "was", "were"),
grid.usable_weeks(),
grid.weeks
);
}
if options.save {
let spec = plan::Spec {
text: options.text.to_uppercase(),
year: options.year,
start_week: placed.start_week,
top: options.top,
commits: options.commits,
background: options.background,
user: options.tracking.clone(),
art: None,
};
spec.save(&options.plan_path)
.unwrap_or_else(|error| fail(&error));
println!(
"saved {} — from now on:\n mossaic-art --track\n",
options.plan_path.display()
);
}
if options.track {
track_progress(&options, &grid, &columns, &placed, shades);
return;
}
if options.backfill {
backfill(&options, &grid, &columns, &placed, shades);
return;
}
let mut combined = existing.clone();
for (day, count) in &placed.all() {
let total = combined.entry(*day).or_insert(0);
*total = total.saturating_add(*count);
}
let peak = combined.values().copied().max().unwrap_or(1);
let art_level = art::level(options.commits, peak);
let total = placed.total();
println!(
"{} · {} · {} of {} columns · {} days · {} commits\n",
options.text.to_uppercase(),
grid.year,
columns.len(),
grid.weeks,
placed.lit.len(),
thousands(total),
);
let palette = options
.colour
.enabled()
.then(|| Palette::new(Appearance::Dark, Season::Default, true));
println!(
"{}\n",
art::preview(&art::shading(&placed, shades), &grid, palette.as_ref())
);
if shades.field > 0 {
let (legibility, delta) = drawn.worst();
println!(
"background level {} under letters at level {} · {} background {}, \
{} each · ΔE {delta:.0} at worst, {legibility}",
drawn.field,
drawn.ink,
thousands(placed.field.len() as u32),
plural(placed.field.len(), "day", "days"),
ink.field,
);
match legibility {
primer::Legibility::Faint => println!(
" -> {} and {} are neighbouring shades. On some themes they are all \
but the same colour;\n leave two levels between them — \
--background {} is the safe one against level {}.",
drawn.field,
drawn.ink,
drawn.ink.saturating_sub(2),
drawn.ink
),
primer::Legibility::Readable => println!(
" -> readable, but not at a glance on a small graph. \
--background {} reads more clearly.",
drawn.ink.saturating_sub(3)
),
primer::Legibility::Clear => {}
}
println!();
}
if !existing.is_empty() {
let rivals = existing
.iter()
.filter(|(day, count)| {
!placed.lit.contains_key(*day) && art::level(**count, peak) >= art_level
})
.count();
println!(
"drawing over {} {} already active (busiest {})",
existing.len(),
plural(existing.len(), "day", "days"),
existing.values().copied().max().unwrap_or(0)
);
println!(
"peak after merge {peak} · letters land at level {art_level}/4 · \
{rivals} existing {} as bright or brighter",
plural(rivals, "day", "days")
);
if art_level < 4 {
let days: Vec<NaiveDate> = placed.lit.keys().copied().collect();
if let Some(need) = art::commits_for_level(&days, &existing, 4) {
println!(
" -> for the brightest level, use --commits {need} ({} commits)",
thousands(need.saturating_mul(placed.lit.len() as u32))
);
}
}
if rivals > 6 {
println!(" -> the letters will be competing with a lot of real activity");
}
}
if let Some(path) = &options.snapshot {
let body = art::snapshot(&combined, &grid, &options.login);
std::fs::write(path, body)
.unwrap_or_else(|error| fail(&format!("could not write {path:?}: {error}")));
println!(
"\nwrote {} — see it in the real renderer with:",
path.display()
);
println!(" mossaic --file {}", path.display());
}
match (&options.repo, options.write) {
(Some(repo), true) => {
let (name, email) = art::identity();
let name = options.name.unwrap_or(name);
let email = options.email.unwrap_or(email);
println!(
"\ncommitting {} commits into {} as {name} <{email}>",
thousands(total),
repo.display()
);
let label = format!("art: {}", options.text.to_uppercase());
let made = art::write_commits(&placed.all(), repo, &label, &name, &email)
.unwrap_or_else(|error| fail(&error));
println!(
"made {} commits — nothing has been pushed.\n\n\
To publish, create an empty repo on GitHub and:\n\n \
cd {}\n git remote add origin git@github.com:<you>/<repo>.git\n \
git push -u origin main\n\n\
To count towards the graph these must be on the default branch of a \
repo you own (not a fork), authored with an email registered to your \
GitHub account.",
thousands(made as u32),
repo.display()
);
}
(Some(_), false) => println!("\n(add --write to create the commits; this was a dry run)"),
(None, true) => fail("--write needs --repo DIR"),
(None, false) => {}
}
}
fn run_editor(options: &Options, grid: &Grid, name: &str, canvas: art::Canvas) {
use ratatui::crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, KeyModifiers,
};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
let output = options.output.clone().unwrap_or_else(|| {
let stem: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
PathBuf::from(format!("{}.art", stem.trim_matches('-')))
});
let start = options
.start_week
.unwrap_or_else(|| canvas.centred(grid).min(grid.weeks.saturating_sub(1)));
let mut editor = mossaic::draw::Editor::new(canvas, *grid, start, output, options.commits);
if editor.canvas.meta().name.is_none() {
let mut meta = editor.canvas.meta().clone();
meta.name = Some(name.to_string());
editor.canvas.set_meta(meta);
}
let mut terminal = ratatui::try_init()
.unwrap_or_else(|error| fail(&format!("--draw needs an interactive terminal ({error})")));
let mut out = std::io::stdout();
let _ = execute!(out, EnableMouseCapture);
let palette = mossaic::draw::palette();
let outcome = (|| -> std::io::Result<()> {
loop {
let mut body = ratatui::layout::Rect::default();
execute!(out, BeginSynchronizedUpdate)?;
terminal.draw(|frame| {
mossaic::draw::render(frame, &editor, &palette);
let area = frame.area();
body = ratatui::layout::Rect {
x: area.x,
y: area.y + 1,
width: area.width,
height: (mossaic::art::CANVAS_ROWS as u16) + 2,
};
})?;
execute!(out, EndSynchronizedUpdate)?;
let origin = mossaic::draw::grid_origin(body);
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match editor.on_key(key.code, ctrl) {
mossaic::draw::Outcome::Quit => break,
mossaic::draw::Outcome::Save => {
if let Err(error) = editor.save() {
editor.status = error;
}
}
mossaic::draw::Outcome::Idle => {}
}
}
Event::Mouse(mouse) => editor.on_mouse(mouse, origin),
_ => {}
}
}
Ok(())
})();
let _ = execute!(out, DisableMouseCapture);
let restored = ratatui::try_restore();
if let Some(error) = [outcome.err(), restored.err()].into_iter().flatten().next() {
fail(&format!("{error}"));
}
if editor.dirty {
eprintln!(
"not saved — press s in the editor, or run it again and save to {}",
editor.output.display()
);
} else if editor.output.exists() {
println!("{}", editor.output.display());
println!(
"\ndraw it on a year:\n mossaic-art --matrix {} --year {}",
editor.output.display(),
grid.year
);
}
}
fn show_templates(colour: bool) {
let catalogue = templates::catalogue();
if catalogue.is_empty() {
println!("no templates installed");
return;
}
let palette = colour.then(|| Palette::new(Appearance::Dark, Season::Default, true));
let width = catalogue
.iter()
.map(|template| template.name.len())
.max()
.unwrap_or(0);
println!(
"{} {}:\n",
catalogue.len(),
plural(catalogue.len(), "template", "templates")
);
for template in &catalogue {
let author = template
.author()
.map(|who| format!(" by {who}"))
.unwrap_or_default();
println!(
" {:<width$} {}{author}",
template.name,
template.title(),
width = width
);
if let Some(description) = template.description() {
println!(" {:<width$} {description}", "", width = width);
}
println!(
" {:<width$} {} columns, {}\n",
"",
template.canvas.width(),
template.origin,
width = width
);
for row in 0..art::CANVAS_ROWS {
let mut line = String::from(" ");
for week in 0..template.canvas.width() {
let level = template.canvas.at(week, row);
let block = art::SHADE_CHARS[usize::from(level).min(4)].1;
match (&palette, level) {
(Some(palette), level) if level > 0 => {
let rgb = palette.levels[usize::from(level).min(4)];
line.push_str(&format!(
"\x1b[38;2;{};{};{}m{block}\x1b[0m",
rgb.0, rgb.1, rgb.2
));
}
_ => line.push(block),
}
}
println!("{line}");
}
println!();
}
println!("draw one: mossaic-art --template {}", catalogue[0].name);
}
fn run_canvas(options: &Options, grid: &Grid, name: &str, canvas: &art::Canvas) {
let start = options.start_week.unwrap_or_else(|| canvas.centred(grid));
if start >= grid.weeks {
fail(&format!(
"--start-week {start} is past the {} columns {} has",
grid.weeks, grid.year
));
}
let (levels, skipped) = canvas.place(grid, start);
if levels.is_empty() {
fail(&format!(
"nothing of the picture lands inside {} from week {start}",
grid.year
));
}
if skipped > 0 {
eprintln!(
"note: {skipped} {} of the picture fell outside {} — the first and \
last calendar columns are partial weeks, so a full-width picture \
overhangs them. Try --start-week, or a narrower picture.",
plural(skipped, "shade", "shades"),
grid.year
);
}
if options.save {
let spec = plan::Spec {
text: name.to_string(),
year: options.year,
start_week: start,
top: 0,
commits: options.commits,
background: 0,
user: options.tracking.clone(),
art: Some(canvas.to_art()),
};
spec.save(&options.plan_path)
.unwrap_or_else(|error| fail(&error));
println!(
"saved {} — from now on:\n mossaic-art --track\n",
options.plan_path.display()
);
}
if options.track {
track_canvas(options, grid, name, canvas, &levels, start);
return;
}
let existing = match &options.merge {
Some(path) => load(path, grid),
None => BTreeMap::new(),
};
let peak = existing
.values()
.copied()
.max()
.unwrap_or(0)
.max(options.commits)
.max(canvas.min_peak());
let commits: BTreeMap<NaiveDate, u32> = levels
.iter()
.filter(|(_, level)| **level > 0)
.map(|(date, level)| (*date, art::commits_to_reach(*level, peak)))
.collect();
let total = commits
.values()
.fold(0u32, |sum, count| sum.saturating_add(*count));
if options.backfill {
backfill_canvas(options, grid, &levels);
return;
}
let histogram = canvas.histogram();
println!(
"{name} · {} · {} of {} columns · {} days · {} commits\n",
grid.year,
canvas.width(),
grid.weeks,
commits.len(),
thousands(total),
);
let palette = options
.colour
.enabled()
.then(|| Palette::new(Appearance::Dark, Season::Default, true));
println!("{}\n", art::preview(&levels, grid, palette.as_ref()));
println!(" level days commits each");
for level in (1..=4u8).rev() {
if histogram[usize::from(level)] == 0 {
continue;
}
println!(
" {level:>5} {:>4} {}",
histogram[usize::from(level)],
thousands(art::commits_to_reach(level, peak)),
);
}
println!(" {:>5} {:>4} must stay dark", 0, histogram[0]);
if let Some((low, high, legibility, delta)) = canvas.closest_pair() {
let used: Vec<String> = canvas.palette().iter().map(u8::to_string).collect();
println!(
"\n shades {} · closest pair {low} and {high} · ΔE {delta:.0}, {legibility}",
used.join(" ")
);
if legibility != primer::Legibility::Clear {
println!(
" -> levels {low} and {high} are all but the same colour on some themes, so \
whatever\n they separate will read as one shade for part of your \
audience. Leaving two\n levels between the shades you need told apart is \
what fixes it."
);
}
}
println!();
if let Some(path) = &options.snapshot {
let mut combined = existing.clone();
for (day, count) in &commits {
let total = combined.entry(*day).or_insert(0);
*total = total.saturating_add(*count);
}
let body = art::snapshot(&combined, grid, &options.login);
std::fs::write(path, body)
.unwrap_or_else(|error| fail(&format!("could not write {path:?}: {error}")));
println!(
"wrote {} — see it in the real renderer with:",
path.display()
);
println!(" mossaic --file {}\n", path.display());
}
match (&options.repo, options.write) {
(Some(repo), true) => {
let (default_name, default_email) = art::identity();
let who = options.name.clone().unwrap_or(default_name);
let email = options.email.clone().unwrap_or(default_email);
println!(
"committing {} commits into {} as {who} <{email}>",
thousands(total),
repo.display()
);
let label = format!("art: {name}");
let made = art::write_commits(&commits, repo, &label, &who, &email)
.unwrap_or_else(|error| fail(&error));
println!(
"made {} commits — nothing has been pushed.\n\n\
To publish, create an empty repo on GitHub and:\n\n \
cd {}\n git remote add origin git@github.com:<you>/<repo>.git\n \
git push -u origin main",
thousands(made as u32),
repo.display()
);
}
(Some(_), false) => println!("(add --write to create the commits; this was a dry run)"),
(None, true) => fail("--write needs --repo DIR"),
(None, false) => {}
}
}
fn track_canvas(
options: &Options,
grid: &Grid,
name: &str,
canvas: &art::Canvas,
levels: &BTreeMap<NaiveDate, u8>,
start: usize,
) {
let colour = options.colour.enabled();
let palette = colour.then(|| Palette::new(Appearance::Dark, Season::Default, true));
let (who, actual) = observed(options, grid);
let plan = plan::Plan::from_levels(name, grid, levels, start, canvas.width(), &actual);
let today = options.now();
if options.format != Format::Text {
let year_total = actual
.values()
.fold(0u32, |sum, count| sum.saturating_add(*count));
let report = plan::Report::of(&plan, &who, year_total, today, None);
match options.format {
Format::Json => println!(
"{}",
serde_json::to_string_pretty(&report)
.unwrap_or_else(|error| fail(&format!("could not encode the report: {error}")))
),
Format::Markdown => println!("{}", report.markdown()),
Format::Text => unreachable!("handled above"),
}
return;
}
println!("{name} · {} — tracking {who}\n", grid.year);
println!("{}\n", art::preview(levels, grid, palette.as_ref()));
let (owing_days, owing_commits) = plan.owing();
let drawn = plan.bright();
let wanted = plan.letters().count();
match plan.verdict() {
plan::Verdict::Done => println!(" Drawn. Every day is the shade the picture asks for."),
plan::Verdict::Reachable => println!(
" On track — {drawn} of {wanted} shaded {} right, \
{owing_days} to go.",
plural(wanted, "day is", "days are")
),
plan::Verdict::Holed { holes } => println!(
" Cannot be drawn cleanly — {holes} {} brighter than the \
picture wants,\n and nothing takes a contribution away.",
plural(holes, "day is", "days are")
),
}
println!("\n level days done owing each");
let histogram = canvas.histogram();
for level in (1..=4u8).rev() {
if histogram[usize::from(level)] == 0 {
continue;
}
let at_level: Vec<&plan::Day> = plan
.days
.iter()
.filter(|day| levels.get(&day.date).copied() == Some(level))
.collect();
let done = at_level.iter().filter(|day| day.done()).count();
let short: u32 = at_level
.iter()
.fold(0u32, |sum, day| sum.saturating_add(day.short()));
let each = at_level.first().map(|day| day.need).unwrap_or(0);
println!(
" {level:>5} {:>4} {done:>4} {:>5} {}",
at_level.len(),
thousands(short),
thousands(each),
);
}
let dark: Vec<&plan::Day> = plan
.days
.iter()
.filter(|day| day.want == plan::Want::Hole)
.collect();
println!(
" {:>5} {:>4} {:>4} {:>5} must stay dark",
0,
dark.len(),
dark.iter().filter(|day| day.done()).count(),
"-",
);
println!(
"\n still owing {} {} · {} {}",
thousands(owing_days as u32),
plural(owing_days, "day", "days"),
thousands(owing_commits),
plural(owing_commits, "contribution", "contributions")
);
let describe = |day: &plan::Day| match day.want {
plan::Want::Around => "outside the picture — nothing owed".to_string(),
plan::Want::Hole => "must stay dark".to_string(),
plan::Want::Lit if day.short() > 0 => format!(
"{} of {} there, {} to go",
thousands(day.have),
thousands(day.need),
thousands(day.short())
),
plan::Want::Lit if day.over() > 0 => format!(
"{} is past {}, which is a hole",
thousands(day.have),
thousands(day.ceiling.unwrap_or(0))
),
plan::Want::Lit => "already the right shade".to_string(),
};
if plan.holds(today) {
println!(
" today {}",
match plan.on(today) {
Some(day) => describe(day),
None => "outside the picture — nothing owed".to_string(),
}
);
if let Some(next) = today.succ_opt().filter(|next| plan.holds(*next)) {
println!(
" tomorrow {}",
match plan.on(next) {
Some(day) => describe(day),
None => "outside the picture — nothing owed".to_string(),
}
);
}
}
let holes = plan.holes();
if !holes.is_empty() {
println!(
"\n {} {} already too bright:",
holes.len(),
plural(holes.len(), "day", "days")
);
for day in holes.iter().take(8) {
println!(
" {} has {}, wants at most {}",
day.date,
thousands(day.have),
thousands(day.ceiling.unwrap_or(0))
);
}
if holes.len() > 8 {
println!(" ... and {} more", holes.len() - 8);
}
}
println!();
}
fn backfill_canvas(options: &Options, grid: &Grid, levels: &BTreeMap<NaiveDate, u8>) {
let (_, actual) = observed(options, grid);
let plan = plan::Plan::from_levels("picture", grid, levels, 0, 0, &actual);
let today = options.now();
let owed: BTreeMap<NaiveDate, u32> = plan
.days
.iter()
.filter(|day| day.date <= today && day.short() > 0)
.map(|day| (day.date, day.short()))
.collect();
let total = owed
.values()
.fold(0u32, |sum, count| sum.saturating_add(*count));
if owed.is_empty() {
println!("nothing to catch up on — every day up to {today} is already right.");
return;
}
println!(
"{} {} short up to {today}, {} contributions in total",
owed.len(),
plural(owed.len(), "day is", "days are"),
thousands(total)
);
match (&options.repo, options.write) {
(Some(repo), true) => {
let (default_name, default_email) = art::identity();
let who = options.name.clone().unwrap_or(default_name);
let email = options.email.clone().unwrap_or(default_email);
let made = art::write_commits(&owed, repo, "art: catch up", &who, &email)
.unwrap_or_else(|error| fail(&error));
println!(
"made {} commits — nothing has been pushed.",
thousands(made as u32)
);
}
(Some(_), false) => println!("(add --write to create them; this was a dry run)"),
(None, true) => fail("--write needs --repo DIR"),
(None, false) => println!("(--repo DIR --write would create them)"),
}
}
fn observed(options: &Options, grid: &Grid) -> (String, BTreeMap<NaiveDate, u32>) {
match &options.merge {
Some(path) => (path.display().to_string(), load(path, grid)),
None => {
let who = options
.tracking
.clone()
.or_else(github::whoami)
.map(|who| mossaic::printable(&who))
.unwrap_or_else(|| {
fail("could not tell whose contributions to track — pass `--track USER`, or run `gh auth login`")
});
let calendar = github::fetch(&who, grid.year, options.now()).unwrap_or_else(|error| {
fail(&format!(
"could not read {who}'s {} contributions: {error}",
grid.year
))
});
(who, plan::contributions(&calendar))
}
}
}
fn backfill(
options: &Options,
grid: &Grid,
columns: &[[bool; art::GLYPH_ROWS]],
placed: &art::Placed,
shades: art::Shades,
) {
let repo = options.repo.clone().unwrap_or_else(|| {
fail("--backfill needs --repo DIR — the directory to make the commits in")
});
let (who, actual) = observed(options, grid);
let plan = plan::Plan::build(
&options.text,
grid,
placed,
columns.len(),
options.top,
&actual,
shades,
);
let today = options.now();
let owed: BTreeMap<NaiveDate, u32> = plan
.days
.iter()
.filter(|day| day.date < today && day.short() > 0)
.map(|day| (day.date, day.short()))
.collect();
let total = owed.values().fold(0u32, |sum, n| sum.saturating_add(*n));
let ahead = plan
.days
.iter()
.filter(|day| day.date >= today && day.short() > 0)
.count();
println!(
"{} · {} · backfilling against {who}\n",
plan.text, plan.year
);
let (letter_days, letter_commits) = plan.owing();
println!(
" letters {letter_days} {} short, {} commits",
plural(letter_days, "day", "days"),
thousands(letter_commits)
);
if shades.field > 0 {
let (field_days, field_commits) = plan.field_owing();
println!(
" background {field_days} {} short, {} commits",
plural(field_days, "day", "days"),
thousands(field_commits)
);
}
println!(
" a day gets what it is short of {}, never a flat count",
thousands(plan.need)
);
if !owed.is_empty() {
println!(" reaching days before {today}, which are the ones only back-dating reaches");
if ahead > 0 {
println!(
" {ahead} {} from {today} on {} short too, and left alone — \
contribute on those as they come",
plural(ahead, "day", "days"),
plural(ahead, "is", "are")
);
}
}
if let plan::Verdict::Holed { holes } = plan.verdict() {
println!(
"\n warning: {} cannot be drawn cleanly in {} — {holes} {} inside the\n \
letters already lit, and nothing takes those away. Backfilling will\n \
brighten the letters, and the text will still read with holes in it.\n \
`mossaic-art --track` sweeps --start-week for a placement with fewer.",
plan.text,
plan.year,
plural(holes, "day", "days")
);
}
if owed.is_empty() {
match ahead {
0 => println!("\nnothing to backfill — every day the plan wants is already there."),
_ => {
println!("\nnothing to backfill — every day the plan is short of is still to come.")
}
}
return;
}
println!(
"\n {} {} across {} {}, earliest {}, latest {}",
thousands(total),
plural(total, "commit", "commits"),
owed.len(),
plural(owed.len(), "day", "days"),
owed.keys().next().expect("not empty"),
owed.keys().next_back().expect("not empty"),
);
if !options.write {
println!("\n(add --write to create them; this was a dry run)");
return;
}
let (name, email) = art::identity();
let name = options.name.clone().unwrap_or(name);
let email = options.email.clone().unwrap_or(email);
println!(
"\ncommitting {} commits into {} as {name} <{email}>",
thousands(total),
repo.display()
);
let label = format!("art: {}", plan.text);
let made = art::write_commits(&owed, &repo, &label, &name, &email)
.unwrap_or_else(|error| fail(&error));
println!(
"made {} commits — nothing has been pushed.\n\n\
To publish, from {}:\n\n \
git push -u origin main\n\n\
To count towards the graph these must be on the default branch of a repo \
you own (not a fork), authored with an email registered to your GitHub \
account. Run --track again afterwards: GitHub takes a few minutes to \
recount.",
thousands(made as u32),
repo.display()
);
}
fn track_progress(
options: &Options,
grid: &Grid,
columns: &[[bool; art::GLYPH_ROWS]],
placed: &art::Placed,
shades: art::Shades,
) {
let colour = options.colour.enabled();
let palette = colour.then(|| Palette::new(Appearance::Dark, Season::Default, true));
let paint = |text: &str, colour: Option<mossaic::primer::Rgb>| match (colour, &palette) {
(Some(colour), Some(_)) => format!(
"\x1b[38;2;{};{};{}m{text}\x1b[0m",
colour.0, colour.1, colour.2
),
_ => text.to_string(),
};
let shade = |level: usize| palette.as_ref().map(|palette| palette.levels[level]);
let danger = || palette.as_ref().map(|palette| palette.danger);
let (who, actual) = observed(options, grid);
let plan = plan::Plan::build(
&options.text,
grid,
placed,
columns.len(),
options.top,
&actual,
shades,
);
let hideable = plan.field_ceiling.unwrap_or(0);
let today = options.now();
if plan.under_way(today) && !plan.holds(today) {
eprintln!(
"note: {today} is after {}, so this report has no today or tomorrow in \
it — those lines cover the plan's own year.",
plan.year
);
}
if options.format != Format::Text {
let suggestion =
plan::best_start_week(grid, columns.len(), options.top, columns, &actual, hideable);
let year_total = actual
.values()
.fold(0u32, |sum, count| sum.saturating_add(*count));
let report = plan::Report::of(&plan, &who, year_total, today, suggestion);
match options.format {
Format::Json => println!(
"{}",
serde_json::to_string_pretty(&report).expect("a tree of numbers and strings")
),
Format::Markdown => print!("{}", report.markdown()),
Format::Text => unreachable!("handled below"),
}
return;
}
let (owing_days, owing_commits) = plan.owing();
let holes = plan.holes();
let total = actual
.values()
.fold(0u32, |sum, count| sum.saturating_add(*count));
println!("{} · {} · tracking {who}\n", plan.text, plan.year);
println!(
" the plan {} of {} columns from week {}, on rows {}-{}",
plan.columns,
grid.weeks,
plan.start_week,
options.top,
options.top + art::GLYPH_ROWS - 1
);
println!(
" the year {} contributions{}",
thousands(total),
match plan.peak_day {
Some(date) if plan.peak > 0 => format!(
" · busiest {} ({})",
date.format("%b %-d"),
thousands(plan.peak)
),
_ => String::new(),
}
);
println!(
" a letter day has to reach {} to match it",
thousands(plan.need)
);
if shades.field > 0 {
let (legibility, delta) = shades.worst();
println!(
" the shades letters at level {}, background at level {} · \
ΔE {delta:.0} at worst, {legibility}",
shades.ink, shades.field
);
println!(
" a background day has to reach {}{}",
thousands(plan.field_need),
match plan.field_ceiling {
Some(most) => format!(" and stay under {}", thousands(most + 1)),
None => String::new(),
}
);
}
println!();
let bright = plan.bright();
let letters = plan.letters().count();
const BAR: usize = 28;
let filled = (bright * BAR).checked_div(letters).unwrap_or(0);
let bar = format!("{}{}", "█".repeat(filled), "░".repeat(BAR - filled));
println!(
" letters {} {} of {} bright",
paint(&bar, shade(4)),
bright,
letters
);
if owing_days > 0 {
println!(
" owing {owing_days} {} short, {} contributions between them",
plural(owing_days, "day", "days"),
thousands(owing_commits)
);
}
let field_days = plan.field().count();
if field_days > 0 {
let done = plan.field_bright();
let filled = (done * BAR).checked_div(field_days).unwrap_or(0);
let bar = format!("{}{}", "█".repeat(filled), "░".repeat(BAR - filled));
println!(
" background {} {} of {} at level {}",
paint(&bar, shade(usize::from(shades.field))),
done,
field_days,
shades.field
);
let (field_owing_days, field_owing_commits) = plan.field_owing();
if field_owing_days > 0 {
println!(
" owing {field_owing_days} background {} short, {} contributions",
plural(field_owing_days, "day", "days"),
thousands(field_owing_commits)
);
}
}
if !holes.is_empty() {
println!(
" holes {}",
paint(
&format!(
"{} {} lit inside the letters and cannot be unlit",
holes.len(),
plural(holes.len(), "day is", "days are")
),
danger()
)
);
}
if plan.around() > 0 {
println!(
" around {} {} outside the text with contributions",
plan.around(),
plural(plan.around(), "day", "days")
);
}
println!("\n{}\n", plan::preview(&plan, grid, palette.as_ref()));
match plan.verdict() {
plan::Verdict::Done => {
println!(" {}", paint(&format!("{} is drawn.", plan.text), shade(4)))
}
plan::Verdict::Reachable => println!(
" {} can still be drawn cleanly — {}.",
plan.text,
match plan.field_owing().1 {
0 => format!("{} contributions to go", thousands(owing_commits)),
owed => format!(
"{} for the letters, {} for the field",
thousands(owing_commits),
thousands(owed)
),
}
),
plan::Verdict::Holed { holes } => {
println!(
" {}",
paint(
&format!("{} cannot be drawn cleanly in {}.", plan.text, plan.year),
danger()
)
);
println!(
" {holes} {} inside the letters already have contributions, and\n \
nothing takes those away — the text would read with holes in it.",
plural(holes, "day", "days")
);
match plan::best_start_week(
grid,
columns.len(),
options.top,
columns,
&actual,
hideable,
) {
Some((week, left)) if left < holes => {
println!(" --start-week {week} would leave {left} instead of {holes}.")
}
_ => println!(
" Every placement in {} runs into the same problem; an emptier\n \
year is the way out.",
plan.year
),
}
}
}
if !plan.under_way(today) {
let first = plan
.letters()
.map(|day| day.date)
.next()
.unwrap_or(grid.first);
println!(
"\n {} has not started. The first letter day is {} {}.",
plan.year,
plan::weekday(first),
first.format("%b %-d")
);
return;
}
println!();
for (label, date) in [
("today", today),
("tomorrow", today.succ_opt().unwrap_or(today)),
] {
if !grid.holds(date) {
continue;
}
let line = match plan.on(date) {
Some(day) if day.want == plan::Want::Lit && day.short() > 0 => format!(
"a letter day — {} needed, {} there, {} to go",
thousands(day.need),
thousands(day.have),
thousands(day.short())
),
Some(day) if day.want == plan::Want::Lit => {
"a letter day — already bright enough".to_string()
}
Some(day) if day.over() > 0 && day.want == plan::Want::Hole => {
format!(
"inside the letters and already lit ({}) — a permanent hole",
thousands(day.have)
)
}
Some(day) if day.over() > 0 => format!(
"background — {} contributions, {} too many for level {}",
thousands(day.have),
thousands(day.over()),
shades.field
),
Some(day) if day.need > 0 && day.short() > 0 => format!(
"background — {} needed, {} there, {} to go{}",
thousands(day.need),
thousands(day.have),
thousands(day.short()),
match day.ceiling {
Some(most) => format!(" (and no more than {})", thousands(most)),
None => String::new(),
}
),
Some(day) if day.need > 0 => "background — already the right shade".to_string(),
Some(day) if day.want == plan::Want::Hole => {
"inside the letters — keep it dark, or it becomes a permanent hole".to_string()
}
Some(day) if day.have > 0 => "not part of the text, and already lit".to_string(),
_ => "not part of the text — anything committed on it shows".to_string(),
};
println!(
" {label:<11} {} {} · {line}",
plan::weekday(date),
date.format("%b %-d")
);
}
let upcoming = plan.schedule(today, 7);
if upcoming
.iter()
.any(|day| day.want != plan::Want::Around || day.need > 0)
{
println!("\n the next seven days");
for day in upcoming {
let what = match day.want {
plan::Want::Lit if day.short() > 0 => {
format!("letter {} to go", thousands(day.short()))
}
plan::Want::Lit => "letter done".to_string(),
plan::Want::Hole if day.over() > 0 => "hole".to_string(),
_ if day.over() > 0 => "too bright".to_string(),
_ if day.short() > 0 => {
format!("field {} to go", thousands(day.short()))
}
_ if day.need > 0 => "field done".to_string(),
plan::Want::Hole => "keep dark".to_string(),
_ => "—".to_string(),
};
println!(
" {} {} {what}",
plan::weekday(day.date),
day.date.format("%b %-d")
);
}
}
let (past, past_commits) = plan.overdue(today);
let (future, future_commits) = plan.ahead(today);
if past == 0 && future == 0 {
return;
}
println!("\n the rest of the year");
println!(
" {future} letter {} still to come, {} contributions",
plural(future, "day", "days"),
thousands(future_commits)
);
if past > 0 {
println!(
" {past} letter {} already past, {} contributions — only back-dated\n \
commits reach those:\n\n mossaic-art {}--backfill --repo ../art --write\n",
plural(past, "day", "days"),
thousands(past_commits),
match options.plan_loaded {
true => String::new(),
false => format!(
"{} --year {} --start-week {} --top {} {}",
plan.text,
plan.year,
plan.start_week,
options.top,
match shades.field {
0 => String::new(),
level => format!("--background {level} "),
}
),
}
);
if !options.plan_loaded {
println!(
" (or `--save` the plan once, and it is just \
`mossaic-art --backfill …`)"
);
}
}
}
fn show_font(colour: bool) {
const PER_ROW: usize = 8;
let palette = Palette::new(Appearance::Dark, Season::Default, true);
let paint = |cell: &str, lit: bool| {
if !colour {
return if lit {
cell.to_string()
} else {
" ".to_string()
};
}
let shade = palette.levels[if lit { 4 } else { 0 }];
format!(
"\x1b[38;2;{};{};{}m{cell}\x1b[0m",
shade.0, shade.1, shade.2
)
};
let characters: Vec<char> = art::alphabet().collect();
println!(
"{} glyphs, {}x{} each — write any of them with mossaic-art TEXT\n",
characters.len(),
art::GLYPH_COLS,
art::GLYPH_ROWS
);
for chunk in characters.chunks(PER_ROW) {
let labels: Vec<String> = chunk
.iter()
.map(|character| format!("{:<12}", art::label(*character)))
.collect();
println!(" {}", labels.join("").trim_end());
for row in 0..art::GLYPH_ROWS {
let mut line = String::from(" ");
for character in chunk {
let glyph = art::glyph(*character).expect("from the font itself");
for lit in glyph[row].chars() {
line.push_str(&paint("██", lit == '#'));
}
line.push_str(" ");
}
println!("{line}");
}
println!();
}
}
fn write_font_sheet(path: &Path) {
const PER_ROW: usize = 13;
const INK: u8 = 4;
const FIELD: u8 = 1;
let characters: Vec<char> = art::alphabet().filter(|c| !c.is_whitespace()).collect();
const GAP: usize = 2;
let rows = characters.len().div_ceil(PER_ROW);
let width = PER_ROW * (art::GLYPH_COLS + GAP) + GAP;
let height = rows * (art::GLYPH_ROWS + GAP) + GAP;
let mut levels: Vec<Vec<Option<u8>>> = vec![vec![Some(FIELD); height]; width];
for (index, character) in characters.iter().enumerate() {
let glyph = art::glyph(*character).expect("from the font itself");
let left = GAP + (index % PER_ROW) * (art::GLYPH_COLS + GAP);
let top = GAP + (index / PER_ROW) * (art::GLYPH_ROWS + GAP);
for (row, line) in glyph.iter().enumerate() {
for (column, lit) in line.chars().enumerate() {
if lit == '#' {
levels[left + column][top + row] = Some(INK);
}
}
}
}
let palette = Palette::new(Appearance::Dark, Season::Default, true);
let image = graphics::sheet(&levels, &palette, (9, 19));
png::write(path, &image, palette.canvas)
.unwrap_or_else(|error| fail(&format!("could not write {}: {error}", path.display())));
println!(
"wrote {} — {}x{} px, {} glyphs",
path.display(),
image.width,
image.height,
characters.len()
);
}
fn load(path: &PathBuf, grid: &Grid) -> BTreeMap<NaiveDate, u32> {
let calendar = github::from_file(&path.to_string_lossy(), None)
.unwrap_or_else(|error| fail(&format!("could not read {path:?}: {error}")));
let kept: BTreeMap<NaiveDate, u32> = calendar
.days()
.filter(|day| day.count > 0 && grid.holds(day.date))
.map(|day| (day.date, day.count))
.collect();
if kept.is_empty() && calendar.days().any(|day| day.count > 0) {
eprintln!(
"note: {} holds no contributions in {} — it covers {}. Tracking \
against an empty year.",
path.display(),
grid.year,
calendar.year
);
}
kept
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Format {
Text,
Json,
Markdown,
}
impl Options {
fn now(&self) -> NaiveDate {
self.today.unwrap_or_else(|| Local::now().date_naive())
}
}
struct Options {
format: Format,
track: bool,
tracking: Option<String>,
backfill: bool,
today: Option<NaiveDate>,
plan_loaded: bool,
text: String,
year: i32,
commits: u32,
background: u8,
top: usize,
start_week: Option<usize>,
plan_path: PathBuf,
save: bool,
merge: Option<PathBuf>,
snapshot: Option<PathBuf>,
repo: Option<PathBuf>,
write: bool,
login: String,
name: Option<String>,
email: Option<String>,
colour: Colour,
canvas: Option<(String, art::Canvas)>,
output: Option<PathBuf>,
draw: bool,
}
fn parse_args() -> Option<Options> {
let mut options = Options {
format: Format::Text,
track: false,
tracking: None,
backfill: false,
today: None,
plan_loaded: false,
text: String::new(),
plan_path: PathBuf::from(plan::DEFAULT_SPEC),
save: false,
year: Local::now().year(),
commits: 4,
background: 0,
top: 1,
start_week: None,
merge: None,
snapshot: None,
repo: None,
write: false,
login: "preview".to_string(),
name: None,
email: None,
colour: Colour::default(),
canvas: None,
output: None,
draw: false,
};
let mut template: Option<String> = None;
let mut matrix: Option<PathBuf> = None;
let mut picture: Option<PathBuf> = None;
let mut image_options = mossaic::image::Options::default();
let mut list_templates = false;
let mut font = false;
let mut png_path: Option<PathBuf> = None;
let mut track = false;
let mut tracking: Option<String> = None;
let mut args = Args::from_env("mossaic-art");
while let Some(arg) = args.next_arg() {
match arg.as_str() {
"-h" | "--help" => {
println!("{HELP}");
return None;
}
"-V" | "--version" => {
println!("mossaic-art {}", env!("CARGO_PKG_VERSION"));
return None;
}
"-y" | "--year" => options.year = args.year("--year"),
"--commits" => {
options.commits = args.number_in("--commits", "count", 1..=1_000_000) as u32;
}
"--background" | "--bg" => {
options.background = args.number_in("--background", "level", 0..=4) as u8;
}
"--top" => {
let rows = (art::WEEKDAYS - art::GLYPH_ROWS) as i64;
options.top = args.number_in("--top", "row", 0..=rows) as usize;
}
"--start-week" => {
options.start_week =
Some(args.number_in("--start-week", "column", 0..=60) as usize);
}
"--backfill" => options.backfill = true,
"--today" => {
let raw = args.value("--today");
let date = NaiveDate::parse_from_str(&raw, "%Y-%m-%d").unwrap_or_else(|_| {
fail(&format!("--today wants a date as YYYY-MM-DD, not {raw:?}"))
});
if !YEARS.contains(&date.year()) {
fail(&format!(
"--today {raw} is outside the years these tools work in, \
{} to {}",
YEARS.start(),
YEARS.end()
));
}
options.today = Some(date);
}
"--merge" => options.merge = Some(args.value("--merge").into()),
"--snapshot" => options.snapshot = Some(args.value("--snapshot").into()),
"--repo" => options.repo = Some(args.value("--repo").into()),
"--write" => options.write = true,
"--login" => options.login = args.value("--login"),
"--name" => options.name = Some(args.value("--name")),
"--email" => options.email = Some(args.value("--email")),
"--color" | "--colour" => {
let raw = args.value("--color");
options.colour = Colour::parse(&raw).unwrap_or_else(|| {
fail(&format!("--color wants auto, always or never, not {raw:?}"))
});
}
"--no-colour" | "--no-color" => options.colour = Colour::Never,
"--format" => {
options.format = match args.value("--format").as_str() {
"text" => Format::Text,
"json" => Format::Json,
"markdown" | "md" => Format::Markdown,
other => fail(&format!(
"unknown format {other:?} — text, json or markdown"
)),
}
}
"--plan" => options.plan_path = args.value("--plan").into(),
"--save" => options.save = true,
"--template" => template = Some(args.value("--template")),
"--image" => picture = Some(args.value("--image").into()),
"--invert" => image_options.invert = true,
"--dither" => image_options.dither = true,
"--matrix" | "--file" => matrix = Some(args.value("--matrix").into()),
"--list-templates" => list_templates = true,
"--output" | "-o" => options.output = Some(args.value("--output").into()),
"--draw" => options.draw = true,
"--font" => font = true,
"--png" => png_path = Some(PathBuf::from(args.value("--png"))),
"--track" => {
track = true;
if args.peek_value() {
tracking = args.next_arg();
}
}
other if other.starts_with('-') => {
fail(&format!("unknown option {other:?} — try --help"))
}
other if options.text.is_empty() => {
options.text = art::canonical(other).unwrap_or_else(|error| fail(&error));
}
other => fail(&format!("unexpected argument {other:?}")),
}
}
if list_templates {
show_templates(options.colour.enabled());
return None;
}
let sources = [
("--template", template.is_some()),
("--matrix", matrix.is_some()),
("--image", picture.is_some()),
("a TEXT argument", !options.text.is_empty()),
];
let given: Vec<&str> = sources
.iter()
.filter(|(_, present)| *present)
.map(|(name, _)| *name)
.collect();
if given.len() > 1 {
fail(&format!(
"{} both draw the picture — pick one",
given.join(" and ")
));
}
if template.is_some() || matrix.is_some() || picture.is_some() {
for (flag, why) in [
(
"top",
"chooses which rows the letters sit on; a picture uses the whole week",
),
(
"background",
"shades the field behind the letters; a picture names its own shade for every day",
),
(
"bg",
"shades the field behind the letters; a picture names its own shade for every day",
),
] {
if args.was_typed(flag) {
fail(&format!("--{flag} {why}"));
}
}
}
if let Some(name) = &template {
let found = templates::find(name).unwrap_or_else(|error| fail(&error));
options.canvas = Some((found.title().to_string(), found.canvas));
}
for (flag, given) in [
("--invert", image_options.invert),
("--dither", image_options.dither),
] {
if given && picture.is_none() {
fail(&format!(
"{flag} describes how to read an image — it needs --image"
));
}
}
if let Some(path) = &picture {
let canvas = mossaic::image::load(path, image_options).unwrap_or_else(|error| fail(&error));
let name = canvas
.meta()
.name
.clone()
.unwrap_or_else(|| "picture".to_string());
options.canvas = Some((name, canvas));
}
if let Some(path) = &matrix {
let body = std::fs::read_to_string(path)
.unwrap_or_else(|error| fail(&format!("could not read {}: {error}", path.display())));
let canvas = art::Canvas::parse(&body)
.unwrap_or_else(|error| fail(&format!("{}: {error}", path.display())));
let name = canvas.meta().name.clone().unwrap_or_else(|| {
path.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("picture")
.to_string()
});
options.canvas = Some((name, canvas));
}
if font {
match &png_path {
Some(path) => write_font_sheet(path),
None => show_font(options.colour.enabled()),
}
return None;
}
options.track = track;
options.tracking = tracking;
if options.draw && options.canvas.is_none() && options.text.is_empty() {
let blank = art::Canvas::blank(art::CANVAS_COLS).expect("53 is a canvas width");
options.canvas = Some(("untitled".to_string(), blank));
}
match (
options.text.is_empty() && options.canvas.is_none(),
options.plan_path.exists(),
) {
(true, true) => {
let spec = plan::Spec::load(&options.plan_path).unwrap_or_else(|error| fail(&error));
options.plan_loaded = true;
options.text = spec.text;
if !args.was_typed("year") {
options.year = spec.year;
}
if !args.was_typed("top") {
options.top = spec.top;
}
if !args.was_typed("commits") {
options.commits = spec.commits;
}
if !args.was_typed("background") && !args.was_typed("bg") {
options.background = spec.background;
}
if !args.was_typed("start-week") {
options.start_week = Some(spec.start_week);
}
if options.tracking.is_none() {
options.tracking = spec.user;
}
if let Some(canvas) = spec.art.as_deref() {
let canvas = art::Canvas::parse(canvas).unwrap_or_else(|error| {
fail(&format!("{}: {error}", options.plan_path.display()))
});
options.canvas = Some((options.text.clone(), canvas));
options.text = String::new();
}
}
(true, false) => fail(&format!(
"nothing to draw.\n\n \
mossaic-art VYNCINT --year 2027 draw something\n \
mossaic-art --template dragon draw a picture instead\n \
mossaic-art VYNCINT --year 2027 --save remember it, then just \
`mossaic-art --track`\n\n\
(no plan at {})",
options.plan_path.display()
)),
_ => {}
}
if options.track && options.backfill {
fail("--track reports on the plan and --backfill commits to it — pick one");
}
if options.track {
for (flag, asked) in [
("--snapshot", options.snapshot.is_some()),
("--write", options.write),
("--repo", options.repo.is_some()),
] {
if asked {
fail(&format!(
"--track reports on the plan and writes nothing, so {flag} would \
do nothing. Drop one of them."
));
}
}
}
Some(options)
}
fn fail(message: &str) -> ! {
eprintln!("mossaic-art: {message}");
std::process::exit(2)
}