Skip to main content

printobserver_types/
timestamp.rs

1//! The one instant representation this vocabulary has.
2//!
3//! Every timestamp is an instant in UTC, serialized as an RFC 3339 string with
4//! a zero offset. There is no local-time representation anywhere in this crate:
5//! a string carrying a non-zero offset parses to the instant it denotes and
6//! re-emits at a zero offset, and a string carrying no offset at all is refused
7//! rather than guessed at.
8
9use core::fmt;
10use core::str::FromStr;
11use std::borrow::Cow;
12
13use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
14use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
15use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
16
17/// Why a string is not an instant this vocabulary can hold.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct TimestampError {
20    /// What is wrong with the string.
21    detail: String,
22}
23
24impl TimestampError {
25    /// What is wrong with the string.
26    #[must_use]
27    pub fn detail(&self) -> &str {
28        &self.detail
29    }
30}
31
32impl fmt::Display for TimestampError {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        write!(formatter, "not an RFC 3339 instant: {}", self.detail)
35    }
36}
37
38impl core::error::Error for TimestampError {}
39
40/// An instant in UTC, serialized as an RFC 3339 string with a zero offset.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
42pub struct Timestamp(DateTime<Utc>);
43
44impl Timestamp {
45    /// The instant now.
46    #[must_use]
47    pub fn now() -> Self {
48        Self(Utc::now())
49    }
50
51    /// The instant this many seconds after the Unix epoch.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error when the count of seconds denotes no representable
56    /// instant.
57    pub fn from_unix_seconds(seconds: i64) -> Result<Self, TimestampError> {
58        DateTime::from_timestamp(seconds, 0)
59            .map(Self)
60            .ok_or_else(|| TimestampError {
61                detail: format!(
62                    "{seconds} seconds from the Unix epoch is not a representable instant"
63                ),
64            })
65    }
66
67    /// The instant this many seconds after this one.
68    ///
69    /// Sub-second precision is kept rather than dropped: an adjustment asked to
70    /// stand for a minute stands for a minute, rather than for a minute less
71    /// however far into a second it happened to be made.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error when the result is not a representable instant.
76    pub fn plus_seconds(self, seconds: i64) -> Result<Self, TimestampError> {
77        // `try_seconds` rather than `seconds`: a count of seconds that is not a
78        // representable span is answered here rather than raised, because the
79        // caller is a duration somebody asked for.
80        TimeDelta::try_seconds(seconds)
81            .and_then(|span| self.0.checked_add_signed(span))
82            .map(Self)
83            .ok_or_else(|| TimestampError {
84                detail: format!("{seconds} seconds after {self} is not a representable instant"),
85            })
86    }
87
88    /// The latest instant this type writes as a string it reads back.
89    ///
90    /// The last moment of the year 9999: RFC 3339 spells a year in four
91    /// digits, so a later instant is written with a sign and more digits that
92    /// no RFC 3339 reader accepts — and that a store ordering instants by their
93    /// text would order before every earlier one. Reading what is due by this
94    /// instant reads everything due at any instant this type can record.
95    ///
96    /// # Panics
97    ///
98    /// Never: the instant is a fixed one every `chrono` represents.
99    #[must_use]
100    pub fn latest() -> Self {
101        let last_day = chrono::NaiveDate::from_ymd_opt(9999, 12, 31)
102            .and_then(|day| day.and_hms_nano_opt(23, 59, 59, 999_999_999))
103            .expect("the last moment of the year 9999 is a date-time");
104        Self(last_day.and_utc())
105    }
106
107    /// The instant as the UTC date-time it is.
108    #[must_use]
109    pub const fn as_utc(&self) -> &DateTime<Utc> {
110        &self.0
111    }
112}
113
114impl From<DateTime<Utc>> for Timestamp {
115    fn from(instant: DateTime<Utc>) -> Self {
116        Self(instant)
117    }
118}
119
120impl fmt::Display for Timestamp {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        // `use_z` is what makes the offset a literal zero rather than `+00:00`,
123        // and `AutoSi` keeps whatever sub-second precision the instant carries
124        // so that an instant survives a round trip unchanged.
125        write!(
126            formatter,
127            "{}",
128            self.0.to_rfc3339_opts(SecondsFormat::AutoSi, true)
129        )
130    }
131}
132
133impl FromStr for Timestamp {
134    type Err = TimestampError;
135
136    fn from_str(text: &str) -> Result<Self, Self::Err> {
137        DateTime::parse_from_rfc3339(text)
138            .map(|offset| Self(offset.with_timezone(&Utc)))
139            .map_err(|error| TimestampError {
140                detail: format!("{text:?}: {error}"),
141            })
142    }
143}
144
145impl Serialize for Timestamp {
146    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
147        serializer.collect_str(self)
148    }
149}
150
151impl<'de> Deserialize<'de> for Timestamp {
152    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
153        let text = String::deserialize(deserializer)?;
154        text.parse().map_err(D::Error::custom)
155    }
156}
157
158impl JsonSchema for Timestamp {
159    fn schema_name() -> Cow<'static, str> {
160        Cow::Borrowed("Timestamp")
161    }
162
163    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
164        json_schema!({
165            "type": "string",
166            "format": "date-time",
167            "title": "Timestamp",
168            "description": "An instant in UTC, as an RFC 3339 string with a zero offset."
169        })
170    }
171}