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}