#![allow(clippy::cast_possible_wrap)]
use chrono::{NaiveDate, Utc};
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Days(pub u32);
impl Days {
#[must_use]
pub const fn new(days: u32) -> Self {
Self(days)
}
#[must_use]
pub const fn value(self) -> u32 {
self.0
}
}
impl From<u32> for Days {
fn from(days: u32) -> Self {
Self(days)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DateRange {
pub from: NaiveDate,
pub to: NaiveDate,
}
impl DateRange {
#[must_use]
pub fn last_n_days(days: Days) -> Self {
let to = Utc::now().date_naive();
let from = to - chrono::Duration::days(i64::from(days.0));
Self { from, to }
}
#[must_use]
pub const fn new(from: NaiveDate, to: NaiveDate) -> Self {
Self { from, to }
}
#[must_use]
pub fn contains(&self, date: NaiveDate) -> bool {
date >= self.from && date <= self.to
}
pub fn iter_days(&self) -> impl Iterator<Item = NaiveDate> {
let from = self.from;
let to = self.to;
std::iter::successors(Some(from), move |&d| {
let next = d + chrono::Duration::days(1);
if next <= to { Some(next) } else { None }
})
}
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct PeriodStats {
pub label: String,
#[serde(serialize_with = "serialize_date")]
pub date: NaiveDate,
pub commits: u32,
pub additions: u64,
pub deletions: u64,
pub net_lines: i64,
pub files_changed: u32,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn serialize_date<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&date.format("%Y-%m-%d").to_string())
}
impl PeriodStats {
#[must_use]
pub fn new(date: NaiveDate) -> Self {
Self {
label: date.format("%Y-%m-%d").to_string(),
date,
..Default::default()
}
}
#[must_use]
pub fn with_label(date: NaiveDate, label: String) -> Self {
Self {
label,
date,
..Default::default()
}
}
#[must_use]
pub fn calculate_net_lines(&self) -> i64 {
self.additions as i64 - self.deletions as i64
}
pub fn merge(&mut self, other: &Self) {
self.commits += other.commits;
self.additions += other.additions;
self.deletions += other.deletions;
self.files_changed += other.files_changed;
self.net_lines = self.calculate_net_lines();
}
pub fn update_net_lines(&mut self) {
self.net_lines = self.calculate_net_lines();
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AnalysisResult {
pub repository: String,
pub period: String,
#[serde(serialize_with = "serialize_date")]
pub from: NaiveDate,
#[serde(serialize_with = "serialize_date")]
pub to: NaiveDate,
pub stats: Vec<PeriodStats>,
pub total: TotalStats,
}
impl AnalysisResult {
#[must_use]
pub fn new(
repository: String,
period: String,
from: NaiveDate,
to: NaiveDate,
stats: Vec<PeriodStats>,
) -> Self {
let total = TotalStats::from_periods(&stats);
Self {
repository,
period,
from,
to,
stats,
total,
}
}
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct TotalStats {
pub commits: u32,
pub additions: u64,
pub deletions: u64,
pub net_lines: i64,
pub files_changed: u32,
}
impl TotalStats {
#[must_use]
pub fn from_periods(periods: &[PeriodStats]) -> Self {
let mut total = Self::default();
for p in periods {
total.commits += p.commits;
total.additions += p.additions;
total.deletions += p.deletions;
total.files_changed += p.files_changed;
}
total.net_lines = total.additions as i64 - total.deletions as i64;
total
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_days_creation() {
let days = Days::new(7);
assert_eq!(days.value(), 7);
let days: Days = 30.into();
assert_eq!(days.value(), 30);
}
#[test]
fn test_date_range_last_n_days() {
let range = DateRange::last_n_days(Days::new(7));
let today = Utc::now().date_naive();
assert_eq!(range.to, today);
assert!(range.from < range.to);
}
#[test]
fn test_date_range_contains() {
let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let to = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let range = DateRange::new(from, to);
assert!(range.contains(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap()));
assert!(range.contains(from));
assert!(range.contains(to));
assert!(!range.contains(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()));
assert!(!range.contains(NaiveDate::from_ymd_opt(2024, 2, 1).unwrap()));
}
#[test]
fn test_date_range_iter_days() {
let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let to = NaiveDate::from_ymd_opt(2024, 1, 3).unwrap();
let range = DateRange::new(from, to);
let days: Vec<_> = range.iter_days().collect();
assert_eq!(days.len(), 3);
assert_eq!(days[0], from);
assert_eq!(days[2], to);
}
#[test]
fn test_period_stats_merge() {
let mut stats1 = PeriodStats {
commits: 5,
additions: 100,
deletions: 20,
..Default::default()
};
stats1.update_net_lines();
let stats2 = PeriodStats {
commits: 3,
additions: 50,
deletions: 10,
..Default::default()
};
stats1.merge(&stats2);
assert_eq!(stats1.commits, 8);
assert_eq!(stats1.additions, 150);
assert_eq!(stats1.deletions, 30);
assert_eq!(stats1.net_lines, 120);
}
#[test]
fn test_total_stats_from_periods() {
let periods = vec![
PeriodStats {
commits: 5,
additions: 100,
deletions: 20,
files_changed: 10,
..Default::default()
},
PeriodStats {
commits: 3,
additions: 50,
deletions: 10,
files_changed: 5,
..Default::default()
},
];
let total = TotalStats::from_periods(&periods);
assert_eq!(total.commits, 8);
assert_eq!(total.additions, 150);
assert_eq!(total.deletions, 30);
assert_eq!(total.net_lines, 120);
assert_eq!(total.files_changed, 15);
}
#[test]
fn test_analysis_result_serialization() {
let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let to = NaiveDate::from_ymd_opt(2024, 1, 7).unwrap();
let result = AnalysisResult::new(
"test-repo".to_string(),
"daily".to_string(),
from,
to,
vec![],
);
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"repository\":\"test-repo\""));
assert!(json.contains("\"from\":\"2024-01-01\""));
}
}