//! Conventional Commits types per week, and per day for the most recent //! days, for the commit-types card. A short period reads daily bins; a long //! one reads weekly bins. //! //! A logical change's subject is the first line of its earliest commit. Only //! Tangled and named GitHub commits carry one; redacted work commits are //! counted separately as having no subject, never guessed at. use std::collections::HashSet; use chrono::{Datelike, Duration, NaiveDate}; use serde::Serialize; /// The types the card lists, in order. Anything else that parses is `other`. const TYPES: &[&str] = &[ "feat", "fix", "docs", "test", "refactor", "perf", "build", "ci", "chore", "style", "revert", ]; #[derive(Debug, Default, Serialize)] pub struct CommitTypes { /// Monday of each UTC week, oldest first. pub weeks: Vec, /// Each of the last few UTC days, oldest first, ending on the last day. pub days: Vec, /// Types with at least one commit, most used first. pub types: Vec, /// Subjects that aren't Conventional Commits. pub untyped: Bins, /// Changes with no subject at all (redacted work commits). pub no_subject: Bins, } #[derive(Debug, Serialize)] pub struct TypeRow { pub name: String, #[serde(flatten)] pub bins: Bins, } /// One count binned two ways: by week over the whole window, and by day /// over its last `days`. #[derive(Debug, Default, Clone, PartialEq, Serialize)] pub struct Bins { pub total: u64, pub weekly: Vec, pub daily: Vec, } impl Bins { fn new(weeks: usize, days: usize) -> Self { Self { total: 0, weekly: vec![0; weeks], daily: vec![0; days], } } fn add(&mut self, week: usize, day: Option) { self.total += 1; self.weekly[week] += 1; if let Some(day) = day { self.daily[day] += 1; } } } /// The type a subject declares: `feat(scope)!: …` is `feat`. Common /// spellings fold into the standard name; an unknown word is `other`. pub fn commit_type(subject: &str) -> Option<&'static str> { let head = subject.trim_start().split_once(':')?.0; let head = head.strip_suffix('!').unwrap_or(head); let word = match head.split_once('(') { Some((word, scope)) if scope.ends_with(')') => word, Some(_) => return None, None => head, }; if word.is_empty() || !word.chars().all(|c| c.is_ascii_alphabetic()) { return None; } let word = word.to_ascii_lowercase(); let word = match word.as_str() { "feature" | "features" => "feat", "bugfix" | "hotfix" | "fixes" => "fix", "doc" => "docs", "tests" => "test", other => other, }; Some( TYPES .iter() .copied() .find(|t| *t == word) .unwrap_or("other"), ) } /// Bucket `(author_did, authored_at, subject)` rows from `start` through /// `end` into weeks, and the last `days` of them into days, keeping only /// authors in `mine`. pub fn binned( rows: &[(String, String, String)], mine: &HashSet, start: NaiveDate, end: NaiveDate, days: usize, ) -> CommitTypes { let monday = |day: NaiveDate| day - Duration::days(day.weekday().num_days_from_monday() as i64); let first = monday(start); let count = ((monday(end) - first).num_days() / 7 + 1) as usize; let first_day = end - Duration::days(days as i64 - 1); let mut names: Vec<&str> = TYPES.to_vec(); names.push("other"); let mut bins = vec![Bins::new(count, days); names.len()]; let mut out = CommitTypes { weeks: (0..count) .map(|week| (first + Duration::days(7 * week as i64)).to_string()) .collect(), days: (0..days) .map(|day| (first_day + Duration::days(day as i64)).to_string()) .collect(), untyped: Bins::new(count, days), no_subject: Bins::new(count, days), types: Vec::new(), }; for (author, authored_at, subject) in rows { if !mine.contains(author) { continue; } let Some(day) = authored_at .get(0..10) .and_then(|day| NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()) else { continue; }; if day < start || day > end { continue; } let week = ((monday(day) - first).num_days() / 7) as usize; let daily = (day >= first_day).then(|| (day - first_day).num_days() as usize); let subject = subject.lines().next().unwrap_or("").trim(); if subject.is_empty() { out.no_subject.add(week, daily); continue; } let Some(kind) = commit_type(subject) else { out.untyped.add(week, daily); continue; }; let row = names .iter() .position(|name| *name == kind) .unwrap_or(names.len() - 1); bins[row].add(week, daily); } out.types = names .iter() .zip(bins) .map(|(name, bins)| TypeRow { name: (*name).to_string(), bins, }) .filter(|row| row.bins.total > 0) .collect(); out.types.sort_by(|a, b| { b.bins .total .cmp(&a.bins.total) .then_with(|| a.name.cmp(&b.name)) }); out } #[cfg(test)] mod tests { use super::*; #[test] fn subjects_parse_to_their_declared_type() { for (subject, want) in [ ("feat: a wheel", Some("feat")), ("fix(db)!: fold old identities", Some("fix")), ("Docs: typo", Some("docs")), ("feature: x", Some("feat")), ("tests: more", Some("test")), ("wip: halfway", Some("other")), ("Merge branch 'main'", None), ("Update README.md", None), ("fix(unclosed: x", None), ("see: http://x", Some("other")), ("12: numbers", None), ] { assert_eq!(commit_type(subject), want, "{subject}"); } } #[test] fn rows_land_in_monday_weeks_and_unused_types_drop() { let row = |did: &str, at: &str, subject: &str| (did.into(), at.into(), subject.into()); let rows = [ row("me", "2026-10-19T09:00:00Z", "feat: a"), row("me", "2026-10-25T23:00:00Z", "feat: b"), row("me", "2026-10-26T01:00:00Z", "fix: c"), row("me", "2026-10-26T02:00:00Z", "Update x"), row("me", "2026-10-26T03:00:00Z", ""), row("someone", "2026-10-26T04:00:00Z", "feat: d"), ]; let mine: HashSet = ["me".to_string()].into(); let start = NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(); let end = NaiveDate::from_ymd_opt(2026, 10, 27).unwrap(); let types = binned(&rows, &mine, start, end, 3); // The 19th is before the window; the 20th's week starts on the 19th. assert_eq!(types.weeks, ["2026-10-19", "2026-10-26"]); let summary: Vec<_> = types .types .iter() .map(|row| (row.name.as_str(), row.bins.weekly.clone())) .collect(); assert_eq!(summary, [("feat", vec![1, 0]), ("fix", vec![0, 1])]); assert_eq!((types.untyped.total, types.no_subject.total), (1, 1)); } #[test] fn the_last_days_are_binned_by_day_too() { let row = |at: &str, subject: &str| ("me".into(), at.into(), subject.into()); let rows = [ row("2026-10-24T23:59:00Z", "feat: before the days"), row("2026-10-25T00:00:00Z", "feat: first day"), row("2026-10-27T09:00:00Z", "fix: last day"), row("2026-10-27T10:00:00Z", "fix: last day again"), row("2026-10-26T10:00:00Z", "Update x"), row("2026-10-26T11:00:00Z", ""), ]; let mine: HashSet = ["me".to_string()].into(); let start = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(); let end = NaiveDate::from_ymd_opt(2026, 10, 27).unwrap(); let types = binned(&rows, &mine, start, end, 3); assert_eq!(types.days, ["2026-10-25", "2026-10-26", "2026-10-27"]); let daily: Vec<_> = types .types .iter() .map(|row| (row.name.as_str(), row.bins.total, row.bins.daily.clone())) .collect(); // The 24th counts in its week and the total, but not in a day. assert_eq!( daily, [("feat", 2, vec![1, 0, 0]), ("fix", 2, vec![0, 0, 2])] ); assert_eq!(types.untyped.daily, [0, 1, 0]); assert_eq!(types.no_subject.daily, [0, 1, 0]); // Every row's daily bins sum to no more than its weekly ones. for row in &types.types { let weekly: u64 = row.bins.weekly.iter().sum(); assert_eq!(weekly, row.bins.total); assert!(row.bins.daily.iter().sum::() <= weekly); } } }