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