1#[cfg(feature = "arbitrary")]
2use arbitrary::Arbitrary;
3use bounded_static::{IntoBoundedStatic, ToBoundedStatic};
4use chrono::{Datelike, FixedOffset, NaiveDate, NaiveTime, Timelike};
5use nom::{
6 branch::alt,
7 bytes::complete::{is_a, tag, tag_no_case, take_while_m_n},
8 character,
9 character::complete::{alphanumeric1, digit0},
10 combinator::{eof, map, map_opt, opt, value},
11 sequence::{delimited, pair, preceded, terminated, tuple},
12 IResult,
13};
14use std::fmt::{Debug, Formatter};
15#[cfg(feature = "tracing")]
16use tracing::warn;
17
18#[cfg(feature = "arbitrary")]
19use crate::fuzz_eq::FuzzEq;
20use crate::i18n::ContainsUtf8;
21use crate::print::{Formatter as PFmt, Print};
22use crate::text::whitespace::{cfws, fws};
23use eml_codec_derives::instrument_input;
24
25const MIN: i32 = 60;
26const HOUR: i32 = 60 * MIN;
27
28const MONTHS: &[&[u8]] = &[
29 b"Jan", b"Feb", b"Mar", b"Apr", b"May", b"Jun", b"Jul", b"Aug", b"Sep", b"Oct", b"Nov", b"Dec",
30];
31
32#[derive(Clone, ContainsUtf8, PartialEq)]
36#[contains_utf8(false)]
37pub struct DateTime(pub chrono::DateTime<FixedOffset>);
38
39impl DateTime {
40 pub fn placeholder() -> Self {
42 Self(chrono::DateTime::UNIX_EPOCH.into())
43 }
44}
45
46impl Debug for DateTime {
47 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
48 Debug::fmt(&self.0, f)
49 }
50}
51
52impl AsRef<chrono::DateTime<FixedOffset>> for DateTime {
53 fn as_ref(&self) -> &chrono::DateTime<FixedOffset> {
54 &self.0
55 }
56}
57
58impl IntoBoundedStatic for DateTime {
59 type Static = Self;
60 fn into_static(self) -> Self::Static {
61 self
62 }
63}
64
65impl ToBoundedStatic for DateTime {
66 type Static = Self;
67 fn to_static(&self) -> Self::Static {
68 self.clone()
69 }
70}
71
72#[cfg(feature = "arbitrary")]
73impl<'a> Arbitrary<'a> for DateTime {
74 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
75 let timestamp: i64 = u.arbitrary()?;
76 let d = chrono::DateTime::from_timestamp_secs(timestamp)
77 .ok_or(arbitrary::Error::IncorrectFormat)?;
78 let tz_mins = u.int_in_range(-24 * 60 + 1..=24 * 60 - 1)?;
79 let tz = FixedOffset::east_opt(tz_mins * 60).unwrap();
80 let d: chrono::DateTime<FixedOffset> = d.with_timezone(&tz);
81 if d.year() < 1900 {
82 Ok(Self(chrono::DateTime::UNIX_EPOCH.into()))
83 } else {
84 Ok(Self(d))
85 }
86 }
87}
88#[cfg(feature = "arbitrary")]
89impl FuzzEq for DateTime {
90 fn fuzz_eq(&self, other: &Self) -> bool {
91 self.0 == other.0
92 }
93}
94
95impl Print for DateTime {
96 fn print(&self, fmt: &mut impl PFmt) {
97 fmt.write_bytes(format!("{},", self.0.weekday()).as_bytes());
99 fmt.write_fws();
100 fmt.write_bytes(format!("{}", self.0.day()).as_bytes());
101 fmt.write_fws();
102 fmt.write_bytes(MONTHS[self.0.month0() as usize]);
103 fmt.write_fws();
104 fmt.write_bytes(format!("{}", self.0.year()).as_bytes());
105 fmt.write_fws();
106 fmt.write_bytes(format!("{:02}", self.0.hour()).as_bytes());
108 fmt.write_bytes(b":");
109 fmt.write_bytes(format!("{:02}", self.0.minute()).as_bytes());
110 fmt.write_bytes(b":");
111 fmt.write_bytes(format!("{:02}", self.0.second()).as_bytes());
112 fmt.write_fws();
113 let offset_secs = self.0.offset().local_minus_utc();
115 let sign = if offset_secs >= 0 { b"+" } else { b"-" };
116 let offset_mins = offset_secs.abs().rem_euclid(HOUR).div_euclid(MIN);
117 let offset_hours = offset_secs.abs().div_euclid(HOUR);
118 fmt.write_bytes(sign);
119 fmt.write_bytes(format!("{:02}{:02}", offset_hours, offset_mins).as_bytes());
120 }
121}
122
123#[instrument_input("tracing")]
142pub fn date_time(input: &[u8]) -> IResult<&[u8], DateTime> {
143 map_opt(
144 terminated(
145 tuple((
146 opt(terminated(
147 alt((strict_day_of_week, obs_day_of_week)),
148 tag(","),
149 )),
150 alt((strict_date, obs_date)),
151 alt((strict_time_of_day, obs_time_of_day)),
152 alt((strict_zone, obs_zone, no_zone_eof)),
153 )),
154 opt(cfws),
155 ),
156 |(_, date, time, tz)| {
157 date.and_time(time)
158 .and_local_timezone(tz)
159 .earliest()
160 .map(DateTime)
161 },
162 )(input)
163}
164
165#[instrument_input("tracing")]
167fn strict_day_of_week(input: &[u8]) -> IResult<&[u8], &[u8]> {
168 preceded(opt(fws), day_name)(input)
169}
170
171#[instrument_input("tracing")]
173fn obs_day_of_week(input: &[u8]) -> IResult<&[u8], &[u8]> {
174 delimited(opt(cfws), day_name, opt(cfws))(input)
175}
176
177fn day_name(input: &[u8]) -> IResult<&[u8], &[u8]> {
180 alt((
181 tag_no_case(b"Mon"),
182 tag_no_case(b"Tue"),
183 tag_no_case(b"Wed"),
184 tag_no_case(b"Thu"),
185 tag_no_case(b"Fri"),
186 tag_no_case(b"Sat"),
187 tag_no_case(b"Sun"),
188 ))(input)
189}
190
191#[instrument_input("tracing")]
193fn strict_date(input: &[u8]) -> IResult<&[u8], NaiveDate> {
194 map_opt(tuple((strict_day, month, strict_year)), |(d, m, y)| {
195 NaiveDate::from_ymd_opt(y, m, d)
196 })(input)
197}
198
199#[instrument_input("tracing")]
201fn obs_date(input: &[u8]) -> IResult<&[u8], NaiveDate> {
202 map_opt(tuple((obs_day, month, obs_year)), |(d, m, y)| {
203 NaiveDate::from_ymd_opt(y, m, d)
204 })(input)
205}
206
207#[instrument_input("tracing")]
209fn strict_day(input: &[u8]) -> IResult<&[u8], u32> {
210 delimited(opt(fws), character::complete::u32, fws)(input)
211}
212
213#[instrument_input("tracing")]
215fn obs_day(input: &[u8]) -> IResult<&[u8], u32> {
216 delimited(opt(cfws), character::complete::u32, opt(cfws))(input)
217}
218
219fn month(input: &[u8]) -> IResult<&[u8], u32> {
223 alt((
224 value(1, tag_no_case(b"Jan")),
225 value(2, tag_no_case(b"Feb")),
226 value(3, tag_no_case(b"Mar")),
227 value(4, tag_no_case(b"Apr")),
228 value(5, tag_no_case(b"May")),
229 value(6, tag_no_case(b"Jun")),
230 value(7, tag_no_case(b"Jul")),
231 value(8, tag_no_case(b"Aug")),
232 value(9, tag_no_case(b"Sep")),
233 value(10, tag_no_case(b"Oct")),
234 value(11, tag_no_case(b"Nov")),
235 value(12, tag_no_case(b"Dec")),
236 ))(input)
237}
238
239#[instrument_input("tracing")]
241fn strict_year(input: &[u8]) -> IResult<&[u8], i32> {
242 delimited(
243 fws,
244 map(
245 terminated(take_while_m_n(4, 9, |c| (0x30..=0x39).contains(&c)), digit0),
246 |d: &[u8]| {
247 encoding_rs::UTF_8
248 .decode_without_bom_handling(d)
249 .0
250 .parse::<i32>()
251 .unwrap_or(0)
252 },
253 ),
254 fws,
255 )(input)
256}
257
258#[instrument_input("tracing")]
263fn obs_year(input: &[u8]) -> IResult<&[u8], i32> {
264 map(
265 delimited(
266 opt(cfws),
267 terminated(take_while_m_n(2, 7, |c| (0x30..=0x39).contains(&c)), digit0),
268 opt(cfws),
269 ),
270 |cap: &[u8]| {
271 let year_txt = encoding_rs::UTF_8.decode_without_bom_handling(cap).0;
272 let d = year_txt.parse::<i32>().unwrap_or(0);
273 if (0..=49).contains(&d) {
274 2000 + d
275 } else if (50..=999).contains(&d) {
276 1900 + d
277 } else {
278 d
279 }
280 },
281 )(input)
282}
283
284#[instrument_input("tracing")]
286fn strict_time_of_day(input: &[u8]) -> IResult<&[u8], NaiveTime> {
287 map_opt(
288 tuple((
289 strict_time_digit,
290 tag(":"),
291 strict_time_digit,
292 opt(preceded(tag(":"), strict_time_digit)),
293 )),
294 |(hour, _, minute, maybe_sec)| {
295 NaiveTime::from_hms_opt(hour, minute, maybe_sec.unwrap_or(0))
296 },
297 )(input)
298}
299
300#[instrument_input("tracing")]
302fn obs_time_of_day(input: &[u8]) -> IResult<&[u8], NaiveTime> {
303 map_opt(
304 tuple((
305 obs_time_digit,
306 tag(":"),
307 obs_time_digit,
308 opt(preceded(tag(":"), obs_time_digit)),
309 )),
310 |(hour, _, minute, maybe_sec)| {
311 NaiveTime::from_hms_opt(hour, minute, maybe_sec.unwrap_or(0))
312 },
313 )(input)
314}
315
316fn strict_time_digit(input: &[u8]) -> IResult<&[u8], u32> {
317 character::complete::u32(input)
318}
319
320#[instrument_input("tracing")]
321fn obs_time_digit(input: &[u8]) -> IResult<&[u8], u32> {
322 delimited(opt(cfws), character::complete::u32, opt(cfws))(input)
323}
324
325#[instrument_input("tracing")]
331fn strict_zone(input: &[u8]) -> IResult<&[u8], FixedOffset> {
332 map_opt(
333 tuple((
334 opt(fws),
335 is_a("+-"),
336 take_while_m_n(2, 2, |c| (0x30..=0x39).contains(&c)),
337 take_while_m_n(2, 2, |c| (0x30..=0x39).contains(&c)),
338 )),
339 |(_, op, dig_zone_hour, dig_zone_min)| {
340 let zone_hour: i32 =
341 ((dig_zone_hour[0] - 0x30) * 10 + (dig_zone_hour[1] - 0x30)) as i32;
342 let zone_min: i32 = ((dig_zone_min[0] - 0x30) * 10 + (dig_zone_min[1] - 0x30)) as i32;
343 let zone_hour: i32 = zone_hour.rem_euclid(24);
345 if zone_min >= 60 {
348 return None;
349 }
350 match op {
351 b"+" => FixedOffset::east_opt(zone_hour * HOUR + zone_min * MIN),
352 b"-" => FixedOffset::west_opt(zone_hour * HOUR + zone_min * MIN),
353 _ => unreachable!(),
354 }
355 },
356 )(input)
357}
358
359#[instrument_input("tracing")]
376#[expect(clippy::identity_op)]
377#[expect(clippy::erasing_op)]
378fn obs_zone(input: &[u8]) -> IResult<&[u8], FixedOffset> {
379 preceded(
382 opt(fws),
383 map_opt(alphanumeric1, |zname: &[u8]| {
384 let zname = zname.to_ascii_lowercase();
385 match zname.as_slice() {
386 b"utc" | b"ut" | b"gmt" => FixedOffset::west_opt(0 * HOUR),
388 b"edt" => FixedOffset::west_opt(4 * HOUR),
390 b"est" | b"cdt" => FixedOffset::west_opt(5 * HOUR),
391 b"cst" | b"mdt" => FixedOffset::west_opt(6 * HOUR),
392 b"mst" | b"pdt" => FixedOffset::west_opt(7 * HOUR),
393 b"pst" => FixedOffset::west_opt(8 * HOUR),
394 b"z" => FixedOffset::west_opt(0 * HOUR),
396 b"a" => FixedOffset::east_opt(1 * HOUR),
398 b"b" => FixedOffset::east_opt(2 * HOUR),
399 b"c" => FixedOffset::east_opt(3 * HOUR),
400 b"d" => FixedOffset::east_opt(4 * HOUR),
401 b"e" => FixedOffset::east_opt(5 * HOUR),
402 b"f" => FixedOffset::east_opt(6 * HOUR),
403 b"g" => FixedOffset::east_opt(7 * HOUR),
404 b"h" => FixedOffset::east_opt(8 * HOUR),
405 b"i" => FixedOffset::east_opt(9 * HOUR),
406 b"k" => FixedOffset::east_opt(10 * HOUR),
407 b"l" => FixedOffset::east_opt(11 * HOUR),
408 b"m" => FixedOffset::east_opt(12 * HOUR),
409 b"n" => FixedOffset::west_opt(1 * HOUR),
411 b"o" => FixedOffset::west_opt(2 * HOUR),
412 b"p" => FixedOffset::west_opt(3 * HOUR),
413 b"q" => FixedOffset::west_opt(4 * HOUR),
414 b"r" => FixedOffset::west_opt(5 * HOUR),
415 b"s" => FixedOffset::west_opt(6 * HOUR),
416 b"t" => FixedOffset::west_opt(7 * HOUR),
417 b"u" => FixedOffset::west_opt(8 * HOUR),
418 b"v" => FixedOffset::west_opt(9 * HOUR),
419 b"w" => FixedOffset::west_opt(10 * HOUR),
420 b"x" => FixedOffset::west_opt(11 * HOUR),
421 b"y" => FixedOffset::west_opt(12 * HOUR),
422 _ => FixedOffset::west_opt(0 * HOUR),
424 }
425 }),
426 )(input)
427}
428
429#[expect(clippy::erasing_op)]
432fn no_zone_eof(input: &[u8]) -> IResult<&[u8], FixedOffset> {
433 #[cfg(feature = "tracing-recover")]
434 warn!("missing zone from date-time");
435 map_opt(
436 value(FixedOffset::west_opt(0 * HOUR), pair(opt(cfws), eof)),
437 |tz| tz,
438 )(input)
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use crate::print::tests::print_to_vec;
445 use chrono::TimeZone;
446
447 fn date_parsed_printed(date: &[u8], printed: &[u8], parsed: DateTime) {
448 assert_eq!(date_time(date).unwrap(), (&b""[..], parsed.clone()));
449 let reprinted = print_to_vec(parsed);
450 assert_eq!(
451 String::from_utf8_lossy(&reprinted),
452 String::from_utf8_lossy(printed)
453 );
454 }
455
456 #[test]
457 fn test_date_time_rfc_strict() {
458 date_parsed_printed(
459 b"Fri, 21 Nov 1997 09:55:06 -0600",
460 b"Fri, 21 Nov 1997 09:55:06 -0600",
461 DateTime(
462 FixedOffset::west_opt(6 * HOUR)
463 .unwrap()
464 .with_ymd_and_hms(1997, 11, 21, 9, 55, 6)
465 .unwrap(),
466 ),
467 );
468 }
469
470 #[test]
471 fn test_date_time_received() {
472 date_parsed_printed(
473 b"Sun, 18 Jun 2023 15:39:08 +0200 (CEST)",
474 b"Sun, 18 Jun 2023 15:39:08 +0200",
475 DateTime(
476 FixedOffset::east_opt(2 * HOUR)
477 .unwrap()
478 .with_ymd_and_hms(2023, 6, 18, 15, 39, 8)
479 .unwrap(),
480 ),
481 );
482 }
483
484 #[test]
485 fn test_date_time_rfc_ws() {
486 date_parsed_printed(
487 r#"Thu,
488 13
489 Feb
490 1969
491 23:32
492 -0330 (Newfoundland Time)"#
493 .as_bytes(),
494 b"Thu, 13 Feb 1969 23:32:00 -0330",
495 DateTime(
496 FixedOffset::west_opt(3 * HOUR + 30 * MIN)
497 .unwrap()
498 .with_ymd_and_hms(1969, 2, 13, 23, 32, 00)
499 .unwrap(),
500 ),
501 );
502 }
503
504 #[test]
505 fn test_date_time_rfc_obs() {
506 date_parsed_printed(
507 b"21 Nov 97 09:55:06 GMT",
508 b"Fri, 21 Nov 1997 09:55:06 +0000",
509 DateTime(
510 FixedOffset::east_opt(0)
511 .unwrap()
512 .with_ymd_and_hms(1997, 11, 21, 9, 55, 6)
513 .unwrap(),
514 ),
515 );
516 }
517
518 #[test]
519 fn test_date_time_3digit_year() {
520 date_parsed_printed(
521 b"21 Nov 103 09:55:06 UT",
522 b"Fri, 21 Nov 2003 09:55:06 +0000",
523 DateTime(
524 FixedOffset::east_opt(0)
525 .unwrap()
526 .with_ymd_and_hms(2003, 11, 21, 9, 55, 6)
527 .unwrap(),
528 ),
529 );
530 }
531
532 #[test]
533 fn test_date_time_rfc_obs_ws() {
534 date_parsed_printed(
535 b"Fri, 21 Nov 1997 09(comment): 55 : 06 -0600",
536 b"Fri, 21 Nov 1997 09:55:06 -0600",
537 DateTime(
538 FixedOffset::west_opt(6 * HOUR)
539 .unwrap()
540 .with_ymd_and_hms(1997, 11, 21, 9, 55, 6)
541 .unwrap(),
542 ),
543 );
544 }
545
546 #[test]
547 fn test_date_time_2digit_year() {
548 date_parsed_printed(
549 b"21 Nov 23 09:55:06Z",
550 b"Tue, 21 Nov 2023 09:55:06 +0000",
551 DateTime(
552 FixedOffset::east_opt(0)
553 .unwrap()
554 .with_ymd_and_hms(2023, 11, 21, 9, 55, 6)
555 .unwrap(),
556 ),
557 );
558 }
559
560 #[test]
561 fn test_date_time_military_zone_east() {
562 ["a", "B", "c", "D", "e", "F", "g", "H", "i", "K", "l", "M"]
563 .iter()
564 .enumerate()
565 .for_each(|(i, x)| {
566 assert_eq!(
567 date_time(format!("1 Jan 22 08:00:00 {}", x).as_bytes()),
568 Ok((
569 &b""[..],
570 DateTime(
571 FixedOffset::east_opt((i as i32 + 1) * HOUR)
572 .unwrap()
573 .with_ymd_and_hms(2022, 01, 01, 8, 0, 0)
574 .unwrap()
575 )
576 ))
577 );
578 });
579 }
580
581 #[test]
582 fn test_date_time_military_zone_west() {
583 ["N", "O", "P", "q", "r", "s", "T", "U", "V", "w", "x", "y"]
584 .iter()
585 .enumerate()
586 .for_each(|(i, x)| {
587 assert_eq!(
588 date_time(format!("1 Jan 22 08:00:00 {}", x).as_bytes()),
589 Ok((
590 &b""[..],
591 DateTime(
592 FixedOffset::west_opt((i as i32 + 1) * HOUR)
593 .unwrap()
594 .with_ymd_and_hms(2022, 01, 01, 8, 0, 0)
595 .unwrap()
596 )
597 ))
598 );
599 });
600 }
601
602 #[test]
603 fn test_date_time_gmt() {
604 date_parsed_printed(
605 b"21 Nov 2023 07:07:07 +0000",
606 b"Tue, 21 Nov 2023 07:07:07 +0000",
607 DateTime(
608 FixedOffset::east_opt(0)
609 .unwrap()
610 .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
611 .unwrap(),
612 ),
613 );
614 date_parsed_printed(
615 b"21 Nov 2023 07:07:07 -0000",
616 b"Tue, 21 Nov 2023 07:07:07 +0000",
617 DateTime(
618 FixedOffset::east_opt(0)
619 .unwrap()
620 .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
621 .unwrap(),
622 ),
623 );
624 date_parsed_printed(
625 b"21 Nov 2023 07:07:07 Z",
626 b"Tue, 21 Nov 2023 07:07:07 +0000",
627 DateTime(
628 FixedOffset::east_opt(0)
629 .unwrap()
630 .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
631 .unwrap(),
632 ),
633 );
634 date_parsed_printed(
635 b"21 Nov 2023 07:07:07 GMT",
636 b"Tue, 21 Nov 2023 07:07:07 +0000",
637 DateTime(
638 FixedOffset::east_opt(0)
639 .unwrap()
640 .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
641 .unwrap(),
642 ),
643 );
644 date_parsed_printed(
645 b"21 Nov 2023 07:07:07 UT",
646 b"Tue, 21 Nov 2023 07:07:07 +0000",
647 DateTime(
648 FixedOffset::east_opt(0)
649 .unwrap()
650 .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
651 .unwrap(),
652 ),
653 );
654 date_parsed_printed(
655 b"21 Nov 2023 07:07:07 UTC",
656 b"Tue, 21 Nov 2023 07:07:07 +0000",
657 DateTime(
658 FixedOffset::east_opt(0)
659 .unwrap()
660 .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
661 .unwrap(),
662 ),
663 );
664 }
665
666 #[test]
667 fn test_date_time_usa() {
668 date_parsed_printed(
669 b"21 Nov 2023 4:4:4 CST",
670 b"Tue, 21 Nov 2023 04:04:04 -0600",
671 DateTime(
672 FixedOffset::west_opt(6 * HOUR)
673 .unwrap()
674 .with_ymd_and_hms(2023, 11, 21, 4, 4, 4)
675 .unwrap(),
676 ),
677 );
678 }
679
680 #[test]
681 fn test_date_time_oob_zone_hours() {
682 date_parsed_printed(
683 b"26 Aug 2316 09:06:21 -4508",
684 b"Sat, 26 Aug 2316 09:06:21 -2108",
685 DateTime(
686 FixedOffset::west_opt(21 * HOUR + 08 * MIN)
687 .unwrap()
688 .with_ymd_and_hms(2316, 08, 26, 9, 6, 21)
689 .unwrap(),
690 ),
691 );
692 }
693
694 #[test]
695 fn test_date_time_oob_zone_mins() {
696 assert!(date_time(b"26 Aug 2316 09:06:21 -2160").is_err());
697 }
698
699 #[test]
700 fn test_date_time_no_zone() {
701 date_parsed_printed(
702 b"21 Nov 2023 07:07:07 ",
703 b"Tue, 21 Nov 2023 07:07:07 +0000",
704 DateTime(
705 FixedOffset::east_opt(0)
706 .unwrap()
707 .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
708 .unwrap(),
709 ),
710 );
711 }
712
713 #[test]
714 fn test_date_time_unknown_zone() {
715 date_parsed_printed(
716 b" Mon, 20 Nov 1995 16:54:06 MET",
717 b"Mon, 20 Nov 1995 16:54:06 +0000",
718 DateTime(
719 FixedOffset::east_opt(0)
720 .unwrap()
721 .with_ymd_and_hms(1995, 11, 20, 16, 54, 06)
722 .unwrap(),
723 ),
724 );
725 }
726}