eml_codec/imf/
datetime.rs

1use chrono::{DateTime, FixedOffset, NaiveDate, NaiveTime};
2use nom::{
3    branch::alt,
4    bytes::complete::{is_a, tag, tag_no_case, take_while_m_n},
5    character,
6    character::complete::{alphanumeric1, digit0},
7    combinator::{map, opt, value},
8    sequence::{delimited, preceded, terminated, tuple},
9    IResult,
10};
11
12use crate::text::whitespace::{cfws, fws};
13//use crate::error::IMFError;
14
15const MIN: i32 = 60;
16const HOUR: i32 = 60 * MIN;
17
18/*
19impl<'a> TryFrom<&'a lazy::DateTime<'a>> for DateTime<FixedOffset> {
20    type Error = IMFError<'a>;
21
22    fn try_from(value: &'a lazy::DateTime<'a>) -> Result<Self, Self::Error> {
23        match section(value.0) {
24            Ok((_, Some(dt))) => Ok(dt),
25            Err(e) => Err(IMFError::DateTimeParse(e)),
26            _ => Err(IMFError::DateTimeLogic),
27        }
28    }
29}*/
30
31/// Read datetime
32///
33/// ```abnf
34/// date-time       =   [ day-of-week "," ] date time [CFWS]
35/// time            =   time-of-day zone
36/// ```
37///
38/// ## @FIXME - known bugs
39///  
40///   - if chrono fails, Option::None is silently returned instead of failing the parser
41///   - `-0000` means NaiveDateTime, a date without a timezone
42/// while this library interprets it as +0000 aka UTC.
43///   - Obsolete military zones should be considered as NaiveTime
44/// due to an error in RFC0822 but are interpreted as their respective
45/// timezone according to the RFC5322 definition
46pub fn section(input: &[u8]) -> IResult<&[u8], Option<DateTime<FixedOffset>>> {
47    map(
48        terminated(
49            alt((
50                tuple((
51                    opt(terminated(strict_day_of_week, tag(","))),
52                    strict_date,
53                    strict_time_of_day,
54                    strict_zone,
55                )),
56                tuple((
57                    opt(terminated(obs_day_of_week, tag(","))),
58                    obs_date,
59                    obs_time_of_day,
60                    alt((strict_zone, obs_zone)),
61                )),
62            )),
63            opt(cfws),
64        ),
65        |res| match res {
66            (_, Some(date), Some(time), Some(tz)) => {
67                date.and_time(time).and_local_timezone(tz).earliest()
68            }
69            _ => None,
70        },
71    )(input)
72}
73
74///    day-of-week     =   ([FWS] day-name) / obs-day-of-week
75fn strict_day_of_week(input: &[u8]) -> IResult<&[u8], &[u8]> {
76    preceded(opt(fws), day_name)(input)
77}
78
79///    obs-day-of-week =   [CFWS] day-name [CFWS]
80fn obs_day_of_week(input: &[u8]) -> IResult<&[u8], &[u8]> {
81    delimited(opt(cfws), day_name, opt(cfws))(input)
82}
83
84///   day-name        =   "Mon" / "Tue" / "Wed" / "Thu" /
85///                       "Fri" / "Sat" / "Sun"
86fn day_name(input: &[u8]) -> IResult<&[u8], &[u8]> {
87    alt((
88        tag_no_case(b"Mon"),
89        tag_no_case(b"Tue"),
90        tag_no_case(b"Wed"),
91        tag_no_case(b"Thu"),
92        tag_no_case(b"Fri"),
93        tag_no_case(b"Sat"),
94        tag_no_case(b"Sun"),
95    ))(input)
96}
97
98///    date            =   day month year
99fn strict_date(input: &[u8]) -> IResult<&[u8], Option<NaiveDate>> {
100    map(tuple((strict_day, month, strict_year)), |(d, m, y)| {
101        NaiveDate::from_ymd_opt(y, m, d)
102    })(input)
103}
104
105///    date            =   day month year
106fn obs_date(input: &[u8]) -> IResult<&[u8], Option<NaiveDate>> {
107    map(tuple((obs_day, month, obs_year)), |(d, m, y)| {
108        NaiveDate::from_ymd_opt(y, m, d)
109    })(input)
110}
111
112///    day             =   ([FWS] 1*2DIGIT FWS) / obs-day
113fn strict_day(input: &[u8]) -> IResult<&[u8], u32> {
114    delimited(opt(fws), character::complete::u32, fws)(input)
115}
116
117///    obs-day         =   [CFWS] 1*2DIGIT [CFWS]
118fn obs_day(input: &[u8]) -> IResult<&[u8], u32> {
119    delimited(opt(cfws), character::complete::u32, opt(cfws))(input)
120}
121
122///  month           =   "Jan" / "Feb" / "Mar" / "Apr" /
123///                      "May" / "Jun" / "Jul" / "Aug" /
124///                      "Sep" / "Oct" / "Nov" / "Dec"
125fn month(input: &[u8]) -> IResult<&[u8], u32> {
126    alt((
127        value(1, tag_no_case(b"Jan")),
128        value(2, tag_no_case(b"Feb")),
129        value(3, tag_no_case(b"Mar")),
130        value(4, tag_no_case(b"Apr")),
131        value(5, tag_no_case(b"May")),
132        value(6, tag_no_case(b"Jun")),
133        value(7, tag_no_case(b"Jul")),
134        value(8, tag_no_case(b"Aug")),
135        value(9, tag_no_case(b"Sep")),
136        value(10, tag_no_case(b"Oct")),
137        value(11, tag_no_case(b"Nov")),
138        value(12, tag_no_case(b"Dec")),
139    ))(input)
140}
141
142///   year            =   (FWS 4*DIGIT FWS) / obs-year
143fn strict_year(input: &[u8]) -> IResult<&[u8], i32> {
144    delimited(
145        fws,
146        map(
147            terminated(take_while_m_n(4, 9, |c| (0x30..=0x39).contains(&c)), digit0),
148            |d: &[u8]| {
149                encoding_rs::UTF_8
150                    .decode_without_bom_handling(d)
151                    .0
152                    .parse::<i32>()
153                    .unwrap_or(0)
154            },
155        ),
156        fws,
157    )(input)
158}
159
160///   obs-year        =   [CFWS] 2*DIGIT [CFWS]
161fn obs_year(input: &[u8]) -> IResult<&[u8], i32> {
162    map(
163        delimited(
164            opt(cfws),
165            terminated(take_while_m_n(2, 7, |c| (0x30..=0x39).contains(&c)), digit0),
166            opt(cfws),
167        ),
168        |cap: &[u8]| {
169            let year_txt = encoding_rs::UTF_8.decode_without_bom_handling(cap).0;
170            let d = year_txt.parse::<i32>().unwrap_or(0);
171            if (0..=49).contains(&d) {
172                2000 + d
173            } else if (50..=999).contains(&d) {
174                1900 + d
175            } else {
176                d
177            }
178        },
179    )(input)
180}
181
182///   time-of-day     =   hour ":" minute [ ":" second ]
183fn strict_time_of_day(input: &[u8]) -> IResult<&[u8], Option<NaiveTime>> {
184    map(
185        tuple((
186            strict_time_digit,
187            tag(":"),
188            strict_time_digit,
189            opt(preceded(tag(":"), strict_time_digit)),
190        )),
191        |(hour, _, minute, maybe_sec)| {
192            NaiveTime::from_hms_opt(hour, minute, maybe_sec.unwrap_or(0))
193        },
194    )(input)
195}
196
197///   time-of-day     =   hour ":" minute [ ":" second ]
198fn obs_time_of_day(input: &[u8]) -> IResult<&[u8], Option<NaiveTime>> {
199    map(
200        tuple((
201            obs_time_digit,
202            tag(":"),
203            obs_time_digit,
204            opt(preceded(tag(":"), obs_time_digit)),
205        )),
206        |(hour, _, minute, maybe_sec)| {
207            NaiveTime::from_hms_opt(hour, minute, maybe_sec.unwrap_or(0))
208        },
209    )(input)
210}
211
212fn strict_time_digit(input: &[u8]) -> IResult<&[u8], u32> {
213    character::complete::u32(input)
214}
215
216fn obs_time_digit(input: &[u8]) -> IResult<&[u8], u32> {
217    delimited(opt(cfws), character::complete::u32, opt(cfws))(input)
218}
219
220/// Obsolete zones
221///
222/// ```abnf
223///   zone            =   (FWS ( "+" / "-" ) 4DIGIT) / (FWS obs-zone)
224/// ```
225fn strict_zone(input: &[u8]) -> IResult<&[u8], Option<FixedOffset>> {
226    map(
227        tuple((
228            opt(fws),
229            is_a("+-"),
230            take_while_m_n(2, 2, |c| (0x30..=0x39).contains(&c)),
231            take_while_m_n(2, 2, |c| (0x30..=0x39).contains(&c)),
232        )),
233        |(_, op, dig_zone_hour, dig_zone_min)| {
234            let zone_hour: i32 =
235                ((dig_zone_hour[0] - 0x30) * 10 + (dig_zone_hour[1] - 0x30)) as i32 * HOUR;
236            let zone_min: i32 =
237                ((dig_zone_min[0] - 0x30) * 10 + (dig_zone_min[1] - 0x30)) as i32 * MIN;
238            match op {
239                b"+" => FixedOffset::east_opt(zone_hour + zone_min),
240                b"-" => FixedOffset::west_opt(zone_hour + zone_min),
241                _ => unreachable!(),
242            }
243        },
244    )(input)
245}
246
247/// obsole zone
248///
249///   obs-zone        =   "UT" / "GMT" /     ; Universal Time
250///                                          ; North American UT
251///                                          ; offsets
252///                       "EST" / "EDT" /    ; Eastern:  - 5/ - 4
253///                       "CST" / "CDT" /    ; Central:  - 6/ - 5
254///                       "MST" / "MDT" /    ; Mountain: - 7/ - 6
255///                       "PST" / "PDT" /    ; Pacific:  - 8/ - 7
256///                                          ;
257///                       %d65-73 /          ; Military zones - "A"
258///                       %d75-90 /          ; through "I" and "K"
259///                       %d97-105 /         ; through "Z", both
260///                       %d107-122 /        ; upper and lower case
261///                                          ;
262///                       1*(ALPHA / DIGIT)  ; Unknown legacy timezones
263fn obs_zone(input: &[u8]) -> IResult<&[u8], Option<FixedOffset>> {
264    // The writing of this function is volontarily verbose
265    // to keep it straightforward to understand.
266    // @FIXME: Could return a TimeZone and not an Option<TimeZone>
267    // as it could be determined at compile time if values are correct
268    // and panic at this time if not. But not sure how to do it without unwrap.
269    preceded(
270        opt(fws),
271        alt((
272            // Legacy UTC/GMT
273            value(
274                FixedOffset::west_opt(0 * HOUR),
275                alt((tag_no_case(b"UTC"), tag_no_case(b"UT"), tag_no_case(b"GMT"))),
276            ),
277            // USA Timezones
278            value(FixedOffset::west_opt(4 * HOUR), tag_no_case(b"EDT")),
279            value(
280                FixedOffset::west_opt(5 * HOUR),
281                alt((tag_no_case(b"EST"), tag_no_case(b"CDT"))),
282            ),
283            value(
284                FixedOffset::west_opt(6 * HOUR),
285                alt((tag_no_case(b"CST"), tag_no_case(b"MDT"))),
286            ),
287            value(
288                FixedOffset::west_opt(7 * HOUR),
289                alt((tag_no_case(b"MST"), tag_no_case(b"PDT"))),
290            ),
291            value(FixedOffset::west_opt(8 * HOUR), tag_no_case(b"PST")),
292            // Military Timezone UTC
293            value(FixedOffset::west_opt(0 * HOUR), tag_no_case(b"Z")),
294            // Military Timezones East
295            alt((
296                value(FixedOffset::east_opt(HOUR), tag_no_case(b"A")),
297                value(FixedOffset::east_opt(2 * HOUR), tag_no_case(b"B")),
298                value(FixedOffset::east_opt(3 * HOUR), tag_no_case(b"C")),
299                value(FixedOffset::east_opt(4 * HOUR), tag_no_case(b"D")),
300                value(FixedOffset::east_opt(5 * HOUR), tag_no_case(b"E")),
301                value(FixedOffset::east_opt(6 * HOUR), tag_no_case(b"F")),
302                value(FixedOffset::east_opt(7 * HOUR), tag_no_case(b"G")),
303                value(FixedOffset::east_opt(8 * HOUR), tag_no_case(b"H")),
304                value(FixedOffset::east_opt(9 * HOUR), tag_no_case(b"I")),
305                value(FixedOffset::east_opt(10 * HOUR), tag_no_case(b"K")),
306                value(FixedOffset::east_opt(11 * HOUR), tag_no_case(b"L")),
307                value(FixedOffset::east_opt(12 * HOUR), tag_no_case(b"M")),
308            )),
309            // Military Timezones West
310            alt((
311                value(FixedOffset::west_opt(HOUR), tag_no_case(b"N")),
312                value(FixedOffset::west_opt(2 * HOUR), tag_no_case(b"O")),
313                value(FixedOffset::west_opt(3 * HOUR), tag_no_case(b"P")),
314                value(FixedOffset::west_opt(4 * HOUR), tag_no_case(b"Q")),
315                value(FixedOffset::west_opt(5 * HOUR), tag_no_case(b"R")),
316                value(FixedOffset::west_opt(6 * HOUR), tag_no_case(b"S")),
317                value(FixedOffset::west_opt(7 * HOUR), tag_no_case(b"T")),
318                value(FixedOffset::west_opt(8 * HOUR), tag_no_case(b"U")),
319                value(FixedOffset::west_opt(9 * HOUR), tag_no_case(b"V")),
320                value(FixedOffset::west_opt(10 * HOUR), tag_no_case(b"W")),
321                value(FixedOffset::west_opt(11 * HOUR), tag_no_case(b"X")),
322                value(FixedOffset::west_opt(12 * HOUR), tag_no_case(b"Y")),
323            )),
324            // Unknown timezone
325            value(FixedOffset::west_opt(0 * HOUR), alphanumeric1),
326        )),
327    )(input)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use chrono::TimeZone;
334
335    #[test]
336    fn test_section_rfc_strict() {
337        assert_eq!(
338            section(b"Fri, 21 Nov 1997 09:55:06 -0600"),
339            Ok((
340                &b""[..],
341                Some(
342                    FixedOffset::west_opt(6 * HOUR)
343                        .unwrap()
344                        .with_ymd_and_hms(1997, 11, 21, 9, 55, 6)
345                        .unwrap()
346                )
347            )),
348        );
349    }
350
351    #[test]
352    fn test_section_received() {
353        assert_eq!(
354            section(b"Sun, 18 Jun 2023 15:39:08 +0200 (CEST)"),
355            Ok((
356                &b""[..],
357                Some(
358                    FixedOffset::east_opt(2 * HOUR)
359                        .unwrap()
360                        .with_ymd_and_hms(2023, 6, 18, 15, 39, 8)
361                        .unwrap()
362                )
363            )),
364        );
365    }
366
367    #[test]
368    fn test_section_rfc_ws() {
369        assert_eq!(
370            section(
371                r#"Thu,
372         13
373           Feb
374             1969
375         23:32
376                  -0330 (Newfoundland Time)"#
377                    .as_bytes()
378            ),
379            Ok((
380                &b""[..],
381                Some(
382                    FixedOffset::west_opt(3 * HOUR + 30 * MIN)
383                        .unwrap()
384                        .with_ymd_and_hms(1969, 2, 13, 23, 32, 00)
385                        .unwrap()
386                )
387            )),
388        );
389    }
390
391    #[test]
392    fn test_section_rfc_obs() {
393        assert_eq!(
394            section(b"21 Nov 97 09:55:06 GMT"),
395            Ok((
396                &b""[..],
397                Some(
398                    FixedOffset::east_opt(0)
399                        .unwrap()
400                        .with_ymd_and_hms(1997, 11, 21, 9, 55, 6)
401                        .unwrap()
402                )
403            )),
404        );
405    }
406
407    #[test]
408    fn test_section_3digit_year() {
409        assert_eq!(
410            section(b"21 Nov 103 09:55:06 UT"),
411            Ok((
412                &b""[..],
413                Some(
414                    FixedOffset::east_opt(0)
415                        .unwrap()
416                        .with_ymd_and_hms(2003, 11, 21, 9, 55, 6)
417                        .unwrap()
418                )
419            )),
420        );
421    }
422
423    #[test]
424    fn test_section_rfc_obs_ws() {
425        assert_eq!(
426            section(b"Fri, 21 Nov 1997 09(comment):   55  :  06 -0600"),
427            Ok((
428                &b""[..],
429                Some(
430                    FixedOffset::west_opt(6 * HOUR)
431                        .unwrap()
432                        .with_ymd_and_hms(1997, 11, 21, 9, 55, 6)
433                        .unwrap()
434                )
435            )),
436        );
437    }
438
439    #[test]
440    fn test_section_2digit_year() {
441        assert_eq!(
442            section(b"21 Nov 23 09:55:06Z"),
443            Ok((
444                &b""[..],
445                Some(
446                    FixedOffset::east_opt(0)
447                        .unwrap()
448                        .with_ymd_and_hms(2023, 11, 21, 9, 55, 6)
449                        .unwrap()
450                )
451            )),
452        );
453    }
454
455    #[test]
456    fn test_section_military_zone_east() {
457        ["a", "B", "c", "D", "e", "F", "g", "H", "i", "K", "l", "M"]
458            .iter()
459            .enumerate()
460            .for_each(|(i, x)| {
461                assert_eq!(
462                    section(format!("1 Jan 22 08:00:00 {}", x).as_bytes()),
463                    Ok((
464                        &b""[..],
465                        Some(
466                            FixedOffset::east_opt((i as i32 + 1) * HOUR)
467                                .unwrap()
468                                .with_ymd_and_hms(2022, 01, 01, 8, 0, 0)
469                                .unwrap()
470                        )
471                    ))
472                );
473            });
474    }
475
476    #[test]
477    fn test_section_military_zone_west() {
478        ["N", "O", "P", "q", "r", "s", "T", "U", "V", "w", "x", "y"]
479            .iter()
480            .enumerate()
481            .for_each(|(i, x)| {
482                assert_eq!(
483                    section(format!("1 Jan 22 08:00:00 {}", x).as_bytes()),
484                    Ok((
485                        &b""[..],
486                        Some(
487                            FixedOffset::west_opt((i as i32 + 1) * HOUR)
488                                .unwrap()
489                                .with_ymd_and_hms(2022, 01, 01, 8, 0, 0)
490                                .unwrap()
491                        )
492                    ))
493                );
494            });
495    }
496
497    #[test]
498    fn test_section_gmt() {
499        assert_eq!(
500            section(b"21 Nov 2023 07:07:07 +0000"),
501            Ok((
502                &b""[..],
503                Some(
504                    FixedOffset::east_opt(0)
505                        .unwrap()
506                        .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
507                        .unwrap()
508                )
509            )),
510        );
511        assert_eq!(
512            section(b"21 Nov 2023 07:07:07 -0000"),
513            Ok((
514                &b""[..],
515                Some(
516                    FixedOffset::east_opt(0)
517                        .unwrap()
518                        .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
519                        .unwrap()
520                )
521            )),
522        );
523        assert_eq!(
524            section(b"21 Nov 2023 07:07:07 Z"),
525            Ok((
526                &b""[..],
527                Some(
528                    FixedOffset::east_opt(0)
529                        .unwrap()
530                        .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
531                        .unwrap()
532                )
533            )),
534        );
535        assert_eq!(
536            section(b"21 Nov 2023 07:07:07 GMT"),
537            Ok((
538                &b""[..],
539                Some(
540                    FixedOffset::east_opt(0)
541                        .unwrap()
542                        .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
543                        .unwrap()
544                )
545            )),
546        );
547        assert_eq!(
548            section(b"21 Nov 2023 07:07:07 UT"),
549            Ok((
550                &b""[..],
551                Some(
552                    FixedOffset::east_opt(0)
553                        .unwrap()
554                        .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
555                        .unwrap()
556                )
557            )),
558        );
559        assert_eq!(
560            section(b"21 Nov 2023 07:07:07 UTC"),
561            Ok((
562                &b""[..],
563                Some(
564                    FixedOffset::east_opt(0)
565                        .unwrap()
566                        .with_ymd_and_hms(2023, 11, 21, 7, 7, 7)
567                        .unwrap()
568                )
569            )),
570        );
571    }
572
573    #[test]
574    fn test_section_usa() {
575        assert_eq!(
576            section(b"21 Nov 2023 4:4:4 CST"),
577            Ok((
578                &b""[..],
579                Some(
580                    FixedOffset::west_opt(6 * HOUR)
581                        .unwrap()
582                        .with_ymd_and_hms(2023, 11, 21, 4, 4, 4)
583                        .unwrap()
584                )
585            )),
586        );
587    }
588}