proto_blue_syntax/
datetime.rs1use chrono::{DateTime, FixedOffset, SecondsFormat, Utc};
7use regex::Regex;
8use std::fmt;
9use std::str::FromStr;
10
11const MAX_DATETIME_LENGTH: usize = 64;
13
14static DATETIME_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
15 Regex::new(
16 r"^[0-9]{4}-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-6][0-9]:[0-6][0-9](\.[0-9]{1,20})?(Z|([+-][0-2][0-9]:[0-5][0-9]))$",
17 )
18 .unwrap()
19});
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub struct Datetime(String);
26
27#[derive(Debug, Clone, thiserror::Error)]
29#[error("Invalid datetime: {reason}")]
30pub struct InvalidDatetimeError {
31 pub reason: String,
32}
33
34impl Datetime {
35 pub fn new(s: &str) -> Result<Self, InvalidDatetimeError> {
37 ensure_valid_datetime(s)?;
38 Ok(Self(s.to_string()))
39 }
40
41 #[must_use]
43 pub fn is_valid(s: &str) -> bool {
44 ensure_valid_datetime(s).is_ok()
45 }
46
47 #[must_use]
49 pub fn as_str(&self) -> &str {
50 &self.0
51 }
52
53 #[must_use]
55 pub fn into_inner(self) -> String {
56 self.0
57 }
58
59 #[must_use]
64 pub fn now() -> Self {
65 let s = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
66 Self(s)
68 }
69
70 #[must_use]
74 pub fn from_utc(dt: DateTime<Utc>) -> Self {
75 Self(dt.to_rfc3339_opts(SecondsFormat::Millis, true))
76 }
77}
78
79#[must_use]
81pub fn current_datetime_string() -> String {
82 Datetime::now().into_inner()
83}
84
85fn ensure_valid_datetime(s: &str) -> Result<(), InvalidDatetimeError> {
86 let err = |reason: &str| InvalidDatetimeError {
87 reason: reason.to_string(),
88 };
89
90 if s.len() > MAX_DATETIME_LENGTH {
91 return Err(err(&format!(
92 "Datetime too long ({} chars, max {})",
93 s.len(),
94 MAX_DATETIME_LENGTH
95 )));
96 }
97
98 if !DATETIME_REGEX.is_match(s) {
102 return Err(err("Datetime does not match RFC 3339 format"));
103 }
104
105 if s.ends_with("-00:00") {
108 return Err(err("Datetime cannot use -00:00 offset; use Z for UTC"));
109 }
110
111 if s.starts_with("000") {
113 return Err(err("Datetime year cannot start with 000"));
114 }
115
116 DateTime::parse_from_rfc3339(s).map_err(|e| err(&format!("Invalid datetime value: {e}")))?;
121
122 Ok(())
123}
124
125pub fn normalize_datetime(s: &str) -> Result<String, InvalidDatetimeError> {
133 ensure_valid_datetime(s)?;
134
135 let parsed: DateTime<FixedOffset> =
139 DateTime::parse_from_rfc3339(s).map_err(|e| InvalidDatetimeError {
140 reason: format!("internal: RFC 3339 reparse failed after validation: {e}"),
141 })?;
142 let utc: DateTime<Utc> = parsed.with_timezone(&Utc);
143
144 Ok(utc.to_rfc3339_opts(SecondsFormat::Millis, true))
146}
147
148impl fmt::Display for Datetime {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 f.write_str(&self.0)
151 }
152}
153
154impl FromStr for Datetime {
155 type Err = InvalidDatetimeError;
156 fn from_str(s: &str) -> Result<Self, Self::Err> {
157 Self::new(s)
158 }
159}
160
161impl AsRef<str> for Datetime {
162 fn as_ref(&self) -> &str {
163 &self.0
164 }
165}
166
167impl serde::Serialize for Datetime {
168 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
169 self.0.serialize(serializer)
170 }
171}
172
173impl<'de> serde::Deserialize<'de> for Datetime {
174 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
175 let s = String::deserialize(deserializer)?;
176 Self::new(&s).map_err(serde::de::Error::custom)
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn valid_datetimes() {
186 let cases = [
187 "2023-11-15T12:30:00Z",
188 "2023-11-15T12:30:00.123Z",
189 "2023-11-15T12:30:00+05:30",
190 "2023-11-15T12:30:00-08:00",
191 "2023-11-15T12:30:00.1Z",
192 "2023-11-15T12:30:00.12345678901234567890Z",
193 ];
194 for dt in &cases {
195 assert!(Datetime::new(dt).is_ok(), "should be valid: {dt}");
196 }
197 }
198
199 #[test]
200 fn invalid_datetimes() {
201 assert!(Datetime::new("").is_err(), "empty");
202 assert!(Datetime::new("2023-11-15").is_err(), "date only");
203 assert!(Datetime::new("2023-11-15T12:30:00").is_err(), "no timezone");
204 assert!(
205 Datetime::new("2023-11-15T12:30:00-00:00").is_err(),
206 "-00:00 not allowed"
207 );
208 assert!(
209 Datetime::new("0001-01-01T00:00:00Z").is_err(),
210 "year starts with 000"
211 );
212 }
213
214 #[test]
215 fn normalize() {
216 let result = normalize_datetime("2023-11-15T12:30:00Z").unwrap();
217 assert_eq!(result, "2023-11-15T12:30:00.000Z");
218
219 let result = normalize_datetime("2023-11-15T12:30:00.1Z").unwrap();
220 assert_eq!(result, "2023-11-15T12:30:00.100Z");
221
222 let result = normalize_datetime("2023-11-15T12:30:00.123456Z").unwrap();
223 assert_eq!(result, "2023-11-15T12:30:00.123Z");
224 }
225
226 #[test]
232 fn normalize_handles_month_and_year_rollover() {
233 assert_eq!(
235 normalize_datetime("2023-02-01T00:30:00+02:00").unwrap(),
236 "2023-01-31T22:30:00.000Z",
237 );
238 assert_eq!(
240 normalize_datetime("2023-02-28T23:30:00-02:00").unwrap(),
241 "2023-03-01T01:30:00.000Z",
242 );
243 assert_eq!(
245 normalize_datetime("2024-02-29T12:00:00Z").unwrap(),
246 "2024-02-29T12:00:00.000Z",
247 );
248 assert_eq!(
251 normalize_datetime("2024-01-01T01:00:00+02:00").unwrap(),
252 "2023-12-31T23:00:00.000Z",
253 );
254 assert_eq!(
256 normalize_datetime("2024-02-29T23:00:00-02:00").unwrap(),
257 "2024-03-01T01:00:00.000Z",
258 );
259 }
260
261 #[test]
264 fn rejects_semantically_invalid_datetimes() {
265 let bad = [
266 "1985-00-12T23:20:50.123Z", "1985-13-12T23:20:50.123Z", "1985-04-00T23:20:50.123Z", "1985-04-31T23:20:50.123Z", "2023-02-29T12:00:00Z", "1985-04-12T25:20:50.123Z", "1985-04-12T23:99:50.123Z", "1985-04-12T23:20:61.123Z", ];
275 for s in bad {
276 assert!(
277 Datetime::new(s).is_err(),
278 "should reject semantically-invalid datetime {s:?}"
279 );
280 }
281 }
282
283 #[test]
286 fn leap_second_is_accepted_or_rejected_consistently() {
287 let _ = Datetime::new("1985-04-12T23:20:60Z");
291 }
292}