1use chrono::{Datelike, Days, NaiveDate};
2use regex::Regex;
3use std::sync::LazyLock;
4
5mod reflect;
6mod serialize;
7
8#[cfg(test)]
9mod proptests;
10
11#[inline]
13fn padded_year(year: u16) -> String {
14 let mut text = year.to_string();
15 if text.len() < 4 {
16 text.insert_str(0, &"000"[..4 - text.len()]);
17 }
18 text
19}
20
21#[derive(Debug, Clone, PartialEq)]
22pub enum DateTime {
23 Year(u16),
24 YearMonth(u16, u8),
25 YearMonthDay(u16, u8, u8),
26 Iso8601(chrono::DateTime<chrono::Utc>),
27}
28
29impl ToString for DateTime {
30 fn to_string(&self) -> String {
31 match self {
32 DateTime::Year(year) => padded_year(*year),
33 DateTime::YearMonth(year, month) => format!("{:04}-{:02}", year, month),
34 DateTime::YearMonthDay(year, month, day) => {
35 format!("{:04}-{:02}-{:02}", year, month, day)
36 }
37 DateTime::Iso8601(dt) => dt.to_rfc3339(),
38 }
39 }
40}
41
42impl TryFrom<DateTime> for chrono::DateTime<chrono::Utc> {
43 type Error = ParseError;
44
45 fn try_from(value: DateTime) -> Result<Self, Self::Error> {
46 match value {
47 DateTime::Year(year) => {
49 let datetime = chrono::DateTime::parse_from_rfc3339(
50 format!("{}-01-01T00:00:00Z", year).as_str(),
51 )
52 .map_err(|_| ParseError::InvalidFormat)?;
53
54 Ok(datetime.with_timezone(&chrono::Utc))
55 }
56 DateTime::YearMonth(year, month) => {
57 let datetime = chrono::DateTime::parse_from_rfc3339(
58 format!("{}-{:02}-01T00:00:00Z", year, month).as_str(),
59 )
60 .map_err(|_| ParseError::InvalidFormat)?;
61
62 Ok(datetime.with_timezone(&chrono::Utc))
63 }
64 DateTime::YearMonthDay(year, month, day) => {
65 let datetime = chrono::DateTime::parse_from_rfc3339(
66 format!("{}-{:02}-{:02}T00:00:00Z", year, month, day).as_str(),
67 )
68 .map_err(|_| ParseError::InvalidFormat)?;
69
70 Ok(datetime.with_timezone(&chrono::Utc))
71 }
72 DateTime::Iso8601(dt) => Ok(dt),
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq)]
78pub enum Date {
79 Year(u16),
80 YearMonth(u16, u8),
81 YearMonthDay(u16, u8, u8),
82}
83
84impl ToString for Date {
85 fn to_string(&self) -> String {
86 match self {
87 Date::Year(year) => padded_year(*year),
88 Date::YearMonth(year, month) => format!("{:04}-{:02}", year, month),
89 Date::YearMonthDay(year, month, day) => {
90 format!("{:04}-{:02}-{:02}", year, month, day)
91 }
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq)]
97pub enum Instant {
98 Iso8601(chrono::DateTime<chrono::Utc>),
99}
100
101impl Instant {
102 pub fn format(&self, fmt: &str) -> String {
103 match self {
104 Instant::Iso8601(dt) => dt.to_utc().format(fmt).to_string(),
105 }
106 }
107}
108
109impl ToString for Instant {
110 fn to_string(&self) -> String {
111 match self {
112 Instant::Iso8601(dt) => dt.to_rfc3339(),
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq)]
120pub enum Time {
121 Hour(u8),
122 HourMinute(u8, u8),
123 HourMinuteSecond(chrono::NaiveTime),
124}
125
126impl ToString for Time {
127 fn to_string(&self) -> String {
128 match self {
129 Time::Hour(hour) => format!("{hour:02}"),
130 Time::HourMinute(hour, minute) => format!("{hour:02}:{minute:02}"),
131 Time::HourMinuteSecond(time) => time.format("%H:%M:%S%.f").to_string(),
132 }
133 }
134}
135
136#[derive(Debug)]
137pub enum ParseError {
138 InvalidFormat,
139}
140
141pub static DATE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
142 Regex::new(
143 r"^(?<year>[0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(?<month>0[1-9]|1[0-2])(-(?<day>0[1-9]|[1-2][0-9]|3[0-1]))?)?$",
144 ).unwrap()
145});
146
147pub static DATETIME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
148 Regex::new(
149 r"^(?<year>[0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(?<month>0[1-9]|1[0-2])(-(?<day>0[1-9]|[1-2][0-9]|3[0-1])(?<time>T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))?)?)?$",
150 ).unwrap()
151});
152
153pub static INSTANT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
154 Regex::new(
155 r"^([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))$",
156 ).unwrap()
157});
158
159pub static TIME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
160 Regex::new(r"^([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?$").unwrap()
161});
162
163static PARTIAL_TIME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
165 Regex::new(r"^([01][0-9]|2[0-3])(?::([0-5][0-9])(?::([0-5][0-9]|60)(\.[0-9]+)?)?)?$").unwrap()
166});
167
168#[inline]
170fn captured<T: std::str::FromStr>(capture: regex::Match<'_>) -> T
171where
172 T::Err: std::fmt::Debug,
173{
174 capture.as_str().parse().expect("the regex matched digits")
175}
176
177#[inline]
182fn calendar_date(year: u16, month: u8, day: u8) -> Result<(u16, u8, u8), ParseError> {
183 let (y, m) = (i32::from(year), u32::from(month));
184 if day <= 28 || NaiveDate::from_ymd_opt(y, m, u32::from(day)).is_some() {
185 return Ok((year, month, day));
186 }
187 let carried = NaiveDate::from_ymd_opt(y, m, 1)
188 .and_then(|first| first.checked_add_days(Days::new(u64::from(day).saturating_sub(1))))
189 .ok_or(ParseError::InvalidFormat)?;
190 Ok((
191 u16::try_from(carried.year()).map_err(|_| ParseError::InvalidFormat)?,
192 u8::try_from(carried.month()).map_err(|_| ParseError::InvalidFormat)?,
193 u8::try_from(carried.day()).map_err(|_| ParseError::InvalidFormat)?,
194 ))
195}
196
197#[inline]
201fn parse_rfc3339(text: &str) -> Result<chrono::DateTime<chrono::FixedOffset>, ParseError> {
202 if let Ok(datetime) = chrono::DateTime::parse_from_rfc3339(text) {
203 return Ok(datetime);
204 }
205 let (date, time) = text.split_at(10);
206 let (year, rest) = date.split_once('-').ok_or(ParseError::InvalidFormat)?;
207 let (month, day) = rest.split_once('-').ok_or(ParseError::InvalidFormat)?;
208 let (year, month, day) = calendar_date(
209 year.parse().map_err(|_| ParseError::InvalidFormat)?,
210 month.parse().map_err(|_| ParseError::InvalidFormat)?,
211 day.parse().map_err(|_| ParseError::InvalidFormat)?,
212 )?;
213 chrono::DateTime::parse_from_rfc3339(&format!("{year:04}-{month:02}-{day:02}{time}"))
214 .map_err(|_| ParseError::InvalidFormat)
215}
216
217pub fn parse_instant(instant_string: &str) -> Result<Instant, ParseError> {
218 if INSTANT_REGEX.is_match(instant_string) {
219 let datetime = parse_rfc3339(instant_string)?;
220 Ok(Instant::Iso8601(datetime.with_timezone(&chrono::Utc)))
221 } else {
222 Err(ParseError::InvalidFormat)
223 }
224}
225
226pub fn parse_time(time_string: &str) -> Result<Time, ParseError> {
227 if TIME_REGEX.is_match(time_string) {
228 chrono::NaiveTime::parse_from_str(time_string, "%H:%M:%S%.f")
229 .map(Time::HourMinuteSecond)
230 .map_err(|_| ParseError::InvalidFormat)
231 } else {
232 Err(ParseError::InvalidFormat)
233 }
234}
235
236pub fn parse_partial_time(time_string: &str) -> Result<Time, ParseError> {
238 let captures = PARTIAL_TIME_REGEX
239 .captures(time_string)
240 .ok_or(ParseError::InvalidFormat)?;
241 match (captures.get(1), captures.get(2)) {
242 (Some(hour), None) => Ok(Time::Hour(captured(hour))),
243 (Some(hour), Some(minute)) if time_string.len() == 5 => {
244 Ok(Time::HourMinute(captured(hour), captured(minute)))
245 }
246 _ => parse_time(time_string),
247 }
248}
249
250pub fn parse_datetime(datetime_string: &str) -> Result<DateTime, ParseError> {
251 if let Some(captures) = DATETIME_REGEX.captures(datetime_string) {
252 match (
253 captures.name("year"),
254 captures.name("month"),
255 captures.name("day"),
256 captures.name("time"),
257 ) {
258 (Some(year), None, None, None) => Ok(DateTime::Year(captured(year))),
259 (Some(year), Some(month), None, None) => {
260 Ok(DateTime::YearMonth(captured(year), captured(month)))
261 }
262 (Some(year), Some(month), Some(day), None) => {
263 let (year, month, day) =
264 calendar_date(captured(year), captured(month), captured(day))?;
265 Ok(DateTime::YearMonthDay(year, month, day))
266 }
267 (Some(_), Some(_), Some(_), Some(_)) => {
268 let datetime = parse_rfc3339(datetime_string)?;
269 Ok(DateTime::Iso8601(datetime.with_timezone(&chrono::Utc)))
270 }
271 _ => Err(ParseError::InvalidFormat),
272 }
273 } else {
274 Err(ParseError::InvalidFormat)
275 }
276}
277
278pub fn parse_date(date_string: &str) -> Result<Date, ParseError> {
279 if let Some(captures) = DATE_REGEX.captures(date_string) {
280 match (
281 captures.name("year"),
282 captures.name("month"),
283 captures.name("day"),
284 ) {
285 (Some(year), None, None) => Ok(Date::Year(captured(year))),
286 (Some(year), Some(month), None) => Ok(Date::YearMonth(captured(year), captured(month))),
287 (Some(year), Some(month), Some(day)) => {
288 let (year, month, day) =
289 calendar_date(captured(year), captured(month), captured(day))?;
290 Ok(Date::YearMonthDay(year, month, day))
291 }
292 _ => Err(ParseError::InvalidFormat),
293 }
294 } else {
295 Err(ParseError::InvalidFormat)
296 }
297}
298
299pub enum DateKind {
300 DateTime,
301 Date,
302 Time,
303 Instant,
304}
305
306pub enum DateResult {
307 DateTime(DateTime),
308 Date(Date),
309 Time(Time),
310 Instant(Instant),
311}
312
313pub fn parse(kind: DateKind, input: &str) -> Result<DateResult, ParseError> {
314 match kind {
315 DateKind::DateTime => Ok(DateResult::DateTime(parse_datetime(input)?)),
316 DateKind::Date => Ok(DateResult::Date(parse_date(input)?)),
317 DateKind::Time => Ok(DateResult::Time(parse_time(input)?)),
318 DateKind::Instant => Ok(DateResult::Instant(parse_instant(input)?)),
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn test_parse_time() {
328 assert!(parse_time("12:34:56").is_ok());
329 assert!(parse_time("23:59:59").is_ok());
330 assert!(parse_time("23:59:59.232").is_ok());
331 assert_eq!(
332 parse_time("23:59:59.232").unwrap(),
333 Time::HourMinuteSecond(chrono::NaiveTime::from_hms_milli_opt(23, 59, 59, 232).unwrap())
334 );
335 }
336
337 #[test]
338 fn test_parse_instant() {
339 assert!(parse_instant("2015-02-07T13:28:17.239+02:00").is_ok());
340 assert!(parse_instant("2017-01-01T00:00:00Z").is_ok());
341 }
342
343 #[test]
344 fn test_parse_date() {
345 assert_eq!(parse_date("2023").unwrap(), Date::Year(2023));
346 assert_eq!(parse_date("2023-01").unwrap(), Date::YearMonth(2023, 1));
347 assert_eq!(
348 parse_date("2023-01-01").unwrap(),
349 Date::YearMonthDay(2023, 1, 1)
350 );
351
352 assert_eq!(
353 Date::YearMonthDay(2023, 1, 19),
354 parse_date("2023-01-19").unwrap()
355 );
356
357 assert!(parse_date("2023-01-33").is_err());
358 assert!(parse_date("2023-13-30").is_err());
359 assert!(parse_date("2023-01-01T12:00:00Z").is_err());
360 }
361 #[test]
362 fn test_parse_datetime() {
363 assert_eq!(parse_datetime("2023").unwrap(), DateTime::Year(2023));
364 assert_eq!(
365 parse_datetime("2023-01").unwrap(),
366 DateTime::YearMonth(2023, 1)
367 );
368 assert_eq!(
369 parse_datetime("2023-01-01").unwrap(),
370 DateTime::YearMonthDay(2023, 1, 1)
371 );
372
373 assert_eq!(
374 DateTime::YearMonthDay(2023, 1, 19),
375 parse_datetime("2023-01-19").unwrap()
376 );
377
378 assert!(parse_datetime("2023-01-42").is_err());
380
381 assert_eq!(
382 parse_datetime("2023-01-01T12:00:00Z").unwrap(),
383 DateTime::Iso8601(
384 chrono::DateTime::parse_from_rfc3339("2023-01-01T12:00:00Z")
385 .unwrap()
386 .with_timezone(&chrono::Utc)
387 )
388 );
389 assert!(parse_datetime("2023-01-01T12:00:00+00:00").is_ok());
390 assert!(parse_datetime("2023-01-01T12:00:00+01:00").is_ok());
391 assert!(parse_datetime("2023-01-01T12:00:00-01:00").is_ok());
392 assert!(parse_datetime("2023-01-01T12:00:00+02:00").is_ok());
393 assert!(parse_datetime("2023-01-01T12:00:00-02:00").is_ok());
394 assert!(parse_datetime("2023-01-01T12:00:00+14:00").is_ok());
395 }
396}