use serde::Deserialize;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
pub fn lenient<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned,
{
let raw = serde_json::Value::deserialize(deserializer)?;
Ok(T::deserialize(raw).ok())
}
pub const STALE_AFTER_YEARS: i32 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Age {
pub label: String,
pub stale: bool,
}
pub fn now() -> OffsetDateTime {
OffsetDateTime::now_utc()
}
pub fn parse(ts: &str) -> Option<OffsetDateTime> {
OffsetDateTime::parse(ts, &Rfc3339).ok()
}
fn canonical(dt: OffsetDateTime) -> Option<String> {
dt.to_offset(time::UtcOffset::UTC)
.replace_nanosecond(0)
.ok()?
.format(&Rfc3339)
.ok()
}
pub fn from_rfc3339(ts: &str) -> Option<String> {
canonical(parse(ts)?)
}
pub fn from_unix_secs(secs: i64) -> Option<String> {
canonical(OffsetDateTime::from_unix_timestamp(secs).ok()?)
}
pub fn from_unix_millis(millis: i64) -> Option<String> {
canonical(OffsetDateTime::from_unix_timestamp_nanos(millis as i128 * 1_000_000).ok()?)
}
pub fn from_go_date(text: &str) -> Option<String> {
let cleaned = text.trim().replace(',', "");
let mut parts = cleaned.split_whitespace();
let month = match parts.next()?.to_ascii_lowercase().as_str() {
"jan" => time::Month::January,
"feb" => time::Month::February,
"mar" => time::Month::March,
"apr" => time::Month::April,
"may" => time::Month::May,
"jun" => time::Month::June,
"jul" => time::Month::July,
"aug" => time::Month::August,
"sep" => time::Month::September,
"oct" => time::Month::October,
"nov" => time::Month::November,
"dec" => time::Month::December,
_ => return None,
};
let day: u8 = parts.next()?.parse().ok()?;
let year: i32 = parts.next()?.parse().ok()?;
let date = time::Date::from_calendar_date(year, month, day).ok()?;
canonical(date.midnight().assume_utc())
}
pub fn age(ts: &str, now: OffsetDateTime) -> Option<Age> {
let then = parse(ts)?;
let days = (now - then).whole_days();
let mut years = now.year() - then.year();
if (now.month() as u8, now.day()) < (then.month() as u8, then.day()) {
years -= 1;
}
let label = match days {
d if d <= 0 => "today".to_string(),
1 => "yesterday".to_string(),
d if d < 31 => format!("{d} days ago"),
_ if years < 1 => match (days / 30).clamp(1, 11) {
1 => "1 month ago".to_string(),
m => format!("{m} months ago"),
},
_ => match years {
1 => "1 year ago".to_string(),
y => format!("{y} years ago"),
},
};
Some(Age {
label,
stale: years >= STALE_AFTER_YEARS,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn at(ts: &str) -> OffsetDateTime {
parse(ts).expect("test timestamp must parse")
}
#[test]
fn normalises_every_registry_format_to_the_same_shape() {
assert_eq!(
from_rfc3339("2026-05-15T06:13:41.215606Z").as_deref(), Some("2026-05-15T06:13:41Z")
);
assert_eq!(
from_rfc3339("2026-07-21T15:41:28.716Z").as_deref(), Some("2026-07-21T15:41:28Z")
);
assert_eq!(
from_rfc3339("2026-07-31T18:49:43Z").as_deref(), Some("2026-07-31T18:49:43Z")
);
assert_eq!(
from_unix_millis(1_750_337_811_233).as_deref(), Some("2025-06-19T12:56:51Z")
);
assert_eq!(
from_unix_secs(1_778_477_360).as_deref(), Some("2026-05-11T05:29:20Z")
);
assert_eq!(
from_go_date("Feb 28, 2026").as_deref(), Some("2026-02-28T00:00:00Z")
);
}
#[test]
fn non_utc_offsets_are_converted_rather_than_truncated() {
assert_eq!(
from_rfc3339("2026-05-15T06:13:41+02:00").as_deref(),
Some("2026-05-15T04:13:41Z")
);
}
#[test]
fn malformed_input_yields_none_and_never_panics() {
for junk in [
"",
" ",
"not a date",
"2026-13-45T99:99:99Z",
"1750337811233",
"Feb 2026",
"Smarch 3, 2026",
"Feb 30, 2026",
] {
assert_eq!(from_rfc3339(junk), None, "from_rfc3339({junk:?})");
assert_eq!(from_go_date(junk), None, "from_go_date({junk:?})");
assert_eq!(age(junk, now()), None, "age({junk:?})");
}
assert_eq!(from_unix_secs(i64::MAX), None);
assert_eq!(from_unix_millis(i64::MAX), None);
}
#[test]
fn labels_read_naturally_across_the_scale() {
let now = at("2026-08-02T00:00:00Z");
let cases = [
("2026-08-02T00:00:00Z", "today"),
("2026-08-01T00:00:00Z", "yesterday"),
("2026-07-20T00:00:00Z", "13 days ago"),
("2026-06-02T00:00:00Z", "2 months ago"),
("2026-06-25T00:00:00Z", "1 month ago"),
("2025-09-02T00:00:00Z", "11 months ago"),
("2025-08-02T00:00:00Z", "1 year ago"),
("2021-08-02T00:00:00Z", "5 years ago"),
("2025-08-07T00:00:00Z", "11 months ago"),
("2025-08-03T00:00:00Z", "11 months ago"),
("2025-08-01T00:00:00Z", "1 year ago"),
("2024-08-03T00:00:00Z", "1 year ago"),
];
for (ts, want) in cases {
assert_eq!(age(ts, now).unwrap().label, want, "age({ts})");
}
}
#[test]
fn stale_boundary_sits_exactly_at_the_two_year_anniversary() {
let now = at("2026-08-02T00:00:00Z");
assert!(!age("2024-08-03T00:00:00Z", now).unwrap().stale);
assert!(age("2024-08-02T00:00:00Z", now).unwrap().stale);
assert!(age("2024-08-01T00:00:00Z", now).unwrap().stale);
}
#[test]
fn whole_calendar_years_are_not_lost_to_leap_days() {
let now = at("2026-08-02T00:00:00Z");
assert_eq!(
age("2021-08-02T00:00:00Z", now).unwrap().label,
"5 years ago"
);
assert_eq!(
age("2021-08-01T00:00:00Z", now).unwrap().label,
"5 years ago"
);
assert_eq!(
age("2021-08-03T00:00:00Z", now).unwrap().label,
"4 years ago"
);
}
#[test]
fn future_timestamps_are_not_stale() {
let now = at("2026-08-02T00:00:00Z");
let a = age("2027-01-01T00:00:00Z", now).unwrap();
assert_eq!(a.label, "today");
assert!(!a.stale, "clock skew must never read as abandonment");
}
}