git-koi 1.0.2

Draw a pixel-art koi from a GitHub account's public activity, as a looping GIF for a profile README.
Documentation
use std::collections::BTreeMap;

use chrono::{Datelike, Days, NaiveDate};
use git_koi::history::{
    AccountSnapshot, ConflictingRepository, ContributionDay, ContributionHistory,
    ContributionHistoryError, LanguageColor, RepositoryVisibility, calculate_language_shares,
    summarize_contributions,
};

const ACCOUNT_JSON: &str = include_str!("fixtures/account.json");
const ACCOUNT_WITH_HISTORY_JSON: &str = include_str!("fixtures/account-with-history.json");

#[test]
fn language_shares_count_only_eligible_projects() {
    let account_snapshot: AccountSnapshot = serde_json::from_str(ACCOUNT_JSON).unwrap();

    let language_shares = calculate_language_shares(&account_snapshot).unwrap();

    assert_eq!(
        language_shares,
        BTreeMap::from([
            ("C++".to_owned(), 0.2),
            ("Python".to_owned(), 0.2),
            ("Rust".to_owned(), 0.6),
        ])
    );
}

#[test]
fn language_shares_stay_identical_after_reordering_and_duplicate_observations() {
    let mut account_snapshot: AccountSnapshot = serde_json::from_str(ACCOUNT_JSON).unwrap();
    let original_shares = calculate_language_shares(&account_snapshot).unwrap();
    account_snapshot
        .repositories
        .push(account_snapshot.repositories[0].clone());
    account_snapshot.repositories.reverse();

    for _ in 0..account_snapshot.repositories.len() {
        account_snapshot.repositories.rotate_left(1);
        assert_eq!(
            calculate_language_shares(&account_snapshot).unwrap(),
            original_shares
        );
    }
}

#[test]
fn missing_observations_cannot_be_read_as_a_confirmed_empty_collection() {
    let mut snapshot_document: serde_json::Value = serde_json::from_str(ACCOUNT_JSON).unwrap();
    snapshot_document["repositories"] = serde_json::json!([]);
    let empty_snapshot: AccountSnapshot =
        serde_json::from_value(snapshot_document.clone()).unwrap();
    assert!(
        calculate_language_shares(&empty_snapshot)
            .unwrap()
            .is_empty()
    );

    snapshot_document
        .as_object_mut()
        .unwrap()
        .remove("repositories");
    assert!(serde_json::from_value::<AccountSnapshot>(snapshot_document).is_err());
}

#[test]
fn missing_language_cannot_be_read_as_a_known_absence() {
    let mut snapshot_document: serde_json::Value = serde_json::from_str(ACCOUNT_JSON).unwrap();
    snapshot_document["repositories"][0]["primary_language"] = serde_json::Value::Null;
    let null_language_snapshot: AccountSnapshot =
        serde_json::from_value(snapshot_document.clone()).unwrap();
    assert_eq!(
        calculate_language_shares(&null_language_snapshot).unwrap()["Rust"],
        0.5
    );

    snapshot_document["repositories"][0]
        .as_object_mut()
        .unwrap()
        .remove("primary_language");
    assert!(serde_json::from_value::<AccountSnapshot>(snapshot_document).is_err());
}

#[test]
fn conflicting_observations_fail_regardless_of_order_or_eligibility() {
    let account_snapshot: AccountSnapshot = serde_json::from_str(ACCOUNT_JSON).unwrap();
    let mut repository_with_conflicting_language = account_snapshot.repositories[0].clone();
    repository_with_conflicting_language.primary_language = Some("Python".to_owned());
    let mut repository_with_conflicting_visibility = account_snapshot.repositories[0].clone();
    repository_with_conflicting_visibility.visibility = RepositoryVisibility::Private;

    for conflicting_observation in [
        repository_with_conflicting_language,
        repository_with_conflicting_visibility,
    ] {
        let expected_error = ConflictingRepository {
            repository_id: conflicting_observation.repository_id,
        };
        let mut conflicting_snapshot = account_snapshot.clone();
        conflicting_snapshot
            .repositories
            .push(conflicting_observation);
        assert_eq!(
            calculate_language_shares(&conflicting_snapshot),
            Err(expected_error)
        );
        conflicting_snapshot.repositories.reverse();
        assert!(calculate_language_shares(&conflicting_snapshot).is_err());
    }
}

