oanda_client/
timestamp.rs1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::{fmt, str::FromStr};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
9pub enum InvalidTimestamp {
10 #[error("invalid OANDA timestamp")]
12 Malformed,
13 #[error("OANDA timestamp has more than nine fractional digits")]
15 TooManyFractionalDigits,
16 #[error("OANDA timestamp out of range")]
18 OutOfRange,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub struct Timestamp(DateTime<Utc>);
24
25impl Timestamp {
26 #[must_use]
28 pub const fn new(value: DateTime<Utc>) -> Self {
29 Self(value)
30 }
31
32 #[must_use]
34 pub fn into_inner(self) -> DateTime<Utc> {
35 self.0
36 }
37}
38
39impl From<DateTime<Utc>> for Timestamp {
40 fn from(value: DateTime<Utc>) -> Self {
41 Self::new(value)
42 }
43}
44
45impl fmt::Display for Timestamp {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 write!(f, "{}", self.0.format("%Y-%m-%dT%H:%M:%S%.9fZ"))
48 }
49}
50
51impl FromStr for Timestamp {
52 type Err = InvalidTimestamp;
53
54 fn from_str(value: &str) -> Result<Self, Self::Err> {
55 if let Some((_, fraction)) = value.split_once('.')
56 && fraction.bytes().take_while(u8::is_ascii_digit).count() > 9
57 {
58 return Err(InvalidTimestamp::TooManyFractionalDigits);
59 }
60 if let Ok(parsed) = DateTime::parse_from_rfc3339(value) {
61 return Ok(Self(parsed.with_timezone(&Utc)));
62 }
63 let (whole, fraction) = value.split_once('.').unwrap_or((value, ""));
64 let seconds = whole.parse::<i64>().map_err(|error| match error.kind() {
65 std::num::IntErrorKind::PosOverflow | std::num::IntErrorKind::NegOverflow => {
66 InvalidTimestamp::OutOfRange
67 }
68 _ => InvalidTimestamp::Malformed,
69 })?;
70 if fraction.len() > 9 {
71 return Err(InvalidTimestamp::TooManyFractionalDigits);
72 }
73 if !fraction.bytes().all(|byte| byte.is_ascii_digit())
74 || (value.contains('.') && fraction.is_empty())
75 {
76 return Err(InvalidTimestamp::Malformed);
77 }
78 let digits = fraction
79 .bytes()
80 .fold(0_u32, |nanos, byte| nanos * 10 + u32::from(byte - b'0'));
81 let exponent =
82 u32::try_from(9 - fraction.len()).map_err(|_| InvalidTimestamp::Malformed)?;
83 let nanos = digits * 10_u32.pow(exponent);
84 let (seconds, nanos) = if value.starts_with('-') && nanos != 0 {
86 (
87 seconds.checked_sub(1).ok_or(InvalidTimestamp::OutOfRange)?,
88 1_000_000_000 - nanos,
89 )
90 } else {
91 (seconds, nanos)
92 };
93 DateTime::from_timestamp(seconds, nanos)
94 .map(Self)
95 .ok_or(InvalidTimestamp::OutOfRange)
96 }
97}
98
99impl Serialize for Timestamp {
100 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
101 serializer.collect_str(self)
102 }
103}
104
105impl<'de> Deserialize<'de> for Timestamp {
106 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
107 let value = String::deserialize(deserializer)?;
108 value.parse().map_err(serde::de::Error::custom)
109 }
110}
111
112#[cfg(test)]
113#[allow(clippy::expect_used)]
115mod tests {
116 use super::{InvalidTimestamp, Timestamp};
117
118 #[test]
119 fn fractions_are_exact_and_signed_before_the_epoch() {
120 for (unix, rfc) in [
121 ("0.000000001", "1970-01-01T00:00:00.000000001Z"),
122 ("-0.000000001", "1969-12-31T23:59:59.999999999Z"),
123 ("-1.25", "1969-12-31T23:59:58.750000000Z"),
124 ] {
125 let parsed: Timestamp = unix.parse().expect("valid timestamp");
126 assert_eq!(parsed.to_string(), rfc);
127 assert_eq!(Timestamp::from(parsed.into_inner()), parsed);
128 assert_eq!(
129 serde_json::to_string(&parsed).expect("serialize"),
130 format!("\"{rfc}\"")
131 );
132 }
133 }
134
135 #[test]
136 fn unrepresentable_precision_is_an_error_in_both_formats() {
137 for value in ["1.1234567890", "2024-01-02T03:04:05.1234567890Z"] {
138 assert_eq!(
139 value.parse::<Timestamp>(),
140 Err(InvalidTimestamp::TooManyFractionalDigits)
141 );
142 }
143 assert_eq!(
144 "garbage".parse::<Timestamp>(),
145 Err(InvalidTimestamp::Malformed)
146 );
147 assert_eq!(
148 "9223372036854775808".parse::<Timestamp>(),
149 Err(InvalidTimestamp::OutOfRange)
150 );
151 }
152}