Skip to main content

projectx_client/
timestamp.rs

1// SPDX-FileCopyrightText: 2026 Kevin Monaghan
2// SPDX-License-Identifier: MIT-0
3
4//! Validated provider timestamp type.
5
6use std::{fmt, str::FromStr};
7
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use thiserror::Error;
10
11/// An absolute provider timestamp parsed from an ISO-8601/RFC-3339 value.
12///
13/// Parsing normalizes equivalent offsets to one canonical instant. Serialization
14/// emits the canonical representation accepted by the Gateway API.
15#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16#[repr(transparent)]
17pub struct Timestamp(jiff::Timestamp);
18
19impl Timestamp {
20    /// Parses an absolute provider timestamp.
21    ///
22    /// # Errors
23    ///
24    /// Returns [`TimestampError`] when the input does not contain a valid
25    /// absolute ISO-8601/RFC-3339 timestamp with an offset.
26    pub fn new(value: &str) -> Result<Self, TimestampError> {
27        value.parse()
28    }
29
30    /// Borrows the validated Jiff instant for allocation-free time arithmetic.
31    #[must_use]
32    pub const fn as_jiff(&self) -> &jiff::Timestamp {
33        &self.0
34    }
35}
36
37impl From<jiff::Timestamp> for Timestamp {
38    fn from(value: jiff::Timestamp) -> Self {
39        Self(value)
40    }
41}
42
43impl From<Timestamp> for jiff::Timestamp {
44    fn from(value: Timestamp) -> Self {
45        value.0
46    }
47}
48
49impl FromStr for Timestamp {
50    type Err = TimestampError;
51
52    fn from_str(value: &str) -> Result<Self, Self::Err> {
53        value
54            .parse::<jiff::Timestamp>()
55            .map(Self)
56            .map_err(|_| TimestampError)
57    }
58}
59
60impl TryFrom<String> for Timestamp {
61    type Error = TimestampError;
62
63    fn try_from(value: String) -> Result<Self, Self::Error> {
64        value.parse()
65    }
66}
67
68impl fmt::Display for Timestamp {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        self.0.fmt(formatter)
71    }
72}
73
74impl Serialize for Timestamp {
75    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
76    where
77        S: Serializer,
78    {
79        self.0.serialize(serializer)
80    }
81}
82
83impl<'de> Deserialize<'de> for Timestamp {
84    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
85    where
86        D: Deserializer<'de>,
87    {
88        let timestamp = jiff::Timestamp::deserialize(deserializer)?;
89        Ok(Self(timestamp))
90    }
91}
92
93/// A string was not a valid absolute provider timestamp.
94#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
95#[error("timestamp must be an absolute ISO-8601/RFC-3339 value with an offset")]
96pub struct TimestampError;
97
98/// A provider civil date parsed from an ISO-8601 `YYYY-MM-DD` value.
99#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
100#[repr(transparent)]
101pub struct ProviderDate(jiff::civil::Date);
102
103impl ProviderDate {
104    /// Parses a provider civil date.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`ProviderDateError`] when the input is not a valid ISO-8601
109    /// calendar date.
110    pub fn new(value: &str) -> Result<Self, ProviderDateError> {
111        value.parse()
112    }
113
114    /// Borrows the validated Jiff civil date.
115    #[must_use]
116    pub const fn as_jiff(&self) -> &jiff::civil::Date {
117        &self.0
118    }
119}
120
121impl From<jiff::civil::Date> for ProviderDate {
122    fn from(value: jiff::civil::Date) -> Self {
123        Self(value)
124    }
125}
126
127impl From<ProviderDate> for jiff::civil::Date {
128    fn from(value: ProviderDate) -> Self {
129        value.0
130    }
131}
132
133impl FromStr for ProviderDate {
134    type Err = ProviderDateError;
135
136    fn from_str(value: &str) -> Result<Self, Self::Err> {
137        value
138            .parse::<jiff::civil::Date>()
139            .map(Self)
140            .map_err(|_| ProviderDateError)
141    }
142}
143
144impl TryFrom<String> for ProviderDate {
145    type Error = ProviderDateError;
146
147    fn try_from(value: String) -> Result<Self, Self::Error> {
148        value.parse()
149    }
150}
151
152impl fmt::Display for ProviderDate {
153    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
154        self.0.fmt(formatter)
155    }
156}
157
158impl Serialize for ProviderDate {
159    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
160    where
161        S: Serializer,
162    {
163        self.0.serialize(serializer)
164    }
165}
166
167impl<'de> Deserialize<'de> for ProviderDate {
168    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
169    where
170        D: Deserializer<'de>,
171    {
172        let date = jiff::civil::Date::deserialize(deserializer)?;
173        Ok(Self(date))
174    }
175}
176
177/// A string was not a valid provider civil date.
178#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
179#[error("provider date must be a valid ISO-8601 calendar date")]
180pub struct ProviderDateError;
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn timestamp_rejects_malformed_or_offset_free_values() {
188        assert!(Timestamp::new("not-a-date").is_err());
189        assert!(Timestamp::new("2026-01-01T00:00:00").is_err());
190    }
191
192    #[test]
193    fn timestamp_round_trips_a_canonical_instant() {
194        let timestamp = Timestamp::new("2026-01-01T08:00:00+08:00")
195            .unwrap_or_else(|error| panic!("fixture timestamp must be valid: {error}"));
196        let encoded = serde_json::to_string(&timestamp)
197            .unwrap_or_else(|error| panic!("fixture timestamp must serialize: {error}"));
198        assert_eq!(encoded, "\"2026-01-01T00:00:00Z\"");
199        let decoded: Timestamp = serde_json::from_str(&encoded)
200            .unwrap_or_else(|error| panic!("fixture timestamp must deserialize: {error}"));
201        assert_eq!(decoded, timestamp);
202
203        let jiff_timestamp: jiff::Timestamp = timestamp.into();
204        assert_eq!(Timestamp::from(jiff_timestamp), timestamp);
205    }
206
207    #[test]
208    fn provider_date_validates_and_round_trips() {
209        let date = ProviderDate::new("2026-02-28")
210            .unwrap_or_else(|error| panic!("fixture date must be valid: {error}"));
211        assert_eq!(date.to_string(), "2026-02-28");
212        assert!(ProviderDate::new("2026-02-29").is_err());
213        assert!(serde_json::from_str::<ProviderDate>("\"not-a-date\"").is_err());
214        let jiff_date: jiff::civil::Date = date.into();
215        assert_eq!(ProviderDate::from(jiff_date), date);
216    }
217}