Skip to main content

jacquard_common/types/
datetime.rs

1use alloc::string::{String, ToString};
2use core::cmp;
3use core::fmt;
4use core::str::FromStr;
5
6use chrono::DurationRound;
7use serde::Serializer;
8use serde::{Deserialize, Deserializer, Serialize, de::Error};
9use smol_str::{SmolStr, ToSmolStr};
10
11use super::Lazy;
12
13use crate::{CowStr, IntoStatic};
14#[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
15use regex::Regex;
16#[cfg(all(not(target_arch = "wasm32"), not(feature = "std")))]
17use regex_automata::meta::Regex;
18#[cfg(target_arch = "wasm32")]
19use regex_lite::Regex;
20
21/// Regex for ISO 8601 datetime validation per AT Protocol spec
22pub static ISO8601_REGEX: Lazy<Regex> = Lazy::new(|| {
23    Regex::new(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|(\+[0-9]{2}|\-[0-9][1-9]):[0-9]{2})$").unwrap()
24});
25
26/// AT Protocol datetime (ISO 8601 with specific requirements)
27///
28/// Lexicon datetimes use ISO 8601 format with these requirements:
29/// - Must include timezone (strongly prefer UTC with 'Z')
30/// - Requires whole seconds precision minimum
31/// - Supports millisecond and microsecond precision
32/// - Uses uppercase 'T' to separate date and time
33///
34/// Examples: `"1985-04-12T23:20:50.123Z"`, `"2023-01-01T00:00:00+00:00"`
35///
36/// The serialized form is preserved during parsing to ensure exact round-trip serialization.
37#[derive(Clone, Debug, Eq, Hash)]
38pub struct Datetime {
39    /// Serialized form preserved from parsing for round-trip consistency
40    serialized: CowStr<'static>,
41    /// Parsed datetime value for comparisons and operations
42    dt: chrono::DateTime<chrono::FixedOffset>,
43}
44
45impl PartialEq for Datetime {
46    fn eq(&self, other: &Self) -> bool {
47        self.dt == other.dt
48    }
49}
50
51impl Ord for Datetime {
52    fn cmp(&self, other: &Self) -> cmp::Ordering {
53        self.dt.cmp(&other.dt)
54    }
55}
56
57impl PartialOrd for Datetime {
58    fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
59        Some(self.cmp(other))
60    }
61}
62
63impl Datetime {
64    /// Returns a `Datetime` which corresponds to the current date and time in UTC.
65    ///
66    /// The timestamp uses microsecond precision.
67    pub fn now() -> Self {
68        Self::new(chrono::Utc::now().fixed_offset())
69    }
70
71    /// Constructs a new Lexicon timestamp.
72    ///
73    /// The timestamp is rounded to microsecond precision.
74    pub fn new(dt: chrono::DateTime<chrono::FixedOffset>) -> Self {
75        let dt = dt
76            .duration_round(chrono::Duration::microseconds(1))
77            .expect("delta does not exceed limits");
78        // This serialization format is compatible with ISO 8601.
79        let serialized = CowStr::Owned(
80            dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
81                .to_smolstr(),
82        );
83        Self { serialized, dt }
84    }
85
86    /// Infallibly parses a new Lexicon timestamp from a compatible str reference
87    ///
88    /// Panics if invalid. Use the fallible trait implementations or deserialize for input
89    /// you cannot reasonably trust to be properly formatted.
90    pub fn raw_str(s: impl AsRef<str>) -> Self {
91        let s = s.as_ref();
92        if ISO8601_REGEX.is_match(s) {
93            let dt = chrono::DateTime::parse_from_rfc3339(s).expect("valid ISO8601 time string");
94            Self {
95                serialized: CowStr::Borrowed(s).into_static(),
96                dt,
97            }
98        } else {
99            panic!("atproto datetime should be valid ISO8601")
100        }
101    }
102
103    /// Extracts a string slice containing the entire `Datetime`.
104    #[inline]
105    #[must_use]
106    pub fn as_str(&self) -> &str {
107        self.serialized.as_ref()
108    }
109
110    /// Extracts the number of non-leap seconds since January 1, 1970 0:00:00 UTC (aka "UNIX timestamp").
111    ///
112    /// For full access to the underlying DateTime, use the [`AsRef<chrono::DateTime<chrono::FixedOffset>>`] implementation.
113    #[inline]
114    #[must_use]
115    pub fn timestamp(&self) -> i64 {
116        self.dt.timestamp()
117    }
118
119    /// Extracts the number of non-leap-milliseconds since January 1, 1970 UTC.
120    ///
121    /// For full access to the underlying DateTime, use the [`AsRef<chrono::DateTime<chrono::FixedOffset>>`] implementation.
122    #[inline]
123    #[must_use]
124    pub fn timestamp_millis(&self) -> i64 {
125        self.dt.timestamp_millis()
126    }
127
128    /// Extracts the number of non-leap-microseconds since January 1, 1970 UTC.
129    ///
130    /// For full access to the underlying DateTime, use the [`AsRef<chrono::DateTime<chrono::FixedOffset>>`] implementation.
131    #[inline]
132    #[must_use]
133    pub fn timestamp_micros(&self) -> i64 {
134        self.dt.timestamp_micros()
135    }
136}
137
138impl FromStr for Datetime {
139    type Err = chrono::ParseError;
140
141    fn from_str(s: &str) -> Result<Self, Self::Err> {
142        // The `chrono` crate only supports RFC 3339 parsing, but Lexicon restricts
143        // datetimes to the subset that is also valid under ISO 8601. Apply a regex that
144        // validates enough of the relevant ISO 8601 format that the RFC 3339 parser can
145        // do the rest.
146        if ISO8601_REGEX.is_match(s) {
147            let dt = chrono::DateTime::parse_from_rfc3339(s)?;
148            Ok(Self {
149                serialized: CowStr::Owned(s.to_smolstr()),
150                dt,
151            })
152        } else {
153            // Simulate an invalid `ParseError`.
154            Err(chrono::DateTime::parse_from_rfc3339("invalid").expect_err("invalid"))
155        }
156    }
157}
158
159impl<'de> Deserialize<'de> for Datetime {
160    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
161    where
162        D: Deserializer<'de>,
163    {
164        let value: String = Deserialize::deserialize(deserializer)?;
165        Self::from_str(&value).map_err(D::Error::custom)
166    }
167}
168impl Serialize for Datetime {
169    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
170    where
171        S: Serializer,
172    {
173        serializer.serialize_str(&self.serialized)
174    }
175}
176
177impl AsRef<chrono::DateTime<chrono::FixedOffset>> for Datetime {
178    fn as_ref(&self) -> &chrono::DateTime<chrono::FixedOffset> {
179        &self.dt
180    }
181}
182
183impl TryFrom<String> for Datetime {
184    type Error = chrono::ParseError;
185    fn try_from(value: String) -> Result<Self, Self::Error> {
186        if ISO8601_REGEX.is_match(&value) {
187            let dt = chrono::DateTime::parse_from_rfc3339(&value)?;
188            Ok(Self {
189                serialized: CowStr::Owned(value.to_smolstr()),
190                dt,
191            })
192        } else {
193            // Simulate an invalid `ParseError`.
194            Err(chrono::DateTime::parse_from_rfc3339("invalid").expect_err("invalid"))
195        }
196    }
197}
198
199impl TryFrom<CowStr<'_>> for Datetime {
200    type Error = chrono::ParseError;
201    fn try_from(value: CowStr<'_>) -> Result<Self, Self::Error> {
202        if ISO8601_REGEX.is_match(&value) {
203            let dt = chrono::DateTime::parse_from_rfc3339(&value)?;
204            Ok(Self {
205                serialized: value.into_static(),
206                dt,
207            })
208        } else {
209            // Simulate an invalid `ParseError`.
210            Err(chrono::DateTime::parse_from_rfc3339("invalid").expect_err("invalid"))
211        }
212    }
213}
214
215impl From<chrono::DateTime<chrono::FixedOffset>> for Datetime {
216    fn from(dt: chrono::DateTime<chrono::FixedOffset>) -> Self {
217        Self::new(dt)
218    }
219}
220
221impl From<Datetime> for String {
222    fn from(value: Datetime) -> Self {
223        value.serialized.to_string()
224    }
225}
226
227impl From<Datetime> for SmolStr {
228    fn from(value: Datetime) -> Self {
229        match value.serialized {
230            CowStr::Borrowed(s) => SmolStr::new(s),
231            CowStr::Owned(s) => s,
232        }
233    }
234}
235
236impl From<Datetime> for CowStr<'static> {
237    fn from(value: Datetime) -> Self {
238        value.serialized
239    }
240}
241
242impl fmt::Display for Datetime {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        f.write_str(self.as_str())
245    }
246}
247
248impl IntoStatic for Datetime {
249    type Output = Datetime;
250
251    fn into_static(self) -> Self::Output {
252        self
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn valid_datetimes() {
262        assert!(Datetime::from_str("2023-01-15T12:30:45.123456Z").is_ok());
263        assert!(Datetime::from_str("2023-01-15T12:30:45Z").is_ok());
264        assert!(Datetime::from_str("2023-01-15T12:30:45+00:00").is_ok());
265        assert!(Datetime::from_str("2023-01-15T12:30:45-05:00").is_ok());
266    }
267
268    #[test]
269    fn microsecond_precision() {
270        let dt = Datetime::from_str("2023-01-15T12:30:45.123456Z").unwrap();
271        assert!(dt.as_str().contains(".123456"));
272    }
273
274    #[test]
275    fn requires_timezone() {
276        // Missing timezone should fail
277        assert!(Datetime::from_str("2023-01-15T12:30:45").is_err());
278    }
279
280    #[test]
281    fn round_trip() {
282        let original = "2023-01-15T12:30:45.123456Z";
283        let dt = Datetime::from_str(original).unwrap();
284        assert_eq!(dt.as_str(), original);
285    }
286}