twilight_model/util/datetime/mod.rs
1//! Utilities for parsing and formatting ISO 8601 timestamps.
2//!
3//! # Examples
4//!
5//! Parse an acceptable ISO 8601 timestamp into a [`Timestamp`]:
6//!
7//! ```
8//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
9//! use std::str::FromStr;
10//! use twilight_model::util::Timestamp;
11//!
12//! let timestamp = Timestamp::from_str("2020-02-02T02:02:02.020000+00:00")?;
13//!
14//! // Check the Unix timestamp, which includes microseconds.
15//! assert_eq!(1_580_608_922_020_000, timestamp.as_micros());
16//! # Ok(()) }
17//! ```
18//!
19//! Format a timestamp as an ISO 8601 string used by the Discord API:
20//!
21//! ```
22//! # use std::error::Error;
23//! # fn foo() -> Result<(), Box<dyn Error>> {
24//! use twilight_model::util::Timestamp;
25//!
26//! let timestamp = Timestamp::from_secs(1_580_608_922)?;
27//!
28//! assert_eq!(
29//! "2020-02-02T02:02:02.000000+00:00",
30//! timestamp.iso_8601().to_string(),
31//! );
32//! # Ok(()) }
33//! ```
34
35#![warn(clippy::missing_docs_in_private_items)]
36
37mod display;
38mod error;
39
40pub use self::{
41 display::TimestampIso8601Display,
42 error::{TimestampParseError, TimestampParseErrorType},
43};
44
45use serde::{
46 de::{Deserialize, Deserializer, Error as DeError, Visitor},
47 ser::{Serialize, Serializer},
48};
49use std::{
50 fmt::{Formatter, Result as FmtResult},
51 str::FromStr,
52};
53use time::{format_description::well_known::Rfc3339, OffsetDateTime, PrimitiveDateTime};
54
55/// Number of microseconds in a second.
56const MICROSECONDS_PER_SECOND: i64 = 1_000_000;
57
58/// Number of nanoseconds in a microsecond.
59const NANOSECONDS_PER_MICROSECOND: i64 = 1_000;
60
61/// Representation of a Unix timestamp.
62///
63/// # Display
64///
65/// The timestamp does not itself implement [`core::fmt::Display`]. It could
66/// have two possible display implementations: that of the Unix timestamp or
67/// that of the timestamp in ISO 8601 format. Therefore, the preferred
68/// implementation may be chosen by explicitly retrieving the Unix timestamp
69/// with [seconds precision], with [microseconds precision], or
70/// [retrieving an ISO 8601 formatter].
71///
72/// [retrieving an ISO 8601 formatter]: Self::iso_8601
73/// [microseconds precision]: Self::as_micros
74/// [seconds precision]: Self::as_secs
75// We use a [`PrimitiveDateTime`] here since it does not store an offset, and
76// the API only operates in UTC. Additionally, it is four bytes smaller than an
77// [`OffsetDateTime`].
78#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
79pub struct Timestamp(PrimitiveDateTime);
80
81impl Timestamp {
82 /// Create a timestamp from a Unix timestamp with microseconds precision.
83 ///
84 /// # Errors
85 ///
86 /// Returns a [`TimestampParseErrorType::Parsing`] error type if the parsing
87 /// failed.
88 ///
89 /// [`TimestampParseErrorType::Parsing`]: self::error::TimestampParseErrorType::Parsing
90 pub fn from_micros(unix_microseconds: i64) -> Result<Self, TimestampParseError> {
91 let nanoseconds = i128::from(unix_microseconds) * i128::from(NANOSECONDS_PER_MICROSECOND);
92
93 OffsetDateTime::from_unix_timestamp_nanos(nanoseconds)
94 .map(|offset| Self(PrimitiveDateTime::new(offset.date(), offset.time())))
95 .map_err(TimestampParseError::from_component_range)
96 }
97
98 /// Create a timestamp from a Unix timestamp with seconds precision.
99 ///
100 /// # Errors
101 ///
102 /// Returns a [`TimestampParseErrorType::Parsing`] error type if the parsing
103 /// failed.
104 ///
105 /// [`TimestampParseErrorType::Parsing`]: self::error::TimestampParseErrorType::Parsing
106 pub fn from_secs(unix_seconds: i64) -> Result<Self, TimestampParseError> {
107 OffsetDateTime::from_unix_timestamp(unix_seconds)
108 .map(|offset| Self(PrimitiveDateTime::new(offset.date(), offset.time())))
109 .map_err(TimestampParseError::from_component_range)
110 }
111
112 /// Parse a timestamp from an ISO 8601 datetime string emitted by Discord.
113 ///
114 /// Discord emits two ISO 8601 valid formats of datetimes: with microseconds
115 /// (2021-01-01T01:01:01.010000+00:00) and without microseconds
116 /// (2021-01-01T01:01:01+00:00). This supports parsing from either.
117 ///
118 /// Supports parsing dates between the Discord epoch year (2010) and 2038.
119 ///
120 /// # Examples
121 ///
122 /// ```
123 /// use std::str::FromStr;
124 /// use twilight_model::util::Timestamp;
125 ///
126 /// // Date and time in UTC with +00:00 offsets are supported:
127 /// assert!(Timestamp::parse("2021-01-01T01:01:01.010000+00:00").is_ok());
128 /// assert!(Timestamp::parse("2021-01-01T01:01:01+00:00").is_ok());
129 ///
130 /// // Other formats, such as dates, weeks, zero UTC offset designators, or
131 /// // ordinal dates are not supported:
132 /// assert!(Timestamp::parse("2021-08-10T18:19:59Z").is_err());
133 /// assert!(Timestamp::parse("2021-01-01").is_err());
134 /// assert!(Timestamp::parse("2021-W32-2").is_err());
135 /// assert!(Timestamp::parse("2021-222").is_err());
136 /// ```
137 ///
138 /// # Errors
139 ///
140 /// Returns a [`TimestampParseErrorType::Format`] error type if the provided
141 /// string is too short to be an ISO 8601 datetime without a time offset.
142 ///
143 /// Returns a [`TimestampParseErrorType::Parsing`] error type if the parsing
144 /// failed.
145 ///
146 /// [`TimestampParseErrorType::Format`]: self::error::TimestampParseErrorType::Format
147 /// [`TimestampParseErrorType::Parsing`]: self::error::TimestampParseErrorType::Parsing
148 pub fn parse(datetime: &str) -> Result<Self, TimestampParseError> {
149 parse_iso8601(datetime).map(Self)
150 }
151
152 /// Total number of seconds within the timestamp.
153 ///
154 /// # Examples
155 ///
156 /// Parse a formatted timestamp and then get its Unix timestamp value with
157 /// seconds precision:
158 ///
159 /// ```
160 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
161 /// use std::str::FromStr;
162 /// use twilight_model::util::Timestamp;
163 ///
164 /// let timestamp = Timestamp::from_str("2021-08-10T11:16:37.020000+00:00")?;
165 /// assert_eq!(1_628_594_197, timestamp.as_secs());
166 /// # Ok(()) }
167 /// ```
168 pub const fn as_secs(self) -> i64 {
169 self.0.assume_utc().unix_timestamp()
170 }
171
172 /// Total number of microseconds within the timestamp.
173 ///
174 /// # Examples
175 ///
176 /// Parse a formatted timestamp and then get its Unix timestamp value with
177 /// microseconds precision:
178 ///
179 /// ```
180 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
181 /// use std::str::FromStr;
182 /// use twilight_model::util::Timestamp;
183 ///
184 /// let timestamp = Timestamp::from_str("2021-08-10T11:16:37.123456+00:00")?;
185 /// assert_eq!(1_628_594_197_123_456, timestamp.as_micros());
186 /// # Ok(()) }
187 /// ```
188 pub const fn as_micros(self) -> i64 {
189 let utc = self.0.assume_utc();
190
191 (utc.unix_timestamp() * MICROSECONDS_PER_SECOND) + (utc.microsecond() as i64)
192 }
193
194 /// Create a Display implementation to format the timestamp as an ISO 8601
195 /// datetime.
196 pub const fn iso_8601(self) -> TimestampIso8601Display {
197 TimestampIso8601Display::new(self)
198 }
199}
200
201impl FromStr for Timestamp {
202 type Err = TimestampParseError;
203
204 /// Parse a timestamp from an ISO 8601 datetime string emitted by Discord.
205 ///
206 /// Discord emits two ISO 8601 valid formats of datetimes: with microseconds
207 /// (2021-01-01T01:01:01.010000+00:00) and without microseconds
208 /// (2021-01-01T01:01:01+00:00). This supports parsing from either.
209 ///
210 /// Supports parsing dates between the Discord epoch year (2010) and 2038.
211 ///
212 /// # Examples
213 ///
214 /// Refer to the documentation for [`Timestamp::parse`] for more examples.
215 ///
216 /// ```
217 /// use std::str::FromStr;
218 /// use twilight_model::util::Timestamp;
219 ///
220 /// assert!(Timestamp::from_str("2021-01-01T01:01:01.010000+00:00").is_ok());
221 /// assert!(Timestamp::from_str("2021-01-01T01:01:01+00:00").is_ok());
222 /// ```
223 ///
224 /// # Errors
225 ///
226 /// Refer to the documentation for [`Timestamp::parse`] for a list of
227 /// errors.
228 fn from_str(s: &str) -> Result<Self, Self::Err> {
229 Timestamp::parse(s)
230 }
231}
232
233impl<'de> Deserialize<'de> for Timestamp {
234 /// Parse a timestamp from an ISO 8601 datetime string emitted by Discord.
235 ///
236 /// Discord emits two ISO 8601 valid formats of datetimes: with microseconds
237 /// (2021-01-01T01:01:01.010000+00:00) and without microseconds
238 /// (2021-01-01T01:01:01+00:00). This supports parsing from either.
239 ///
240 /// # Errors
241 ///
242 /// Refer to the documentation for [`Timestamp::parse`] for a list of
243 /// errors.
244 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
245 /// Visitor for the [`Timestamp`] deserialize implementation.
246 struct TimestampVisitor;
247
248 impl Visitor<'_> for TimestampVisitor {
249 type Value = Timestamp;
250
251 fn expecting(&self, f: &mut Formatter<'_>) -> FmtResult {
252 f.write_str("iso 8601 datetime format")
253 }
254
255 fn visit_str<E: DeError>(self, v: &str) -> Result<Self::Value, E> {
256 Timestamp::parse(v).map_err(DeError::custom)
257 }
258 }
259
260 deserializer.deserialize_any(TimestampVisitor)
261 }
262}
263
264impl Serialize for Timestamp {
265 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
266 serializer.collect_str(&self.iso_8601())
267 }
268}
269
270impl TryFrom<&'_ str> for Timestamp {
271 type Error = TimestampParseError;
272
273 /// Parse a timestamp from an ISO 8601 datetime string emitted by Discord.
274 ///
275 /// Discord emits two ISO 8601 valid formats of datetimes: with microseconds
276 /// (2021-01-01T01:01:01.010000+00:00) and without microseconds
277 /// (2021-01-01T01:01:01+00:00). This supports parsing from either.
278 ///
279 /// # Examples
280 ///
281 /// Refer to the documentation for [`Timestamp::parse`] for examples.
282 ///
283 /// # Errors
284 ///
285 /// Refer to the documentation for [`Timestamp::parse`] for a list of
286 /// errors.
287 fn try_from(value: &str) -> Result<Self, Self::Error> {
288 Self::from_str(value)
289 }
290}
291
292/// Parse an input ISO 8601 timestamp into a Unix timestamp with microseconds.
293///
294/// Input in the format of "2021-01-01T01:01:01.010000+00:00" is acceptable.
295///
296/// # Errors
297///
298/// Returns a [`TimestampParseErrorType::Parsing`] if the parsing failed.
299fn parse_iso8601(input: &str) -> Result<PrimitiveDateTime, TimestampParseError> {
300 /// Discord sends some timestamps with the microseconds and some without.
301 const TIMESTAMP_LENGTH: usize = "2021-01-01T01:01:01+00:00".len();
302
303 if input.len() < TIMESTAMP_LENGTH {
304 return Err(TimestampParseError::FORMAT);
305 }
306
307 OffsetDateTime::parse(input, &Rfc3339)
308 .map(|offset| PrimitiveDateTime::new(offset.date(), offset.time()))
309 .map_err(TimestampParseError::from_parse)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::{Timestamp, TimestampParseError};
315 use serde::{Deserialize, Serialize};
316 use static_assertions::assert_impl_all;
317 use std::{fmt::Debug, hash::Hash, str::FromStr};
318 use time::{OffsetDateTime, PrimitiveDateTime};
319
320 assert_impl_all!(
321 Timestamp: Clone,
322 Copy,
323 Debug,
324 Deserialize<'static>,
325 Eq,
326 FromStr,
327 Hash,
328 PartialEq,
329 Send,
330 Serialize,
331 Sync,
332 TryFrom<&'static str>,
333 );
334
335 /// Test a variety of supported ISO 8601 datetime formats.
336 #[test]
337 fn parse_iso8601() -> Result<(), TimestampParseError> {
338 // With milliseconds.
339 let offset = OffsetDateTime::from_unix_timestamp_nanos(1_580_608_922_020_000_000).unwrap();
340
341 assert_eq!(
342 PrimitiveDateTime::new(offset.date(), offset.time()),
343 super::parse_iso8601("2020-02-02T02:02:02.020000+00:00")?
344 );
345
346 // Without milliseconds.
347 let offset = OffsetDateTime::from_unix_timestamp_nanos(1_580_608_922_000_000_000).unwrap();
348
349 assert_eq!(
350 PrimitiveDateTime::new(offset.date(), offset.time()),
351 super::parse_iso8601("2020-02-02T02:02:02+00:00")?
352 );
353
354 // And a couple not in leap years.
355 assert_eq!(
356 "2021-03-16T14:29:19.046000+00:00",
357 Timestamp::from_str("2021-03-16T14:29:19.046000+00:00")?
358 .iso_8601()
359 .to_string(),
360 );
361 assert_eq!(
362 "2022-03-16T14:29:19.046000+00:00",
363 Timestamp::from_str("2022-03-16T14:29:19.046000+00:00")?
364 .iso_8601()
365 .to_string(),
366 );
367 assert_eq!(
368 "2023-03-16T14:29:19.046000+00:00",
369 Timestamp::from_str("2023-03-16T14:29:19.046000+00:00")?
370 .iso_8601()
371 .to_string(),
372 );
373
374 Ok(())
375 }
376
377 /// Test the boundaries of valid ISO 8601 datetime boundaries.
378 #[test]
379 fn parse_iso8601_boundaries() -> Result<(), TimestampParseError> {
380 fn test(input: &str) -> Result<(), TimestampParseError> {
381 assert_eq!(input, Timestamp::from_str(input)?.iso_8601().to_string());
382
383 Ok(())
384 }
385
386 test("2021-12-31T23:59:59.999999+00:00")?;
387 test("2021-01-01T00:00:00.000000+00:00")?;
388
389 Ok(())
390 }
391}