jacquard_common/types/
datetime.rs1use 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
21pub 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#[derive(Clone, Debug, Eq, Hash)]
38pub struct Datetime {
39 serialized: CowStr<'static>,
41 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 pub fn now() -> Self {
68 Self::new(chrono::Utc::now().fixed_offset())
69 }
70
71 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 let serialized = CowStr::Owned(
80 dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
81 .to_smolstr(),
82 );
83 Self { serialized, dt }
84 }
85
86 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 #[inline]
105 #[must_use]
106 pub fn as_str(&self) -> &str {
107 self.serialized.as_ref()
108 }
109
110 #[inline]
114 #[must_use]
115 pub fn timestamp(&self) -> i64 {
116 self.dt.timestamp()
117 }
118
119 #[inline]
123 #[must_use]
124 pub fn timestamp_millis(&self) -> i64 {
125 self.dt.timestamp_millis()
126 }
127
128 #[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 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 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 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 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 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}