#[test]
fn snapshot_writer_and_reader_preserve_observations_and_language_shares() {
    let account_snapshot: AccountSnapshot = serde_json::from_str(ACCOUNT_JSON).unwrap();
    let snapshot_bytes = serde_json::to_vec(&account_snapshot).unwrap();
    let restored_snapshot: AccountSnapshot = serde_json::from_slice(&snapshot_bytes).unwrap();

    assert_eq!(restored_snapshot, account_snapshot);
    assert_eq!(
        calculate_language_shares(&restored_snapshot).unwrap(),
        calculate_language_shares(&account_snapshot).unwrap()
    );
}

#[test]
fn incompatible_documents_cannot_enter_language_calculation() {
    let snapshot_document: serde_json::Value = serde_json::from_str(ACCOUNT_JSON).unwrap();
    let mut snapshot_with_unsupported_version = snapshot_document.clone();
    snapshot_with_unsupported_version["schema_version"] = serde_json::json!(999);
    let mut snapshot_without_version = snapshot_document.clone();
    snapshot_without_version
        .as_object_mut()
        .unwrap()
        .remove("schema_version");
    let mut snapshot_with_unknown_account_field = snapshot_document.clone();
    snapshot_with_unknown_account_field["unexpected_field"] = serde_json::json!(true);
    let mut snapshot_with_unknown_repository_field = snapshot_document;
    snapshot_with_unknown_repository_field["repositories"][0]["unexpected_field"] =
        serde_json::json!(true);

    for incompatible_document in [
        snapshot_with_unsupported_version,
        snapshot_without_version,
        snapshot_with_unknown_account_field,
        snapshot_with_unknown_repository_field,
    ] {
        assert!(serde_json::from_value::<AccountSnapshot>(incompatible_document).is_err());
    }
}

#[test]
fn weekly_summary_preserves_inactive_and_partial_weeks_across_a_year_boundary() {
    let account_snapshot: AccountSnapshot =
        serde_json::from_str(ACCOUNT_WITH_HISTORY_JSON).unwrap();
    let contribution_summary = summarize_contributions(&account_snapshot).unwrap();
    let weekly_participation: Vec<_> = contribution_summary
        .weeks
        .iter()
        .map(|contribution_week| {
            (
                contribution_week.iso_week.year(),
                contribution_week.iso_week.week(),
                contribution_week.active_day_count,
                contribution_week.observed_day_count,
            )
        })
        .collect();

    assert_eq!(contribution_summary.total_contributions, 14);
    assert_eq!(
        weekly_participation,
        [
            (2025, 51, 1, 2),
            (2025, 52, 3, 7),
            (2026, 1, 0, 7),
            (2026, 2, 2, 2)
        ]
    );
    assert_eq!(
        contribution_summary.weeks.last().unwrap().iso_week,
        contribution_summary.observed_on.iso_week()
    );
}

#[test]
fn equal_totals_preserve_different_weekday_and_weekend_participation() {
    let first_monday: NaiveDate = "2026-01-05".parse().unwrap();
    let weekday_counts = [1, 1, 1, 1, 1, 0, 0];
    let saturday_counts = [0, 0, 0, 0, 0, 5, 0];
    for (weekly_counts, expected_active_days) in [(weekday_counts, 5), (saturday_counts, 1)] {
        let contribution_days: Vec<_> = (0..12 * 7)
            .zip(weekly_counts.into_iter().cycle())
            .map(|(day_offset, contribution_count)| ContributionDay {
                date: first_monday
                    .checked_add_days(Days::new(day_offset))
                    .unwrap(),
                contribution_count,
            })
            .collect();
        let mut account_snapshot: AccountSnapshot = serde_json::from_str(ACCOUNT_JSON).unwrap();
        account_snapshot.contribution_history = Some(ContributionHistory {
            observed_on: contribution_days.last().unwrap().date,
            days: contribution_days,
        });
        let contribution_summary = summarize_contributions(&account_snapshot).unwrap();
        assert_eq!(contribution_summary.total_contributions, 60);
        assert_eq!(
            contribution_summary
                .weeks
                .iter()
                .map(|contribution_week| contribution_week.active_day_count)
                .collect::<Vec<_>>(),
            vec![expected_active_days; 12]
        );
    }
}

