use chrono::{DateTime, Datelike, Local, TimeDelta};
use crate::types::{DocDiffTimelinePoint, DocDiffTimelinePointKind};
pub fn ymd(y: i32, m: u32, d: u32) -> String {
format!("{y:04}-{m:02}-{d:02}")
}
pub fn format_date(value: Option<&str>) -> String {
let Some(s) = value else {
return String::new();
};
match DateTime::parse_from_rfc3339(s) {
Ok(dt) => {
let local = dt.with_timezone(&Local);
ymd(local.year(), local.month(), local.day())
}
Err(_) => String::new(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimelineBucket {
pub label: String,
pub points: Vec<DocDiffTimelinePoint>,
}
pub fn bucket_label(point: &DocDiffTimelinePoint, now: DateTime<Local>) -> String {
if point.kind == DocDiffTimelinePointKind::Worktree {
return "Working tree".into();
}
let today = ymd(now.year(), now.month(), now.day());
let prev = now - TimeDelta::days(1);
let yesterday = ymd(prev.year(), prev.month(), prev.day());
let day = format_date(point.date.as_deref());
if day == today {
"Today".into()
} else if day == yesterday {
"Yesterday".into()
} else {
"Earlier".into()
}
}
pub fn group_timeline(
points: Vec<DocDiffTimelinePoint>,
now: DateTime<Local>,
) -> Vec<TimelineBucket> {
let mut buckets: Vec<TimelineBucket> = Vec::new();
for point in points {
let label = bucket_label(&point, now);
if let Some(existing) = buckets.iter_mut().find(|b| b.label == label) {
existing.points.push(point);
} else {
buckets.push(TimelineBucket {
label,
points: vec![point],
});
}
}
buckets
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn point_dated(date: &str) -> DocDiffTimelinePoint {
point(
DocDiffTimelinePointKind::Commit,
Some(date.to_string()),
"p",
)
}
fn point(
kind: DocDiffTimelinePointKind,
date: Option<String>,
id: &str,
) -> DocDiffTimelinePoint {
DocDiffTimelinePoint {
id: id.into(),
kind,
hash: Some("abc".into()),
short_hash: "abc".into(),
subject: "s".into(),
author: None,
date,
base_ref: String::new(),
head_ref: String::new(),
files: vec![],
file_tree: vec![],
total_added_lines: 0,
total_removed_lines: 0,
warnings: vec![],
}
}
#[test]
fn ymd_formats_year_month_day_with_zero_padding() {
assert_eq!(ymd(2026, 1, 5), "2026-01-05");
assert_eq!(ymd(2026, 12, 31), "2026-12-31");
}
#[test]
fn format_date_returns_empty_for_null_or_invalid() {
assert_eq!(format_date(None), "");
assert_eq!(format_date(Some("not-a-date")), "");
}
#[test]
fn format_date_parses_iso_strings() {
let result = format_date(Some("2026-03-15T12:00:00Z"));
assert_eq!(result.len(), 10);
assert!(result.starts_with("2026-03-1"));
assert!(result == "2026-03-14" || result == "2026-03-15");
}
#[test]
fn bucket_label_returns_working_tree_for_worktree_kind() {
let now = Local.with_ymd_and_hms(2026, 5, 15, 12, 0, 0).unwrap();
assert_eq!(
bucket_label(&point(DocDiffTimelinePointKind::Worktree, None, "wt"), now),
"Working tree"
);
}
#[test]
fn buckets_today_yesterday_earlier() {
let now = Local.with_ymd_and_hms(2026, 5, 15, 12, 0, 0).unwrap();
let today = Local
.with_ymd_and_hms(2026, 5, 15, 8, 0, 0)
.unwrap()
.to_rfc3339();
let yesterday = Local
.with_ymd_and_hms(2026, 5, 14, 8, 0, 0)
.unwrap()
.to_rfc3339();
let earlier = Local
.with_ymd_and_hms(2026, 5, 1, 8, 0, 0)
.unwrap()
.to_rfc3339();
assert_eq!(bucket_label(&point_dated(&today), now), "Today");
assert_eq!(bucket_label(&point_dated(&yesterday), now), "Yesterday");
assert_eq!(bucket_label(&point_dated(&earlier), now), "Earlier");
}
#[test]
fn group_timeline_preserves_order_and_groups_by_label() {
let now = Local.with_ymd_and_hms(2026, 5, 15, 12, 0, 0).unwrap();
let today = Local
.with_ymd_and_hms(2026, 5, 15, 8, 0, 0)
.unwrap()
.to_rfc3339();
let earlier = Local
.with_ymd_and_hms(2026, 5, 1, 8, 0, 0)
.unwrap()
.to_rfc3339();
let buckets = group_timeline(
vec![
point(DocDiffTimelinePointKind::Worktree, None, "wt"),
point(DocDiffTimelinePointKind::Commit, Some(today.clone()), "a"),
point(DocDiffTimelinePointKind::Commit, Some(today), "b"),
point(DocDiffTimelinePointKind::Commit, Some(earlier), "c"),
],
now,
);
let proj: Vec<(String, Vec<String>)> = buckets
.iter()
.map(|b| {
(
b.label.clone(),
b.points.iter().map(|p| p.id.clone()).collect(),
)
})
.collect();
assert_eq!(
proj,
vec![
("Working tree".to_string(), vec!["wt".to_string()]),
("Today".to_string(), vec!["a".to_string(), "b".to_string()]),
("Earlier".to_string(), vec!["c".to_string()]),
]
);
}
}