jacquard_common/types/
datetime.rs1use 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
12pub 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#[derive(Clone, Debug, Eq, Hash)]
29pub struct Datetime {
30 serialized: CowStr<'static>,
32 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 pub fn now() -> Self {
59 Self::new(chrono::Utc::now().fixed_offset())
60 }
61
62 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 let serialized = CowStr::Owned(
71 dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
72 .to_smolstr(),
73 );
74 Self { serialized, dt }
75 }
76
77 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 #[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 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 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 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 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 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}