use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
use chrono::{Datelike, IsoWeek, NaiveDate};
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(try_from = "SnapshotDocument", into = "SnapshotDocument")]
pub struct AccountSnapshot {
pub account_id: u64,
pub repositories: Vec<RepositoryObservation>,
pub contribution_history: Option<ContributionHistory>,
pub language_colors: Option<BTreeMap<String, Option<LanguageColor>>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LanguageColor(pub [u8; 3]);
impl TryFrom<&str> for LanguageColor {
type Error = String;
fn try_from(color_text: &str) -> Result<Self, Self::Error> {
let hex_digits = color_text
.strip_prefix('#')
.filter(|hex_digits| {
hex_digits.len() == 6 && hex_digits.bytes().all(|byte| byte.is_ascii_hexdigit())
})
.ok_or_else(|| format!("language color {color_text} is not #rrggbb"))?;
let channel = |start: usize| {
u8::from_str_radix(&hex_digits[start..start + 2], 16).expect("validated hex digits")
};
Ok(Self([channel(0), channel(2), channel(4)]))
}
}
impl Serialize for LanguageColor {
fn serialize<ColorSerializer: serde::Serializer>(
&self,
color_serializer: ColorSerializer,
) -> Result<ColorSerializer::Ok, ColorSerializer::Error> {
let [red, green, blue] = self.0;
color_serializer.serialize_str(&format!("#{red:02x}{green:02x}{blue:02x}"))
}
}
impl<'de> Deserialize<'de> for LanguageColor {
fn deserialize<ColorDeserializer: Deserializer<'de>>(
color_deserializer: ColorDeserializer,
) -> Result<Self, ColorDeserializer::Error> {
let color_text = String::deserialize(color_deserializer)?;
Self::try_from(color_text.as_str()).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ContributionHistory {
pub observed_on: NaiveDate,
pub days: Vec<ContributionDay>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ContributionDay {
pub date: NaiveDate,
pub contribution_count: u32,
}
#[derive(Debug, Eq, PartialEq)]
pub struct ContributionSummary {
pub observed_on: NaiveDate,
pub total_contributions: u64,
pub weeks: Vec<ContributionWeek>,
pub daily_counts: BTreeMap<NaiveDate, u32>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ContributionWeek {
pub iso_week: IsoWeek,
pub active_day_count: u8,
pub observed_day_count: u8,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RepositoryObservation {
pub repository_id: u64,
pub visibility: RepositoryVisibility,
pub is_fork: bool,
#[serde(deserialize_with = "Option::deserialize")]
pub primary_language: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RepositoryVisibility {
Public,
Private,
Internal,
}
pub fn calculate_language_shares(
account_snapshot: &AccountSnapshot,
) -> Result<BTreeMap<String, f64>, ConflictingRepository> {
let mut distinct_repositories = BTreeMap::new();
for repository_observation in &account_snapshot.repositories {
if let Some(previous_observation) = distinct_repositories
.insert(repository_observation.repository_id, repository_observation)
&& previous_observation != repository_observation
{
return Err(ConflictingRepository {
repository_id: repository_observation.repository_id,
});
}
}
let mut language_repository_counts = BTreeMap::new();
for repository_observation in distinct_repositories.values() {
if repository_observation.visibility != RepositoryVisibility::Public
|| repository_observation.is_fork
{
continue;
}
if let Some(language_name) = &repository_observation.primary_language {
*language_repository_counts
.entry(language_name.clone())
.or_insert(0_usize) += 1;
}
}
let eligible_repository_count: usize = language_repository_counts.values().sum();
Ok(language_repository_counts
.into_iter()
.map(|(language_name, repository_count)| {
(
language_name,
repository_count as f64 / eligible_repository_count as f64,
)
})
.collect())
}
pub fn summarize_contributions(
account_snapshot: &AccountSnapshot,
) -> Result<ContributionSummary, ContributionHistoryError> {
let contribution_history = account_snapshot
.contribution_history
.as_ref()
.ok_or(ContributionHistoryError::MissingHistory)?;
let daily_counts = normalize_contribution_days(contribution_history)?;
let mut weekly_contributions = BTreeMap::new();
let mut total_contributions = 0;
for (&contribution_date, &contribution_count) in &daily_counts {
total_contributions += u64::from(contribution_count);
let iso_week = contribution_date.iso_week();
let contribution_week = weekly_contributions
.entry(iso_week)
.or_insert(ContributionWeek {
iso_week,
active_day_count: 0,
observed_day_count: 0,
});
contribution_week.active_day_count += u8::from(contribution_count > 0);
contribution_week.observed_day_count += 1;
}
Ok(ContributionSummary {
observed_on: contribution_history.observed_on,
total_contributions,
weeks: weekly_contributions.into_values().collect(),
daily_counts,
})
}
#[derive(Debug, Eq, PartialEq)]
pub struct ConflictingRepository {
pub repository_id: u64,
}
impl fmt::Display for ConflictingRepository {
fn fmt(&self, output_formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
output_formatter,
"conflicting observations for repository {}; collect a consistent snapshot",
self.repository_id
)
}
}
impl Error for ConflictingRepository {}
#[derive(Debug, Eq, PartialEq)]
pub enum ContributionHistoryError {
MissingHistory,
ConflictingDate(NaiveDate),
AfterObservationDate {
contribution_date: NaiveDate,
observed_on: NaiveDate,
},
MissingDate(NaiveDate),
}
impl fmt::Display for ContributionHistoryError {
fn fmt(&self, output_formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingHistory => write!(
output_formatter,
"contribution history is unavailable; supply a snapshot with dated contributions"
),
Self::ConflictingDate(contribution_date) => write!(
output_formatter,
"conflicting contribution counts for {contribution_date}; collect a consistent history"
),
Self::AfterObservationDate {
contribution_date,
observed_on,
} => write!(
output_formatter,
"contribution date {contribution_date} is after observation date {observed_on}; supply observations through the stated date"
),
Self::MissingDate(contribution_date) => write!(
output_formatter,
"missing contribution date {contribution_date}; include observations for every date, including zero-activity days"
),
}
}
}
impl Error for ContributionHistoryError {}
fn normalize_contribution_days(
contribution_history: &ContributionHistory,
) -> Result<BTreeMap<NaiveDate, u32>, ContributionHistoryError> {
let mut daily_counts = BTreeMap::new();
for contribution_day in &contribution_history.days {
if contribution_day.date > contribution_history.observed_on {
return Err(ContributionHistoryError::AfterObservationDate {
contribution_date: contribution_day.date,
observed_on: contribution_history.observed_on,
});
}
if let Some(previous_count) =
daily_counts.insert(contribution_day.date, contribution_day.contribution_count)
&& previous_count != contribution_day.contribution_count
{
return Err(ContributionHistoryError::ConflictingDate(
contribution_day.date,
));
}
}
let mut next_expected_date = None;
for contribution_date in daily_counts.keys() {
if let Some(expected_date) = next_expected_date
&& *contribution_date != expected_date
{
return Err(ContributionHistoryError::MissingDate(expected_date));
}
next_expected_date = contribution_date.succ_opt();
}
if let Some(expected_date) = next_expected_date
&& expected_date <= contribution_history.observed_on
{
return Err(ContributionHistoryError::MissingDate(expected_date));
}
Ok(daily_counts)
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct SnapshotDocument {
schema_version: SnapshotVersion,
account_id: u64,
repositories: Vec<RepositoryObservation>,
#[serde(
default,
deserialize_with = "deserialize_present_history",
skip_serializing_if = "Option::is_none"
)]
contribution_history: Option<ContributionHistory>,
#[serde(
default,
deserialize_with = "deserialize_present_colors",
skip_serializing_if = "Option::is_none"
)]
language_colors: Option<BTreeMap<String, Option<LanguageColor>>>,
}
impl TryFrom<SnapshotDocument> for AccountSnapshot {
type Error = String;
fn try_from(snapshot_document: SnapshotDocument) -> Result<Self, Self::Error> {
let has_history = snapshot_document.contribution_history.is_some();
let has_colors = snapshot_document.language_colors.is_some();
match snapshot_document.schema_version {
SnapshotVersion::V1 if has_history => {
return Err(
"contribution_history is not a field in snapshot version 1; \
use version 2 for dated contributions"
.to_owned(),
);
}
SnapshotVersion::V2 | SnapshotVersion::V3 if !has_history => {
return Err(
"missing contribution_history in snapshot versions 2 and 3; \
supply dated observations"
.to_owned(),
);
}
SnapshotVersion::V1 | SnapshotVersion::V2 if has_colors => {
return Err("language_colors is not a field before snapshot version 3; \
use version 3 for language colors"
.to_owned());
}
SnapshotVersion::V3 if !has_colors => {
return Err("missing language_colors in snapshot version 3; \
collect the snapshot again"
.to_owned());
}
_ => {}
}
Ok(Self {
account_id: snapshot_document.account_id,
repositories: snapshot_document.repositories,
contribution_history: snapshot_document.contribution_history,
language_colors: snapshot_document.language_colors,
})
}
}
impl From<AccountSnapshot> for SnapshotDocument {
fn from(account_snapshot: AccountSnapshot) -> Self {
let schema_version = if account_snapshot.language_colors.is_some() {
SnapshotVersion::V3
} else if account_snapshot.contribution_history.is_some() {
SnapshotVersion::V2
} else {
SnapshotVersion::V1
};
Self {
schema_version,
account_id: account_snapshot.account_id,
repositories: account_snapshot.repositories,
contribution_history: account_snapshot.contribution_history,
language_colors: account_snapshot.language_colors,
}
}
}
fn deserialize_present_history<'de, HistoryDeserializer>(
history_deserializer: HistoryDeserializer,
) -> Result<Option<ContributionHistory>, HistoryDeserializer::Error>
where
HistoryDeserializer: Deserializer<'de>,
{
ContributionHistory::deserialize(history_deserializer).map(Some)
}
fn deserialize_present_colors<'de, ColorsDeserializer>(
colors_deserializer: ColorsDeserializer,
) -> Result<Option<BTreeMap<String, Option<LanguageColor>>>, ColorsDeserializer::Error>
where
ColorsDeserializer: Deserializer<'de>,
{
BTreeMap::deserialize(colors_deserializer).map(Some)
}
#[derive(Clone, Copy, Deserialize, Serialize)]
#[serde(try_from = "u32", into = "u32")]
enum SnapshotVersion {
V1 = 1,
V2 = 2,
V3 = 3,
}
impl TryFrom<u32> for SnapshotVersion {
type Error = String;
fn try_from(schema_version: u32) -> Result<Self, Self::Error> {
if schema_version == Self::V1 as u32 {
Ok(Self::V1)
} else if schema_version == Self::V2 as u32 {
Ok(Self::V2)
} else if schema_version == Self::V3 as u32 {
Ok(Self::V3)
} else {
Err(format!(
"unsupported snapshot schema version {schema_version}; use a reader supporting that version"
))
}
}
}
impl From<SnapshotVersion> for u32 {
fn from(schema_version: SnapshotVersion) -> Self {
schema_version as Self
}
}