Skip to main content

git_koi/
history.rs

1//! Account observations, language shares, and weekly participation, independent of collection.
2
3use std::collections::BTreeMap;
4use std::error::Error;
5use std::fmt;
6
7use chrono::{Datelike, IsoWeek, NaiveDate};
8use serde::{Deserialize, Deserializer, Serialize};
9
10/// GitHub observations supplied by the caller, readable and writable through Serde.
11///
12/// The caller supplies the owned and GitHub-recognized contributed repositories; only
13/// public, non-fork ones affect language shares, so a public-only collection is enough.
14/// Parsing verifies the format, not collection completeness.
15/// Older snapshots remain usable for language shares but have no contribution history
16/// or language colors. Language colors require contribution history (version 3).
17/// Serialization selects the format from the available observations. Unknown fields,
18/// unsupported versions, and missing fields within a supported format are rejected.
19#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
20#[serde(try_from = "SnapshotDocument", into = "SnapshotDocument")]
21pub struct AccountSnapshot {
22    /// GitHub's stable numeric ID for the account.
23    pub account_id: u64,
24    /// Every repository observation the collector supplied, owned or contributed to.
25    pub repositories: Vec<RepositoryObservation>,
26    /// Daily contributions through an observation date; `None` before snapshot version 2.
27    pub contribution_history: Option<ContributionHistory>,
28    /// GitHub's color for each observed language name; `None` means GitHub has no color.
29    pub language_colors: Option<BTreeMap<String, Option<LanguageColor>>>,
30}
31
32/// A GitHub language color, written as `#rrggbb`.
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub struct LanguageColor(pub [u8; 3]);
35
36impl TryFrom<&str> for LanguageColor {
37    type Error = String;
38
39    fn try_from(color_text: &str) -> Result<Self, Self::Error> {
40        let hex_digits = color_text
41            .strip_prefix('#')
42            .filter(|hex_digits| {
43                hex_digits.len() == 6 && hex_digits.bytes().all(|byte| byte.is_ascii_hexdigit())
44            })
45            .ok_or_else(|| format!("language color {color_text} is not #rrggbb"))?;
46        let channel = |start: usize| {
47            u8::from_str_radix(&hex_digits[start..start + 2], 16).expect("validated hex digits")
48        };
49        Ok(Self([channel(0), channel(2), channel(4)]))
50    }
51}
52
53impl Serialize for LanguageColor {
54    fn serialize<ColorSerializer: serde::Serializer>(
55        &self,
56        color_serializer: ColorSerializer,
57    ) -> Result<ColorSerializer::Ok, ColorSerializer::Error> {
58        let [red, green, blue] = self.0;
59        color_serializer.serialize_str(&format!("#{red:02x}{green:02x}{blue:02x}"))
60    }
61}
62
63impl<'de> Deserialize<'de> for LanguageColor {
64    fn deserialize<ColorDeserializer: Deserializer<'de>>(
65        color_deserializer: ColorDeserializer,
66    ) -> Result<Self, ColorDeserializer::Error> {
67        let color_text = String::deserialize(color_deserializer)?;
68        Self::try_from(color_text.as_str()).map_err(serde::de::Error::custom)
69    }
70}
71
72/// Daily observations through an explicit date, without consulting the system clock.
73///
74/// A nonempty collection must include every date from its first observation through
75/// `observed_on`, including zero-activity dates. An empty collection explicitly
76/// confirms no observations. The collector owns completeness before the first date.
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78#[serde(deny_unknown_fields)]
79pub struct ContributionHistory {
80    /// The last day the history covers, inclusive.
81    pub observed_on: NaiveDate,
82    /// One entry per observed day, complete from the first entry through `observed_on`.
83    pub days: Vec<ContributionDay>,
84}
85
86/// GitHub's contribution count for one day.
87#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
88#[serde(deny_unknown_fields)]
89pub struct ContributionDay {
90    /// The day, in GitHub's UTC calendar.
91    pub date: NaiveDate,
92    /// Contributions GitHub counted on that day.
93    pub contribution_count: u32,
94}
95
96/// Derived measurements; these are not stored alongside source observations.
97#[derive(Debug, Eq, PartialEq)]
98pub struct ContributionSummary {
99    /// The observation date of the summarized history.
100    pub observed_on: NaiveDate,
101    /// Every counted contribution, unweighted.
102    pub total_contributions: u64,
103    /// Chronological Monday-Sunday weeks, including observed weeks with no activity.
104    pub weeks: Vec<ContributionWeek>,
105    /// One count per date, in order, from the first observation through the
106    /// observation date, with identical duplicates merged.
107    pub daily_counts: BTreeMap<NaiveDate, u32>,
108}
109
110/// Participation in one Monday-Sunday week, including explicitly inactive weeks.
111/// `observed_day_count` distinguishes partial calendar coverage from a full week.
112/// A week matching the summary's observation week is still in progress.
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct ContributionWeek {
115    /// The ISO week the participation falls in.
116    pub iso_week: IsoWeek,
117    /// Observed days in the week with at least one contribution.
118    pub active_day_count: u8,
119    /// Days of the week the history covers; seven for a complete week.
120    pub observed_day_count: u8,
121}
122
123/// Current repository metadata identified by its stable GitHub numeric ID.
124#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
125#[serde(deny_unknown_fields)]
126pub struct RepositoryObservation {
127    /// GitHub's stable numeric ID for the repository.
128    pub repository_id: u64,
129    /// Who can see the repository; only public ones count toward language shares.
130    pub visibility: RepositoryVisibility,
131    /// Whether the repository is a fork, which keeps it out of language shares.
132    pub is_fork: bool,
133    /// GitHub's language name unchanged; null means a known absence.
134    /// Omitting the field is an error because absence of data is not an observation.
135    #[serde(deserialize_with = "Option::deserialize")]
136    pub primary_language: Option<String>,
137}
138
139/// A repository's visibility, as GitHub's API names it.
140#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
141#[serde(rename_all = "lowercase")]
142pub enum RepositoryVisibility {
143    /// Visible to everyone.
144    Public,
145    /// Visible to its collaborators.
146    Private,
147    /// Visible within an enterprise.
148    Internal,
149}
150
151/// Calculate fractions of eligible projects, keyed by unchanged language names.
152///
153/// Each distinct public, non-fork repository with a known primary language has
154/// one vote. Identical observations count once; conflicting observations for the
155/// same ID fail rather than let input order determine the answer. No additional
156/// contribution or age rules apply. The input is borrowed without mutation.
157///
158/// Shares are ordered by language name and range from zero to one. An empty
159/// eligible collection returns an empty map. Collection and file access belong
160/// to the caller; this operation performs no I/O.
161pub fn calculate_language_shares(
162    account_snapshot: &AccountSnapshot,
163) -> Result<BTreeMap<String, f64>, ConflictingRepository> {
164    let mut distinct_repositories = BTreeMap::new();
165    for repository_observation in &account_snapshot.repositories {
166        if let Some(previous_observation) = distinct_repositories
167            .insert(repository_observation.repository_id, repository_observation)
168            && previous_observation != repository_observation
169        {
170            return Err(ConflictingRepository {
171                repository_id: repository_observation.repository_id,
172            });
173        }
174    }
175
176    let mut language_repository_counts = BTreeMap::new();
177    for repository_observation in distinct_repositories.values() {
178        if repository_observation.visibility != RepositoryVisibility::Public
179            || repository_observation.is_fork
180        {
181            continue;
182        }
183        if let Some(language_name) = &repository_observation.primary_language {
184            *language_repository_counts
185                .entry(language_name.clone())
186                .or_insert(0_usize) += 1;
187        }
188    }
189
190    let eligible_repository_count: usize = language_repository_counts.values().sum();
191    Ok(language_repository_counts
192        .into_iter()
193        .map(|(language_name, repository_count)| {
194            (
195                language_name,
196                repository_count as f64 / eligible_repository_count as f64,
197            )
198        })
199        .collect())
200}
201
202/// Summarize one account's dated contributions into chronological calendar weeks.
203///
204/// Counts are GitHub-recognized contributions without additional weighting. Each
205/// date counts once; identical duplicates and input order do not affect results.
206/// Missing history, gaps, conflicting dates, and dates after the observation date
207/// fail rather than silently changing participation. Inputs are not mutated.
208/// A confirmed empty history produces zero contributions and no observed weeks.
209pub fn summarize_contributions(
210    account_snapshot: &AccountSnapshot,
211) -> Result<ContributionSummary, ContributionHistoryError> {
212    let contribution_history = account_snapshot
213        .contribution_history
214        .as_ref()
215        .ok_or(ContributionHistoryError::MissingHistory)?;
216    let daily_counts = normalize_contribution_days(contribution_history)?;
217    let mut weekly_contributions = BTreeMap::new();
218    let mut total_contributions = 0;
219    for (&contribution_date, &contribution_count) in &daily_counts {
220        total_contributions += u64::from(contribution_count);
221        let iso_week = contribution_date.iso_week();
222        let contribution_week = weekly_contributions
223            .entry(iso_week)
224            .or_insert(ContributionWeek {
225                iso_week,
226                active_day_count: 0,
227                observed_day_count: 0,
228            });
229        contribution_week.active_day_count += u8::from(contribution_count > 0);
230        contribution_week.observed_day_count += 1;
231    }
232    Ok(ContributionSummary {
233        observed_on: contribution_history.observed_on,
234        total_contributions,
235        weeks: weekly_contributions.into_values().collect(),
236        daily_counts,
237    })
238}
239
240/// Two observations disagree about the same repository; no shares were produced.
241#[derive(Debug, Eq, PartialEq)]
242pub struct ConflictingRepository {
243    /// The repository the observations disagree about.
244    pub repository_id: u64,
245}
246
247impl fmt::Display for ConflictingRepository {
248    fn fmt(&self, output_formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249        write!(
250            output_formatter,
251            "conflicting observations for repository {}; collect a consistent snapshot",
252            self.repository_id
253        )
254    }
255}
256
257impl Error for ConflictingRepository {}
258
259/// Why a history could not be summarized. Each message names the repair.
260#[derive(Debug, Eq, PartialEq)]
261pub enum ContributionHistoryError {
262    /// The snapshot has no contribution history.
263    MissingHistory,
264    /// Two entries for this date carry different counts.
265    ConflictingDate(NaiveDate),
266    /// An entry is dated after the observation date.
267    AfterObservationDate {
268        /// The offending entry's date.
269        contribution_date: NaiveDate,
270        /// The history's observation date.
271        observed_on: NaiveDate,
272    },
273    /// The history skips this date.
274    MissingDate(NaiveDate),
275}
276
277impl fmt::Display for ContributionHistoryError {
278    fn fmt(&self, output_formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
279        match self {
280            Self::MissingHistory => write!(
281                output_formatter,
282                "contribution history is unavailable; supply a snapshot with dated contributions"
283            ),
284            Self::ConflictingDate(contribution_date) => write!(
285                output_formatter,
286                "conflicting contribution counts for {contribution_date}; collect a consistent history"
287            ),
288            Self::AfterObservationDate {
289                contribution_date,
290                observed_on,
291            } => write!(
292                output_formatter,
293                "contribution date {contribution_date} is after observation date {observed_on}; supply observations through the stated date"
294            ),
295            Self::MissingDate(contribution_date) => write!(
296                output_formatter,
297                "missing contribution date {contribution_date}; include observations for every date, including zero-activity days"
298            ),
299        }
300    }
301}
302
303impl Error for ContributionHistoryError {}
304
305fn normalize_contribution_days(
306    contribution_history: &ContributionHistory,
307) -> Result<BTreeMap<NaiveDate, u32>, ContributionHistoryError> {
308    let mut daily_counts = BTreeMap::new();
309    for contribution_day in &contribution_history.days {
310        if contribution_day.date > contribution_history.observed_on {
311            return Err(ContributionHistoryError::AfterObservationDate {
312                contribution_date: contribution_day.date,
313                observed_on: contribution_history.observed_on,
314            });
315        }
316        if let Some(previous_count) =
317            daily_counts.insert(contribution_day.date, contribution_day.contribution_count)
318            && previous_count != contribution_day.contribution_count
319        {
320            return Err(ContributionHistoryError::ConflictingDate(
321                contribution_day.date,
322            ));
323        }
324    }
325
326    let mut next_expected_date = None;
327    for contribution_date in daily_counts.keys() {
328        if let Some(expected_date) = next_expected_date
329            && *contribution_date != expected_date
330        {
331            return Err(ContributionHistoryError::MissingDate(expected_date));
332        }
333        next_expected_date = contribution_date.succ_opt();
334    }
335    if let Some(expected_date) = next_expected_date
336        && expected_date <= contribution_history.observed_on
337    {
338        return Err(ContributionHistoryError::MissingDate(expected_date));
339    }
340    Ok(daily_counts)
341}
342
343#[derive(Deserialize, Serialize)]
344#[serde(deny_unknown_fields)]
345struct SnapshotDocument {
346    schema_version: SnapshotVersion,
347    account_id: u64,
348    repositories: Vec<RepositoryObservation>,
349    #[serde(
350        default,
351        deserialize_with = "deserialize_present_history",
352        skip_serializing_if = "Option::is_none"
353    )]
354    contribution_history: Option<ContributionHistory>,
355    #[serde(
356        default,
357        deserialize_with = "deserialize_present_colors",
358        skip_serializing_if = "Option::is_none"
359    )]
360    language_colors: Option<BTreeMap<String, Option<LanguageColor>>>,
361}
362
363impl TryFrom<SnapshotDocument> for AccountSnapshot {
364    type Error = String;
365
366    fn try_from(snapshot_document: SnapshotDocument) -> Result<Self, Self::Error> {
367        let has_history = snapshot_document.contribution_history.is_some();
368        let has_colors = snapshot_document.language_colors.is_some();
369        match snapshot_document.schema_version {
370            SnapshotVersion::V1 if has_history => {
371                return Err(
372                    "contribution_history is not a field in snapshot version 1; \
373                     use version 2 for dated contributions"
374                        .to_owned(),
375                );
376            }
377            SnapshotVersion::V2 | SnapshotVersion::V3 if !has_history => {
378                return Err(
379                    "missing contribution_history in snapshot versions 2 and 3; \
380                     supply dated observations"
381                        .to_owned(),
382                );
383            }
384            SnapshotVersion::V1 | SnapshotVersion::V2 if has_colors => {
385                return Err("language_colors is not a field before snapshot version 3; \
386                     use version 3 for language colors"
387                    .to_owned());
388            }
389            SnapshotVersion::V3 if !has_colors => {
390                return Err("missing language_colors in snapshot version 3; \
391                     collect the snapshot again"
392                    .to_owned());
393            }
394            _ => {}
395        }
396        Ok(Self {
397            account_id: snapshot_document.account_id,
398            repositories: snapshot_document.repositories,
399            contribution_history: snapshot_document.contribution_history,
400            language_colors: snapshot_document.language_colors,
401        })
402    }
403}
404
405impl From<AccountSnapshot> for SnapshotDocument {
406    fn from(account_snapshot: AccountSnapshot) -> Self {
407        let schema_version = if account_snapshot.language_colors.is_some() {
408            SnapshotVersion::V3
409        } else if account_snapshot.contribution_history.is_some() {
410            SnapshotVersion::V2
411        } else {
412            SnapshotVersion::V1
413        };
414        Self {
415            schema_version,
416            account_id: account_snapshot.account_id,
417            repositories: account_snapshot.repositories,
418            contribution_history: account_snapshot.contribution_history,
419            language_colors: account_snapshot.language_colors,
420        }
421    }
422}
423
424// An explicit history field must be an object; absence is reserved for older snapshots.
425fn deserialize_present_history<'de, HistoryDeserializer>(
426    history_deserializer: HistoryDeserializer,
427) -> Result<Option<ContributionHistory>, HistoryDeserializer::Error>
428where
429    HistoryDeserializer: Deserializer<'de>,
430{
431    ContributionHistory::deserialize(history_deserializer).map(Some)
432}
433
434// An explicit colors field must be an object; absence is reserved for older snapshots.
435fn deserialize_present_colors<'de, ColorsDeserializer>(
436    colors_deserializer: ColorsDeserializer,
437) -> Result<Option<BTreeMap<String, Option<LanguageColor>>>, ColorsDeserializer::Error>
438where
439    ColorsDeserializer: Deserializer<'de>,
440{
441    BTreeMap::deserialize(colors_deserializer).map(Some)
442}
443
444#[derive(Clone, Copy, Deserialize, Serialize)]
445#[serde(try_from = "u32", into = "u32")]
446enum SnapshotVersion {
447    V1 = 1,
448    V2 = 2,
449    V3 = 3,
450}
451
452impl TryFrom<u32> for SnapshotVersion {
453    type Error = String;
454
455    fn try_from(schema_version: u32) -> Result<Self, Self::Error> {
456        if schema_version == Self::V1 as u32 {
457            Ok(Self::V1)
458        } else if schema_version == Self::V2 as u32 {
459            Ok(Self::V2)
460        } else if schema_version == Self::V3 as u32 {
461            Ok(Self::V3)
462        } else {
463            Err(format!(
464                "unsupported snapshot schema version {schema_version}; use a reader supporting that version"
465            ))
466        }
467    }
468}
469
470impl From<SnapshotVersion> for u32 {
471    fn from(schema_version: SnapshotVersion) -> Self {
472        schema_version as Self
473    }
474}