Skip to main content

generate_library/
generate-library.rs

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
15/// Language mixes with GitHub's colors and their shares of the account's projects.
16type 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];
41/// One language for each pigment, so every pigment shows on every body.
42const 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/// A recent streak sample: still running, or ended some days before the observation.
58#[derive(Clone, Copy)]
59enum RecentSample {
60    Current { weeks: usize },
61    Ended { weeks: usize, days_since_end: u64 },
62}
63/// Streak samples on the four-language mix: name, longest streak in weeks, and the
64/// recent streak. Each row of six fills one overview row.
65const 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    // Growth samples show normal colors and no streak fins.
130    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
213/// Replace the development's languages with a sample mix and describe it for the index.
214fn 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
237/// Replace the development's streaks with a sample longest and recent streak.
238fn 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
256/// A 13-week streak that ended some days ago, so its fins are fading.
257const fn ended_long_streak(days_since_end: u64) -> RecentSample {
258    RecentSample::Ended {
259        weeks: 13,
260        days_since_end,
261    }
262}
263
264/// A streak that ended long enough ago that its fins have faded.
265const fn ended_long_ago(weeks: usize) -> RecentSample {
266    RecentSample::Ended {
267        weeks,
268        days_since_end: 70,
269    }
270}
271
272/// Fully active weeks; the marks depend only on how many there are.
273fn 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
284/// Render and write one sample per growth total, and list them in the index.
285fn 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
307/// Render and write one PNG/SVG pair, and list it in the index under a heading.
308fn 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![{file_stem}]({file_name})\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}