Skip to main content

parse_rust_core/
date.rs

1//! Parse dates.
2//!
3//! The wire form is `{"__type":"Date","iso":"YYYY-MM-DDTHH:MM:SS.mmmZ"}`: ISO 8601, always UTC,
4//! always a literal `Z`, always exactly three fractional digits. Round trips must be lossless.
5//!
6//! **The same logical value has two wire forms depending on position**, and conflating them is a
7//! real bug that a published Rust Parse client shipped:
8//! top-level `createdAt` and `updatedAt` are **bare ISO strings**, while a user-defined Date
9//! field is the `__type` envelope above. `ParseDate` models the value; the encoder decides the
10//! form from where it sits.
11
12use chrono::{DateTime, SecondsFormat, Utc};
13
14use crate::error::ParseError;
15
16/// A Parse date. Millisecond precision, UTC.
17///
18/// Precision is truncated to milliseconds on construction rather than at serialization, so that
19/// two values that will serialize identically also compare equal. Doing it the other way round
20/// makes `a == b` disagree with `encode(a) == encode(b)`, which is the kind of thing that
21/// produces an intermittent conformance failure.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub struct ParseDate(DateTime<Utc>);
24
25impl ParseDate {
26    /// Truncates sub-millisecond precision.
27    ///
28    /// Total by construction. The millisecond count came from a `DateTime`, so reconstructing
29    /// one from it cannot fail, but this is a request path and "cannot fail" is not a reason to
30    /// write a panic. Falling back to the untruncated value degrades precision on a date no
31    /// client can construct, which is a strictly better failure than taking down a worker.
32    pub fn from_datetime(dt: DateTime<Utc>) -> Self {
33        let ms = dt.timestamp_millis();
34        Self(DateTime::from_timestamp_millis(ms).unwrap_or(dt))
35    }
36
37    pub fn now() -> Self {
38        Self::from_datetime(Utc::now())
39    }
40
41    pub fn timestamp_millis(&self) -> i64 {
42        self.0.timestamp_millis()
43    }
44
45    pub fn as_datetime(&self) -> DateTime<Utc> {
46        self.0
47    }
48
49    /// The ISO form Parse emits: exactly three fractional digits, `Z`, never `+00:00`.
50    pub fn to_iso(&self) -> String {
51        self.0.to_rfc3339_opts(SecondsFormat::Millis, true)
52    }
53
54    /// Parse an ISO 8601 string.
55    ///
56    /// Deliberately permissive on input and strict on output, which matches Node: `new Date(s)`
57    /// accepts offsets and varying fractional precision, and `toISOString()` always emits
58    /// millisecond `Z`. So a value read from a database written by another client may carry an
59    /// offset, and it must normalize rather than fail.
60    pub fn parse_iso(s: &str) -> Result<Self, ParseError> {
61        DateTime::parse_from_rfc3339(s)
62            .map(|dt| Self::from_datetime(dt.with_timezone(&Utc)))
63            .map_err(|e| ParseError::invalid_json(format!("invalid date: {s}: {e}")))
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    #[test]
72    fn iso_form_is_exactly_upstreams() {
73        let d = ParseDate::parse_iso("2026-08-14T13:34:33.581Z").unwrap();
74        assert_eq!(d.to_iso(), "2026-08-14T13:34:33.581Z");
75    }
76
77    #[test]
78    fn always_three_fractional_digits() {
79        // Whole seconds still carry .000, which is what Node's toISOString does.
80        let d = ParseDate::parse_iso("2026-01-02T03:04:05Z").unwrap();
81        assert_eq!(d.to_iso(), "2026-01-02T03:04:05.000Z");
82        // One and two digit fractions are padded, not truncated to nothing.
83        assert_eq!(
84            ParseDate::parse_iso("2026-01-02T03:04:05.5Z")
85                .unwrap()
86                .to_iso(),
87            "2026-01-02T03:04:05.500Z"
88        );
89    }
90
91    #[test]
92    fn offsets_normalize_to_utc_z() {
93        let d = ParseDate::parse_iso("2026-08-14T15:34:33.581+02:00").unwrap();
94        assert_eq!(d.to_iso(), "2026-08-14T13:34:33.581Z");
95        assert!(!d.to_iso().contains("+00:00"), "must emit Z, never +00:00");
96    }
97
98    #[test]
99    fn sub_millisecond_input_truncates_at_construction() {
100        let a = ParseDate::parse_iso("2026-01-01T00:00:00.123456Z").unwrap();
101        let b = ParseDate::parse_iso("2026-01-01T00:00:00.123Z").unwrap();
102        // Equality must agree with encoded equality.
103        assert_eq!(a, b);
104        assert_eq!(a.to_iso(), b.to_iso());
105    }
106
107    #[test]
108    fn round_trips_losslessly() {
109        for s in [
110            "1970-01-01T00:00:00.000Z",
111            "2026-08-14T13:34:33.581Z",
112            "1969-12-31T23:59:59.999Z", // pre-epoch, negative millis
113            "2100-12-31T23:59:59.999Z",
114        ] {
115            let d = ParseDate::parse_iso(s).unwrap();
116            assert_eq!(d.to_iso(), s);
117            assert_eq!(ParseDate::parse_iso(&d.to_iso()).unwrap(), d);
118        }
119    }
120
121    #[test]
122    fn rejects_garbage_with_a_parse_error() {
123        let e = ParseDate::parse_iso("not a date").unwrap_err();
124        assert_eq!(e.code, crate::error::ErrorCode::InvalidJson);
125        assert!(ParseDate::parse_iso("").is_err());
126        // A bare date with no time is not RFC 3339 and must not silently become midnight.
127        assert!(ParseDate::parse_iso("2026-08-14").is_err());
128    }
129}