Skip to main content

proto_blue_syntax/
datetime.rs

1//! Datetime validation and types.
2//!
3//! AT Protocol datetimes follow a strict subset of RFC 3339.
4//! See: <https://atproto.com/specs/lexicon#datetime>
5
6use chrono::{DateTime, FixedOffset, SecondsFormat, Utc};
7use once_cell::sync::Lazy;
8use regex::Regex;
9use std::fmt;
10use std::str::FromStr;
11
12/// Maximum length of a datetime string.
13const MAX_DATETIME_LENGTH: usize = 64;
14
15static DATETIME_REGEX: Lazy<Regex> = Lazy::new(|| {
16    Regex::new(
17        r"^[0-9]{4}-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-6][0-9]:[0-6][0-9](\.[0-9]{1,20})?(Z|([+-][0-2][0-9]:[0-5][0-9]))$",
18    )
19    .unwrap()
20});
21
22/// A validated AT Protocol datetime string.
23///
24/// Format: `YYYY-MM-DDTHH:mm:ss(.fractional)?(Z|±HH:mm)`
25#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
26pub struct Datetime(String);
27
28/// Error returned when a datetime string is invalid.
29#[derive(Debug, Clone, thiserror::Error)]
30#[error("Invalid datetime: {reason}")]
31pub struct InvalidDatetimeError {
32    pub reason: String,
33}
34
35impl Datetime {
36    /// Create a new `Datetime` from a string, validating the format.
37    pub fn new(s: &str) -> Result<Self, InvalidDatetimeError> {
38        ensure_valid_datetime(s)?;
39        Ok(Datetime(s.to_string()))
40    }
41
42    /// Check whether a string is a valid datetime.
43    pub fn is_valid(s: &str) -> bool {
44        ensure_valid_datetime(s).is_ok()
45    }
46
47    /// Return the inner string.
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51
52    /// Consume and return the inner string.
53    pub fn into_inner(self) -> String {
54        self.0
55    }
56
57    /// Produce an RFC 3339 datetime string for "now" with millisecond
58    /// precision and a UTC `Z` suffix — the canonical shape used by
59    /// atproto record `createdAt` fields. Mirrors TS
60    /// `currentDatetimeString`.
61    pub fn now() -> Self {
62        let s = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
63        // Safe — the formatter emits a valid atproto datetime shape.
64        Datetime(s)
65    }
66
67    /// Convert a `chrono::DateTime<Utc>` to a canonical atproto
68    /// datetime string (millisecond precision, `Z` suffix). Mirrors TS
69    /// `toDatetimeString(date)`.
70    pub fn from_utc(dt: DateTime<Utc>) -> Self {
71        Datetime(dt.to_rfc3339_opts(SecondsFormat::Millis, true))
72    }
73}
74
75/// Free-function shortcut for [`Datetime::now`], matching TS naming.
76pub fn current_datetime_string() -> String {
77    Datetime::now().into_inner()
78}
79
80fn ensure_valid_datetime(s: &str) -> Result<(), InvalidDatetimeError> {
81    let err = |reason: &str| InvalidDatetimeError {
82        reason: reason.to_string(),
83    };
84
85    if s.len() > MAX_DATETIME_LENGTH {
86        return Err(err(&format!(
87            "Datetime too long ({} chars, max {})",
88            s.len(),
89            MAX_DATETIME_LENGTH
90        )));
91    }
92
93    // Syntactic gate: enforce atproto-specific strictness (2-digit zero
94    // padding, uppercase `T`/`Z`, exact offset shape, ≤20 fractional digits)
95    // that the more permissive RFC 3339 parser would otherwise accept.
96    if !DATETIME_REGEX.is_match(s) {
97        return Err(err("Datetime does not match RFC 3339 format"));
98    }
99
100    // Cannot use -00:00 offset (use Z for UTC). RFC 3339 permits -00:00
101    // to signal "unknown offset"; atproto bans it.
102    if s.ends_with("-00:00") {
103        return Err(err("Datetime cannot use -00:00 offset; use Z for UTC"));
104    }
105
106    // Cannot start with 000 (too close to year zero).
107    if s.starts_with("000") {
108        return Err(err("Datetime year cannot start with 000"));
109    }
110
111    // Semantic gate: reject calendar-invalid values that pass the regex
112    // (month 0, month 13, day 31 in a 30-day month, day 29 in a non-leap
113    // Feb, hour 25, minute 60, second 61, etc.). chrono enforces all of
114    // these when parsing RFC 3339.
115    DateTime::parse_from_rfc3339(s).map_err(|e| err(&format!("Invalid datetime value: {e}")))?;
116
117    Ok(())
118}
119
120/// Normalize a datetime string to canonical `YYYY-MM-DDTHH:mm:ss.sssZ` form.
121///
122/// The returned string:
123/// - is in UTC (any non-Z offset is converted, with correct day/month/year
124///   rollover via `chrono`);
125/// - has exactly three fractional-second digits, truncated (not rounded)
126///   from longer inputs to match the TS SDK's `Date.toISOString()` output.
127pub fn normalize_datetime(s: &str) -> Result<String, InvalidDatetimeError> {
128    ensure_valid_datetime(s)?;
129
130    // chrono parses RFC 3339 with any offset and gives us a correctly-adjusted
131    // UTC instant. Regex + ensure_valid_datetime already guaranteed the input
132    // is a shape it understands, so a parse failure here would be a bug.
133    let parsed: DateTime<FixedOffset> =
134        DateTime::parse_from_rfc3339(s).map_err(|e| InvalidDatetimeError {
135            reason: format!("internal: RFC 3339 reparse failed after validation: {e}"),
136        })?;
137    let utc: DateTime<Utc> = parsed.with_timezone(&Utc);
138
139    // Millisecond precision mirrors `Date.toISOString()` in the TS SDK.
140    Ok(utc.to_rfc3339_opts(SecondsFormat::Millis, /*use_z=*/ true))
141}
142
143impl fmt::Display for Datetime {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        f.write_str(&self.0)
146    }
147}
148
149impl FromStr for Datetime {
150    type Err = InvalidDatetimeError;
151    fn from_str(s: &str) -> Result<Self, Self::Err> {
152        Datetime::new(s)
153    }
154}
155
156impl AsRef<str> for Datetime {
157    fn as_ref(&self) -> &str {
158        &self.0
159    }
160}
161
162impl serde::Serialize for Datetime {
163    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
164        self.0.serialize(serializer)
165    }
166}
167
168impl<'de> serde::Deserialize<'de> for Datetime {
169    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
170        let s = String::deserialize(deserializer)?;
171        Datetime::new(&s).map_err(serde::de::Error::custom)
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn valid_datetimes() {
181        let cases = [
182            "2023-11-15T12:30:00Z",
183            "2023-11-15T12:30:00.123Z",
184            "2023-11-15T12:30:00+05:30",
185            "2023-11-15T12:30:00-08:00",
186            "2023-11-15T12:30:00.1Z",
187            "2023-11-15T12:30:00.12345678901234567890Z",
188        ];
189        for dt in &cases {
190            assert!(Datetime::new(dt).is_ok(), "should be valid: {dt}");
191        }
192    }
193
194    #[test]
195    fn invalid_datetimes() {
196        assert!(Datetime::new("").is_err(), "empty");
197        assert!(Datetime::new("2023-11-15").is_err(), "date only");
198        assert!(Datetime::new("2023-11-15T12:30:00").is_err(), "no timezone");
199        assert!(
200            Datetime::new("2023-11-15T12:30:00-00:00").is_err(),
201            "-00:00 not allowed"
202        );
203        assert!(
204            Datetime::new("0001-01-01T00:00:00Z").is_err(),
205            "year starts with 000"
206        );
207    }
208
209    #[test]
210    fn normalize() {
211        let result = normalize_datetime("2023-11-15T12:30:00Z").unwrap();
212        assert_eq!(result, "2023-11-15T12:30:00.000Z");
213
214        let result = normalize_datetime("2023-11-15T12:30:00.1Z").unwrap();
215        assert_eq!(result, "2023-11-15T12:30:00.100Z");
216
217        let result = normalize_datetime("2023-11-15T12:30:00.123456Z").unwrap();
218        assert_eq!(result, "2023-11-15T12:30:00.123Z");
219    }
220
221    /// Regression: the previous hand-rolled normalizer admitted in a comment
222    /// that it "doesn't handle month boundaries perfectly". `+HH:MM` means
223    /// local-is-ahead-of-UTC, so `UTC = local - offset`; `-HH:MM` means
224    /// local-is-behind-UTC, so `UTC = local + offset`. We exercise each
225    /// direction across month, year, and leap-day boundaries.
226    #[test]
227    fn normalize_handles_month_and_year_rollover() {
228        // Early Feb 1 in a +02:00 zone → UTC rolls BACK to Jan 31.
229        assert_eq!(
230            normalize_datetime("2023-02-01T00:30:00+02:00").unwrap(),
231            "2023-01-31T22:30:00.000Z",
232        );
233        // Late Feb 28 (non-leap) in a -02:00 zone → UTC rolls FORWARD to Mar 1.
234        assert_eq!(
235            normalize_datetime("2023-02-28T23:30:00-02:00").unwrap(),
236            "2023-03-01T01:30:00.000Z",
237        );
238        // Leap-year Feb 29 exists and stays Feb 29 in UTC.
239        assert_eq!(
240            normalize_datetime("2024-02-29T12:00:00Z").unwrap(),
241            "2024-02-29T12:00:00.000Z",
242        );
243        // Early Jan 1 in a +02:00 zone → UTC rolls BACK to Dec 31 of the
244        // previous year.
245        assert_eq!(
246            normalize_datetime("2024-01-01T01:00:00+02:00").unwrap(),
247            "2023-12-31T23:00:00.000Z",
248        );
249        // Late leap-year Feb 29 in a -02:00 zone → UTC rolls FORWARD to Mar 1.
250        assert_eq!(
251            normalize_datetime("2024-02-29T23:00:00-02:00").unwrap(),
252            "2024-03-01T01:00:00.000Z",
253        );
254    }
255
256    /// Regression: semantic validation must reject calendar-invalid values
257    /// that pass the regex (issue #1).
258    #[test]
259    fn rejects_semantically_invalid_datetimes() {
260        let bad = [
261            "1985-00-12T23:20:50.123Z", // month 0
262            "1985-13-12T23:20:50.123Z", // month 13
263            "1985-04-00T23:20:50.123Z", // day 0
264            "1985-04-31T23:20:50.123Z", // April only has 30 days
265            "2023-02-29T12:00:00Z",     // non-leap year Feb 29
266            "1985-04-12T25:20:50.123Z", // hour 25
267            "1985-04-12T23:99:50.123Z", // minute 99
268            "1985-04-12T23:20:61.123Z", // second 61
269        ];
270        for s in bad {
271            assert!(
272                Datetime::new(s).is_err(),
273                "should reject semantically-invalid datetime {s:?}"
274            );
275        }
276    }
277
278    /// Leap seconds (`:60`) are legal in RFC 3339 *and* chrono parses them
279    /// by rolling forward. We must still accept them if the TS SDK does.
280    #[test]
281    fn leap_second_is_accepted_or_rejected_consistently() {
282        // We don't require leap-second support either way, but we must not
283        // panic, and the answer must match what we'd say for :59 of the
284        // same minute.
285        let _ = Datetime::new("1985-04-12T23:20:60Z");
286    }
287}