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