jacquard_common/types/
datetime.rs

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