1use std::collections::BTreeMap;
4use std::error::Error;
5use std::fmt;
6
7use chrono::{Datelike, IsoWeek, NaiveDate};
8use serde::{Deserialize, Deserializer, Serialize};
9
10#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
20#[serde(try_from = "SnapshotDocument", into = "SnapshotDocument")]
21pub struct AccountSnapshot {
22 pub account_id: u64,
24 pub repositories: Vec<RepositoryObservation>,
26 pub contribution_history: Option<ContributionHistory>,
28 pub language_colors: Option<BTreeMap<String, Option<LanguageColor>>>,
30}
31
32#[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78#[serde(deny_unknown_fields)]
79pub struct ContributionHistory {
80 pub observed_on: NaiveDate,
82 pub days: Vec<ContributionDay>,
84}
85
86#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
88#[serde(deny_unknown_fields)]
89pub struct ContributionDay {
90 pub date: NaiveDate,
92 pub contribution_count: u32,
94}
95
96#[derive(Debug, Eq, PartialEq)]
98pub struct ContributionSummary {
99 pub observed_on: NaiveDate,
101 pub total_contributions: u64,
103 pub weeks: Vec<ContributionWeek>,
105 pub daily_counts: BTreeMap<NaiveDate, u32>,
108}
109
110#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct ContributionWeek {
115 pub iso_week: IsoWeek,
117 pub active_day_count: u8,
119 pub observed_day_count: u8,
121}
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
125#[serde(deny_unknown_fields)]
126pub struct RepositoryObservation {
127 pub repository_id: u64,
129 pub visibility: RepositoryVisibility,
131 pub is_fork: bool,
133 #[serde(deserialize_with = "Option::deserialize")]
136 pub primary_language: Option<String>,
137}
138
139#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
141#[serde(rename_all = "lowercase")]
142pub enum RepositoryVisibility {
143 Public,
145 Private,
147 Internal,
149}
150
151pub 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
202pub 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#[derive(Debug, Eq, PartialEq)]
242pub struct ConflictingRepository {
243 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#[derive(Debug, Eq, PartialEq)]
261pub enum ContributionHistoryError {
262 MissingHistory,
264 ConflictingDate(NaiveDate),
266 AfterObservationDate {
268 contribution_date: NaiveDate,
270 observed_on: NaiveDate,
272 },
273 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
424fn 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
434fn 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}