#[test]
fn weekly_measurements_ignore_observation_order_and_identical_duplicates() {
    let mut account_snapshot: AccountSnapshot =
        serde_json::from_str(ACCOUNT_WITH_HISTORY_JSON).unwrap();
    let original_summary = summarize_contributions(&account_snapshot).unwrap();
    let contribution_days = &mut account_snapshot.contribution_history.as_mut().unwrap().days;
    contribution_days.push(contribution_days[0].clone());
    contribution_days.reverse();
    assert_eq!(
        summarize_contributions(&account_snapshot).unwrap(),
        original_summary
    );
}

#[test]
fn supported_snapshot_versions_preserve_observations_and_language_shares() {
    let mut legacy_snapshot: AccountSnapshot = serde_json::from_str(ACCOUNT_JSON).unwrap();
    assert_eq!(
        serde_json::to_value(&legacy_snapshot).unwrap(),
        serde_json::from_str::<serde_json::Value>(ACCOUNT_JSON).unwrap()
    );
    let original_shares = calculate_language_shares(&legacy_snapshot).unwrap();
    let dated_snapshot: AccountSnapshot = serde_json::from_str(ACCOUNT_WITH_HISTORY_JSON).unwrap();
    legacy_snapshot.contribution_history = dated_snapshot.contribution_history;
    let snapshot_bytes = serde_json::to_vec(&legacy_snapshot).unwrap();
    let restored_snapshot: AccountSnapshot = serde_json::from_slice(&snapshot_bytes).unwrap();
    assert_eq!(restored_snapshot, legacy_snapshot);
    assert_eq!(
        calculate_language_shares(&restored_snapshot).unwrap(),
        original_shares
    );
    assert_eq!(
        summarize_contributions(&restored_snapshot).unwrap(),
        summarize_contributions(&legacy_snapshot).unwrap()
    );
}

#[test]
fn colored_snapshots_round_trip_with_absent_colors_kept_distinct() {
    let mut account_snapshot: AccountSnapshot =
        serde_json::from_str(ACCOUNT_WITH_HISTORY_JSON).unwrap();
    assert_eq!(
        serde_json::to_value(&account_snapshot).unwrap(),
        serde_json::from_str::<serde_json::Value>(ACCOUNT_WITH_HISTORY_JSON).unwrap()
    );
    account_snapshot
        .language_colors
        .as_mut()
        .unwrap()
        .insert("Text".to_owned(), None);
    let snapshot_bytes = serde_json::to_vec(&account_snapshot).unwrap();
    let restored_snapshot: AccountSnapshot = serde_json::from_slice(&snapshot_bytes).unwrap();
    assert_eq!(restored_snapshot, account_snapshot);
    assert_eq!(
        restored_snapshot.language_colors.unwrap()["Rust"],
        Some(LanguageColor([0xde, 0xa5, 0x84]))
    );
}

#[test]
fn unavailable_history_differs_from_confirmed_empty_history() {
    let mut account_snapshot: AccountSnapshot = serde_json::from_str(ACCOUNT_JSON).unwrap();
    assert_eq!(
        summarize_contributions(&account_snapshot),
        Err(ContributionHistoryError::MissingHistory)
    );
    account_snapshot.contribution_history = Some(ContributionHistory {
        observed_on: "2026-01-06".parse().unwrap(),
        days: Vec::new(),
    });
    let empty_summary = summarize_contributions(&account_snapshot).unwrap();
    assert_eq!(empty_summary.total_contributions, 0);
    assert!(empty_summary.weeks.is_empty());
}

