1use std::{env, error::Error, fs, path::Path, process::ExitCode};
2
3use chrono::{Datelike, Days, NaiveDate};
4use git_koi::coat::KoiBody;
5use git_koi::history::{AccountSnapshot, ContributionWeek, LanguageColor};
6use git_koi::koi::{
7 ADULT_CONTRIBUTIONS, KoiDevelopment, MAX_GROWTH_CONTRIBUTIONS, RecentStreak,
8 YOUNG_SHAPE_CONTRIBUTIONS, derive_koi_development,
9};
10use git_koi::output::{
11 STATIC_EXPORT_SCALE, STATIC_HEIGHT_PIXELS, STATIC_WIDTH_PIXELS, encode_png, encode_svg,
12};
13use git_koi::render::{FRAME_HEIGHT_PIXELS, FRAME_WIDTH_PIXELS, KoiFrame, render_koi};
14
15type LanguageMix = &'static [(&'static str, &'static str, f64)];
17const LANGUAGE_SAMPLES: [(&str, LanguageMix); 4] = [
18 ("one", &[("C++", "#f34b7d", 1.0)]),
19 (
20 "two",
21 &[("Python", "#3572a5", 0.65), ("C", "#555555", 0.35)],
22 ),
23 (
24 "three",
25 &[
26 ("TypeScript", "#3178c6", 0.45),
27 ("C++", "#f34b7d", 0.35),
28 ("Shell", "#89e051", 0.2),
29 ],
30 ),
31 (
32 "four",
33 &[
34 ("C", "#555555", 0.25),
35 ("Python", "#3572a5", 0.25),
36 ("C++", "#f34b7d", 0.25),
37 ("JavaScript", "#f1e05a", 0.25),
38 ],
39 ),
40];
41const BODY_MIX: LanguageMix = &[
43 ("C++", "#f34b7d", 0.2),
44 ("C", "#555555", 0.2),
45 ("JavaScript", "#f1e05a", 0.2),
46 ("Rust", "#dea584", 0.2),
47 ("Python", "#3572a5", 0.2),
48];
49const BODY_SAMPLES: [(&str, KoiBody); 6] = [
50 ("white", KoiBody::White),
51 ("black", KoiBody::Black),
52 ("asagi", KoiBody::Asagi),
53 ("tea", KoiBody::Tea),
54 ("gold", KoiBody::Gold),
55 ("platinum", KoiBody::Platinum),
56];
57#[derive(Clone, Copy)]
59enum RecentSample {
60 Current { weeks: usize },
61 Ended { weeks: usize, days_since_end: u64 },
62}
63const MARKING_SAMPLES: [(&str, usize, Option<RecentSample>); 12] = [
66 (
67 "current-1-week",
68 1,
69 Some(RecentSample::Current { weeks: 1 }),
70 ),
71 (
72 "current-6-weeks",
73 6,
74 Some(RecentSample::Current { weeks: 6 }),
75 ),
76 (
77 "current-13-weeks",
78 13,
79 Some(RecentSample::Current { weeks: 13 }),
80 ),
81 ("ended-1-week-ago", 13, Some(ended_long_streak(7))),
82 ("ended-2-weeks-ago", 13, Some(ended_long_streak(14))),
83 ("ended-4-weeks-ago", 13, Some(ended_long_streak(28))),
84 ("longest-0-weeks", 0, None),
85 ("longest-1-week", 1, Some(ended_long_ago(1))),
86 ("longest-4-weeks", 4, Some(ended_long_ago(4))),
87 ("longest-52-weeks", 52, Some(ended_long_ago(52))),
88 ("longest-104-weeks", 104, Some(ended_long_ago(104))),
89 ("longest-208-weeks", 208, Some(ended_long_ago(208))),
90];
91const GROWTH_SAMPLES: [u64; 6] = [
92 0,
93 500,
94 YOUNG_SHAPE_CONTRIBUTIONS,
95 ADULT_CONTRIBUTIONS,
96 50_000,
97 MAX_GROWTH_CONTRIBUTIONS,
98];
99const USAGE: &str = "Usage: generate-library OUTPUT_DIRECTORY
100Write reproducible PNGs, SVGs, and an index; replace matching files, preserve others.
101Success is silent. Errors go to stderr and exit 1; invalid usage exits 2.
102An interrupted run may leave a partial library; rerun the same command to repair it.
103Example: cargo run --offline --locked --example generate-library -- library";
104
105fn main() -> ExitCode {
106 let program_arguments: Vec<_> = env::args_os().skip(1).collect();
107 if program_arguments.len() == 1 && program_arguments[0] == "--help" {
108 println!("{USAGE}");
109 return ExitCode::SUCCESS;
110 }
111 if program_arguments.len() != 1 {
112 eprintln!("{USAGE}");
113 return ExitCode::from(2);
114 }
115 let output_directory = Path::new(&program_arguments[0]);
116 match generate_library(output_directory) {
117 Ok(()) => ExitCode::SUCCESS,
118 Err(generation_error) => {
119 eprintln!("{}: {generation_error}", output_directory.display());
120 ExitCode::FAILURE
121 }
122 }
123}
124
125fn generate_library(output_directory: &Path) -> Result<(), Box<dyn Error>> {
126 let account_snapshot: AccountSnapshot =
127 serde_json::from_str(include_str!("../tests/fixtures/account-with-history.json"))?;
128 let mut koi_development = derive_koi_development(&account_snapshot)?;
129 set_streaks(&mut koi_development, 4, Some(ended_long_ago(4)));
131 let mut index_markdown = build_index_header(koi_development.identity_seed);
132 fs::create_dir_all(output_directory)?;
133 let mut mix_frames = Vec::new();
134 for (mix_name, language_mix) in LANGUAGE_SAMPLES {
135 let mix_description = apply_language_mix(&mut koi_development, language_mix)?;
136 index_markdown.push_str(&format!(
137 "## {}\n\n{mix_description}.\n\n",
138 capitalize(mix_name)
139 ));
140 mix_frames.extend(render_growth_samples(
141 output_directory,
142 &mut koi_development,
143 KoiBody::default(),
144 mix_name,
145 &mut index_markdown,
146 )?);
147 }
148 write_overviews(output_directory, "overview", &mix_frames)?;
149 let body_mix_description = apply_language_mix(&mut koi_development, BODY_MIX)?;
150 let mut body_frames = Vec::new();
151 for (body_name, koi_body) in BODY_SAMPLES {
152 index_markdown.push_str(&format!(
153 "## {} Body\n\n{body_mix_description}.\n\n",
154 capitalize(body_name)
155 ));
156 body_frames.extend(render_growth_samples(
157 output_directory,
158 &mut koi_development,
159 koi_body,
160 &format!("body-{body_name}"),
161 &mut index_markdown,
162 )?);
163 }
164 write_overviews(output_directory, "bodies", &body_frames)?;
165 let mut marking_frames = Vec::new();
166 koi_development.total_contributions = ADULT_CONTRIBUTIONS;
167 let marking_mix_description = apply_language_mix(&mut koi_development, LANGUAGE_SAMPLES[3].1)?;
168 index_markdown.push_str(&format!(
169 "## Streak Markings\n\n{marking_mix_description}, at {ADULT_CONTRIBUTIONS} contributions.\n\n"
170 ));
171 for (sample_name, longest_week_count, recent_sample) in MARKING_SAMPLES {
172 set_streaks(&mut koi_development, longest_week_count, recent_sample);
173 marking_frames.push(render_sample(
174 output_directory,
175 &koi_development,
176 KoiBody::default(),
177 &format!("marks-{sample_name}"),
178 &capitalize(&sample_name.replace('-', " ")),
179 &mut index_markdown,
180 )?);
181 }
182 apply_language_mix(&mut koi_development, BODY_MIX)?;
183 index_markdown.push_str(&format!(
184 "## Bodies With and Without Marks\n\n{body_mix_description}, at \
185 {ADULT_CONTRIBUTIONS} contributions. Without marks, no streak has happened yet; \
186 with every mark, a 13-week streak is running and the longest is 208 weeks.\n\n"
187 ));
188 for (mark_name, longest_week_count, recent_sample) in [
189 ("unmarked", 0, None),
190 ("marked", 208, Some(RecentSample::Current { weeks: 13 })),
191 ] {
192 set_streaks(&mut koi_development, longest_week_count, recent_sample);
193 for (body_name, koi_body) in BODY_SAMPLES {
194 marking_frames.push(render_sample(
195 output_directory,
196 &koi_development,
197 koi_body,
198 &format!("marks-{mark_name}-{body_name}"),
199 &format!("{} Body, {}", capitalize(body_name), capitalize(mark_name)),
200 &mut index_markdown,
201 )?);
202 }
203 }
204 write_overviews(output_directory, "markings", &marking_frames)?;
205 write_artifact(
206 output_directory,
207 "README.md",
208 format!("{}\n", index_markdown.trim_end()).as_bytes(),
209 )?;
210 Ok(())
211}
212
213fn apply_language_mix(
215 koi_development: &mut KoiDevelopment,
216 language_mix: LanguageMix,
217) -> Result<String, Box<dyn Error>> {
218 koi_development.language_shares.clear();
219 koi_development.language_colors.clear();
220 let mut mix_description = Vec::new();
221 for &(language_name, color_text, language_share) in language_mix {
222 koi_development
223 .language_shares
224 .insert(language_name.to_owned(), language_share);
225 koi_development.language_colors.insert(
226 language_name.to_owned(),
227 Some(LanguageColor::try_from(color_text)?),
228 );
229 mix_description.push(format!(
230 "{language_name} {:.0}% ({color_text})",
231 language_share * 100.0
232 ));
233 }
234 Ok(mix_description.join(", "))
235}
236
237fn set_streaks(
239 koi_development: &mut KoiDevelopment,
240 longest_week_count: usize,
241 recent_sample: Option<RecentSample>,
242) {
243 koi_development.streaks.longest = build_weeks(longest_week_count);
244 koi_development.streaks.recent = recent_sample.map(|recent_sample| match recent_sample {
245 RecentSample::Current { weeks } => RecentStreak::Current(build_weeks(weeks)),
246 RecentSample::Ended {
247 weeks,
248 days_since_end,
249 } => RecentStreak::Ended {
250 weeks: build_weeks(weeks),
251 ended_on: koi_development.observed_on - Days::new(days_since_end),
252 },
253 });
254}
255
256const fn ended_long_streak(days_since_end: u64) -> RecentSample {
258 RecentSample::Ended {
259 weeks: 13,
260 days_since_end,
261 }
262}
263
264const fn ended_long_ago(weeks: usize) -> RecentSample {
266 RecentSample::Ended {
267 weeks,
268 days_since_end: 70,
269 }
270}
271
272fn build_weeks(week_count: usize) -> Vec<ContributionWeek> {
274 let first_monday: NaiveDate = "2024-01-01".parse().expect("a valid date");
275 (0..week_count)
276 .map(|week_index| ContributionWeek {
277 iso_week: (first_monday + Days::new(7 * week_index as u64)).iso_week(),
278 active_day_count: 7,
279 observed_day_count: 7,
280 })
281 .collect()
282}
283
284fn render_growth_samples(
286 output_directory: &Path,
287 koi_development: &mut KoiDevelopment,
288 koi_body: KoiBody,
289 sample_name: &str,
290 index_markdown: &mut String,
291) -> Result<Vec<KoiFrame>, Box<dyn Error>> {
292 let mut rendered_frames = Vec::new();
293 for contribution_total in GROWTH_SAMPLES {
294 koi_development.total_contributions = contribution_total;
295 rendered_frames.push(render_sample(
296 output_directory,
297 koi_development,
298 koi_body,
299 &format!("{sample_name}-{contribution_total}"),
300 &format!("{contribution_total} Contributions"),
301 index_markdown,
302 )?);
303 }
304 Ok(rendered_frames)
305}
306
307fn render_sample(
309 output_directory: &Path,
310 koi_development: &KoiDevelopment,
311 koi_body: KoiBody,
312 file_stem: &str,
313 heading: &str,
314 index_markdown: &mut String,
315) -> Result<KoiFrame, Box<dyn Error>> {
316 let koi_frame = render_koi(koi_development, koi_body);
317 let file_name = format!("{file_stem}.png");
318 write_artifact(output_directory, &file_name, &encode_png(&koi_frame)?)?;
319 let svg_name = format!("{file_stem}.svg");
320 write_artifact(
321 output_directory,
322 &svg_name,
323 encode_svg(&koi_frame).as_bytes(),
324 )?;
325 index_markdown.push_str(&format!(
326 "### {heading}\n\n\n\n[SVG]({svg_name})\n\n"
327 ));
328 Ok(koi_frame)
329}
330
331fn write_overviews(
332 output_directory: &Path,
333 overview_name: &str,
334 rendered_frames: &[KoiFrame],
335) -> Result<(), Box<dyn Error>> {
336 for (background_name, background_color) in
337 [("light", [255, 255, 255, 255]), ("dark", [13, 17, 23, 255])]
338 {
339 write_artifact(
340 output_directory,
341 &format!("{overview_name}-{background_name}.png"),
342 &encode_overview(rendered_frames, background_color)?,
343 )?;
344 }
345 Ok(())
346}
347
348fn capitalize(sample_name: &str) -> String {
349 sample_name[..1].to_uppercase() + &sample_name[1..]
350}
351
352fn build_index_header(identity_seed: u64) -> String {
353 let mut index_markdown = format!(
354 "# Koi Library\n\n\
355 These samples share the same anatomy, pose, and Classic lighting. Language mixes\n\
356 appear on the default white body, followed by each body with one language per\n\
357 pigment, then streak markings. The samples use a fixed synthetic account seed,\n\
358 explicit language shares with their GitHub colors, explicit growth totals, and\n\
359 explicit streaks. Growth samples show normal colors and no streak fins.\n\n\
360 Regenerate with the command in the\n\
361 [repository README](../README.md#koi-library).\n\n\
362 In the mix and body overviews, columns follow the contribution totals below,\n\
363 from left to right, and rows follow the mix or body order. These are samples of\n\
364 continuous growth. The markings overview follows the streak marking sections in\n\
365 rows of six: current streaks, longest streaks, then bodies without and with\n\
366 marks.\n\n\
367 Mixes: [light overview](overview-light.png) · [dark overview](overview-dark.png)\n\n\
368 Bodies: [light overview](bodies-light.png) · [dark overview](bodies-dark.png)\n\n\
369 Streak markings: [light overview](markings-light.png) ·\n\
370 [dark overview](markings-dark.png)\n\n\
371 Native canvas: {FRAME_WIDTH_PIXELS} × {FRAME_HEIGHT_PIXELS}.\n\n\
372 PNG and SVG exports: {STATIC_WIDTH_PIXELS} × {STATIC_HEIGHT_PIXELS}.\n\n\
373 Identity seed: {identity_seed}.\n\n"
374 );
375 for contribution_total in GROWTH_SAMPLES {
376 index_markdown.push_str(&format!("- {contribution_total} contributions\n"));
377 }
378 index_markdown.push('\n');
379 index_markdown
380}
381
382fn encode_overview(
383 rendered_frames: &[KoiFrame],
384 background_color: [u8; 4],
385) -> Result<Vec<u8>, png::EncodingError> {
386 let overview_scale = STATIC_EXPORT_SCALE as usize;
387 let frame_width = FRAME_WIDTH_PIXELS as usize;
388 let frame_height = FRAME_HEIGHT_PIXELS as usize;
389 let overview_width = frame_width * GROWTH_SAMPLES.len() * overview_scale;
390 let overview_height =
391 frame_height * rendered_frames.len() / GROWTH_SAMPLES.len() * overview_scale;
392 let mut overview_pixels = background_color.repeat(overview_width * overview_height);
393 for (pixel_index, pixel_channels) in overview_pixels
394 .as_chunks_mut::<4>()
395 .0
396 .iter_mut()
397 .enumerate()
398 {
399 let source_x = pixel_index % overview_width / overview_scale;
400 let source_y = pixel_index / overview_width / overview_scale;
401 let frame_index = source_y / frame_height * GROWTH_SAMPLES.len() + source_x / frame_width;
402 let source_index = (source_y % frame_height * frame_width + source_x % frame_width) * 4;
403 let source_channels =
404 &rendered_frames[frame_index].as_rgba_bytes()[source_index..source_index + 4];
405 if source_channels[3] != 0 {
406 pixel_channels.copy_from_slice(source_channels);
407 }
408 }
409 let mut png_bytes = Vec::new();
410 let mut png_encoder = png::Encoder::new(
411 &mut png_bytes,
412 overview_width as u32,
413 overview_height as u32,
414 );
415 png_encoder.set_color(png::ColorType::Rgba);
416 png_encoder.set_depth(png::BitDepth::Eight);
417 let mut png_writer = png_encoder.write_header()?;
418 png_writer.write_image_data(&overview_pixels)?;
419 png_writer.finish()?;
420 Ok(png_bytes)
421}
422
423fn write_artifact(
424 output_directory: &Path,
425 file_name: &str,
426 file_bytes: &[u8],
427) -> Result<(), Box<dyn Error>> {
428 let output_path = output_directory.join(file_name);
429 let temporary_path = output_path.with_extension("tmp");
430 fs::write(&temporary_path, file_bytes)
431 .map_err(|write_error| format!("{file_name}: {write_error}"))?;
432 fs::rename(temporary_path, output_path)
433 .map_err(|rename_error| format!("{file_name}: {rename_error}"))?;
434 Ok(())
435}