use chrono::{DateTime, SecondsFormat, Utc};
use crate::error::ParseError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ParseDate(DateTime<Utc>);
impl ParseDate {
pub fn from_datetime(dt: DateTime<Utc>) -> Self {
let ms = dt.timestamp_millis();
Self(DateTime::from_timestamp_millis(ms).unwrap_or(dt))
}
pub fn now() -> Self {
Self::from_datetime(Utc::now())
}
pub fn timestamp_millis(&self) -> i64 {
self.0.timestamp_millis()
}
pub fn as_datetime(&self) -> DateTime<Utc> {
self.0
}
pub fn to_iso(&self) -> String {
self.0.to_rfc3339_opts(SecondsFormat::Millis, true)
}
pub fn parse_iso(s: &str) -> Result<Self, ParseError> {
DateTime::parse_from_rfc3339(s)
.map(|dt| Self::from_datetime(dt.with_timezone(&Utc)))
.map_err(|e| ParseError::invalid_json(format!("invalid date: {s}: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iso_form_is_exactly_upstreams() {
let d = ParseDate::parse_iso("2026-08-14T13:34:33.581Z").unwrap();
assert_eq!(d.to_iso(), "2026-08-14T13:34:33.581Z");
}
#[test]
fn always_three_fractional_digits() {
let d = ParseDate::parse_iso("2026-01-02T03:04:05Z").unwrap();
assert_eq!(d.to_iso(), "2026-01-02T03:04:05.000Z");
assert_eq!(
ParseDate::parse_iso("2026-01-02T03:04:05.5Z")
.unwrap()
.to_iso(),
"2026-01-02T03:04:05.500Z"
);
}
#[test]
fn offsets_normalize_to_utc_z() {
let d = ParseDate::parse_iso("2026-08-14T15:34:33.581+02:00").unwrap();
assert_eq!(d.to_iso(), "2026-08-14T13:34:33.581Z");
assert!(!d.to_iso().contains("+00:00"), "must emit Z, never +00:00");
}
#[test]
fn sub_millisecond_input_truncates_at_construction() {
let a = ParseDate::parse_iso("2026-01-01T00:00:00.123456Z").unwrap();
let b = ParseDate::parse_iso("2026-01-01T00:00:00.123Z").unwrap();
assert_eq!(a, b);
assert_eq!(a.to_iso(), b.to_iso());
}
#[test]
fn round_trips_losslessly() {
for s in [
"1970-01-01T00:00:00.000Z",
"2026-08-14T13:34:33.581Z",
"1969-12-31T23:59:59.999Z", "2100-12-31T23:59:59.999Z",
] {
let d = ParseDate::parse_iso(s).unwrap();
assert_eq!(d.to_iso(), s);
assert_eq!(ParseDate::parse_iso(&d.to_iso()).unwrap(), d);
}
}
#[test]
fn rejects_garbage_with_a_parse_error() {
let e = ParseDate::parse_iso("not a date").unwrap_err();
assert_eq!(e.code, crate::error::ErrorCode::InvalidJson);
assert!(ParseDate::parse_iso("").is_err());
assert!(ParseDate::parse_iso("2026-08-14").is_err());
}
}