Skip to main content

alibi_core/utils/datetime/
mod.rs

1//! JSON timestamps matching JavaScript's `Date.toJSON()` precision.
2
3mod parse;
4use chrono::{DateTime, Datelike, Duration, NaiveDate, SecondsFormat, Utc};
5pub use parse::parse_date_millis;
6use serde::Serializer;
7
8/// Serialize a timestamp with exactly three fractional digits and a UTC suffix.
9///
10/// The official client interprets fractional digits as milliseconds. Chrono's
11/// automatic microsecond precision can otherwise shift a parsed client date.
12///
13/// # Errors
14///
15/// Propagates errors from the serializer.
16pub fn serialize<S: Serializer>(value: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error> {
17    serializer.serialize_str(&value.to_rfc3339_opts(SecondsFormat::Millis, true))
18}
19
20/// Serialize a nullable timestamp using the same millisecond precision.
21///
22/// # Errors
23///
24/// Propagates errors from the serializer.
25pub fn serialize_optional<S: Serializer>(
26    value: &Option<DateTime<Utc>>,
27    serializer: S,
28) -> Result<S::Ok, S::Error> {
29    match value {
30        Some(value) => {
31            serializer.serialize_some(&value.to_rfc3339_opts(SecondsFormat::Millis, true))
32        }
33        None => serializer.serialize_none(),
34    }
35}
36
37/// Normalize the UTC ISO strings revived by Source's `safeJSONParse`.
38///
39/// Only its exact four-digit-year shape is accepted. JavaScript permits day
40/// overflow and midnight at 24:00, truncates fractional seconds, and emits an
41/// expanded six-digit year when midnight advances past year 9999. Invalid
42/// dates remain ordinary strings for authorization and JSON responses.
43#[must_use]
44pub fn normalize_json_date(value: &str) -> Option<String> {
45    let bytes = value.as_bytes();
46    if bytes.len() < 20
47        || !bytes.iter().enumerate().all(|(index, byte)| match index {
48            4 | 7 => *byte == b'-',
49            10 => *byte == b'T',
50            13 | 16 => *byte == b':',
51            19 if bytes.len() > 20 => *byte == b'.',
52            index if index == bytes.len() - 1 => *byte == b'Z',
53            _ => byte.is_ascii_digit(),
54        })
55    {
56        return None;
57    }
58    let year = value.get(..4)?.parse().ok()?;
59    let month = value.get(5..7)?.parse().ok()?;
60    let day: u32 = value.get(8..10)?.parse().ok()?;
61    let hour: u32 = value.get(11..13)?.parse().ok()?;
62    let minute: u32 = value.get(14..16)?.parse().ok()?;
63    let second: u32 = value.get(17..19)?.parse().ok()?;
64    let fraction = if bytes.len() == 20 {
65        ""
66    } else {
67        let fraction = value.get(20..bytes.len() - 1)?;
68        if fraction.is_empty() {
69            return None;
70        }
71        fraction
72    };
73    if !(1..=31).contains(&day)
74        || hour > 24
75        || minute > 59
76        || second > 59
77        || (hour == 24
78            && (minute != 0 || second != 0 || fraction.bytes().any(|digit| digit != b'0')))
79    {
80        return None;
81    }
82    let milliseconds = fraction
83        .bytes()
84        .chain(std::iter::repeat(b'0'))
85        .take(3)
86        .fold(0u32, |value, digit| value * 10 + u32::from(digit - b'0'));
87    let offset = i64::from(day - 1) * 86_400_000
88        + i64::from(hour) * 3_600_000
89        + i64::from(minute) * 60_000
90        + i64::from(second) * 1_000
91        + i64::from(milliseconds);
92    let date = NaiveDate::from_ymd_opt(year, month, 1)?
93        .and_hms_opt(0, 0, 0)?
94        .checked_add_signed(Duration::milliseconds(offset))?;
95    let year = if date.year() > 9999 {
96        format!("+{:06}", date.year())
97    } else {
98        format!("{:04}", date.year())
99    };
100    Some(format!("{year}{}", date.format("-%m-%dT%H:%M:%S%.3fZ")))
101}
102
103/// JavaScript Date JSON at every valid `TimeClip` millisecond, including dates
104/// outside Chrono's physical model range. Callers must supply a `TimeClip` value.
105#[must_use]
106pub fn json_date_millis(millis: i64) -> String {
107    let days = millis.div_euclid(86_400_000);
108    let shifted = days + 719_468;
109    let era = shifted.div_euclid(146_097);
110    let day_of_era = shifted - era * 146_097;
111    let year_of_era =
112        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
113    let mut year = year_of_era + era * 400;
114    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
115    let month_index = (5 * day_of_year + 2) / 153;
116    let day = day_of_year - (153 * month_index + 2) / 5 + 1;
117    let month = month_index + if month_index < 10 { 3 } else { -9 };
118    year += i64::from(month <= 2);
119    let year = if (0..=9999).contains(&year) {
120        format!("{year:04}")
121    } else {
122        format!("{year:+07}")
123    };
124    let time = millis.rem_euclid(86_400_000);
125    format!(
126        "{year}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
127        time / 3_600_000,
128        time / 60_000 % 60,
129        time / 1000 % 60,
130        time % 1000
131    )
132}
133
134// LCOV_EXCL_START
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use serde::{Deserialize, Serialize};
139
140    #[derive(Serialize, Deserialize)]
141    struct Timestamps {
142        #[serde(serialize_with = "super::serialize")]
143        created_at: DateTime<Utc>,
144        #[serde(serialize_with = "super::serialize_optional")]
145        expires_at: Option<DateTime<Utc>>,
146    }
147
148    #[test]
149    fn wire_precision_is_milliseconds_for_every_source_precision() {
150        for (input, expected) in [
151            ("2026-09-30T12:00:00Z", "2026-09-30T12:00:00.000Z"),
152            ("2026-09-30T12:00:00.123Z", "2026-09-30T12:00:00.123Z"),
153            ("2026-09-30T12:00:00.123456Z", "2026-09-30T12:00:00.123Z"),
154            ("2026-09-30T12:00:00.123456789Z", "2026-09-30T12:00:00.123Z"),
155        ] {
156            let value: DateTime<Utc> = input.parse().expect("valid timestamp");
157            let encoded = serde_json::to_value(Timestamps {
158                created_at: value,
159                expires_at: Some(value),
160            })
161            .expect("serialize timestamps");
162            assert_eq!(
163                (*(encoded)
164                    .get("created_at")
165                    .unwrap_or(&serde_json::Value::Null)),
166                expected
167            );
168            assert_eq!(
169                (*(encoded)
170                    .get("expires_at")
171                    .unwrap_or(&serde_json::Value::Null)),
172                expected
173            );
174            let decoded: Timestamps =
175                serde_json::from_value(encoded).expect("deserialize timestamps");
176            assert_eq!(
177                decoded.created_at.timestamp_millis(),
178                value.timestamp_millis()
179            );
180            assert_eq!(
181                decoded.expires_at.map(|date| date.timestamp_millis()),
182                Some(value.timestamp_millis())
183            );
184        }
185    }
186
187    #[test]
188    fn optional_null_remains_null_and_input_keeps_full_precision() {
189        let value: Timestamps = serde_json::from_value(
190            serde_json::json!({"created_at":"2026-09-30T12:00:00.123456789Z","expires_at":null}),
191        )
192        .expect("deserialize timestamps");
193        assert_eq!(value.created_at.timestamp_subsec_nanos(), 123_456_789);
194        let encoded = serde_json::to_value(value).expect("serialize timestamps");
195        assert_eq!(
196            (*(encoded)
197                .get("created_at")
198                .unwrap_or(&serde_json::Value::Null)),
199            "2026-09-30T12:00:00.123Z"
200        );
201        assert_eq!(
202            (*(encoded)
203                .get("expires_at")
204                .unwrap_or(&serde_json::Value::Null)),
205            serde_json::Value::Null
206        );
207    }
208}
209// LCOV_EXCL_STOP