#[test]
fn missing_dates_cannot_silently_shorten_weekly_participation() {
    let account_snapshot: AccountSnapshot =
        serde_json::from_str(ACCOUNT_WITH_HISTORY_JSON).unwrap();
    let last_day_index = account_snapshot
        .contribution_history
        .as_ref()
        .unwrap()
        .days
        .len()
        - 1;
    for removed_index in [1, last_day_index] {
        let mut corrupted_snapshot = account_snapshot.clone();
        let contribution_history = corrupted_snapshot.contribution_history.as_mut().unwrap();
        let removed_day = contribution_history.days.remove(removed_index);
        assert_eq!(
            summarize_contributions(&corrupted_snapshot),
            Err(ContributionHistoryError::MissingDate(removed_day.date))
        );
    }
}

#[test]
fn conflicting_counts_cannot_replace_an_observation() {
    let mut account_snapshot: AccountSnapshot =
        serde_json::from_str(ACCOUNT_WITH_HISTORY_JSON).unwrap();
    let contribution_days = &mut account_snapshot.contribution_history.as_mut().unwrap().days;
    let mut conflicting_day = contribution_days[0].clone();
    conflicting_day.contribution_count += 1;
    let expected_error = ContributionHistoryError::ConflictingDate(conflicting_day.date);
    contribution_days.push(conflicting_day);
    assert_eq!(
        summarize_contributions(&account_snapshot),
        Err(expected_error)
    );
}

#[test]
fn observations_after_the_cutoff_cannot_inflate_the_total() {
    let mut account_snapshot: AccountSnapshot =
        serde_json::from_str(ACCOUNT_WITH_HISTORY_JSON).unwrap();
    let contribution_history = account_snapshot.contribution_history.as_mut().unwrap();
    contribution_history.observed_on = contribution_history.observed_on.pred_opt().unwrap();
    let expected_error = ContributionHistoryError::AfterObservationDate {
        contribution_date: contribution_history.days.last().unwrap().date,
        observed_on: contribution_history.observed_on,
    };
    assert_eq!(
        summarize_contributions(&account_snapshot),
        Err(expected_error)
    );
}

#[test]
fn snapshot_versions_enforce_their_own_history_fields() {
    let snapshot_document: serde_json::Value =
        serde_json::from_str(ACCOUNT_WITH_HISTORY_JSON).unwrap();
    let mut legacy_with_history = snapshot_document.clone();
    legacy_with_history["schema_version"] = serde_json::json!(1);
    let mut dated_without_history = snapshot_document.clone();
    dated_without_history
        .as_object_mut()
        .unwrap()
        .remove("contribution_history");
    let mut dated_with_null_history = snapshot_document.clone();
    dated_with_null_history["contribution_history"] = serde_json::Value::Null;
    let mut history_with_unknown_field = snapshot_document.clone();
    history_with_unknown_field["contribution_history"]["unexpected_field"] =
        serde_json::json!(true);
    let mut dated_with_colors = snapshot_document.clone();
    dated_with_colors["schema_version"] = serde_json::json!(2);
    let mut colored_without_colors = snapshot_document.clone();
    colored_without_colors
        .as_object_mut()
        .unwrap()
        .remove("language_colors");
    let mut colored_with_null_colors = snapshot_document.clone();
    colored_with_null_colors["language_colors"] = serde_json::Value::Null;
    let mut malformed_color = snapshot_document;
    malformed_color["language_colors"]["Rust"] = serde_json::json!("dea584");
    for incompatible_document in [
        legacy_with_history,
        dated_without_history,
        dated_with_null_history,
        history_with_unknown_field,
        dated_with_colors,
        colored_without_colors,
        colored_with_null_colors,
        malformed_color,
    ] {
        assert!(serde_json::from_value::<AccountSnapshot>(incompatible_document).is_err());
    }
}