Skip to main content

eml_codec/imf/
datetime.rs

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// NOTE: must satisfy the following properties:
33// - timezone offset: must be a round hours+minutes (no seconds)
34// - year must be after 1900 or later
35#[derive(Clone, ContainsUtf8, PartialEq)]
36#[contains_utf8(false)]
37pub struct DateTime(pub chrono::DateTime<FixedOffset>);
38
39impl DateTime {
40    // Used as placeholder value for a missing or invalid date
41    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        // date
98        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        // time-of-day
107        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        // zone
114        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/// Read datetime
124///
125/// RFC grammar:
126/// ```abnf
127/// date-time       =   [ day-of-week "," ] date time [CFWS]
128/// time            =   time-of-day zone
129/// ```
130///
131/// We additionally allow dates with a missing zone (followed by end of input),
132/// which appear in some real world emails.
133///
134/// ## @FIXME - known bugs
135///
136///   - `-0000` means NaiveDateTime, a date without a timezone
137/// while this library interprets it as +0000 aka UTC.
138///   - Obsolete military zones should be considered as NaiveTime
139/// due to an error in RFC0822 but are interpreted as their respective
140/// timezone according to the RFC5322 definition
141#[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///    day-of-week     =   ([FWS] day-name) / obs-day-of-week
166#[instrument_input("tracing")]
167fn strict_day_of_week(input: &[u8]) -> IResult<&[u8], &[u8]> {
168    preceded(opt(fws), day_name)(input)
169}
170
171///    obs-day-of-week =   [CFWS] day-name [CFWS]
172#[instrument_input("tracing")]
173fn obs_day_of_week(input: &[u8]) -> IResult<&[u8], &[u8]> {
174    delimited(opt(cfws), day_name, opt(cfws))(input)
175}
176
177///   day-name        =   "Mon" / "Tue" / "Wed" / "Thu" /
178///                       "Fri" / "Sat" / "Sun"
179fn 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///    date            =   day month year
192#[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///    date            =   day month year
200#[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///    day             =   ([FWS] 1*2DIGIT FWS) / obs-day
208#[instrument_input("tracing")]
209fn strict_day(input: &[u8]) -> IResult<&[u8], u32> {
210    delimited(opt(fws), character::complete::u32, fws)(input)
211}
212
213///    obs-day         =   [CFWS] 1*2DIGIT [CFWS]
214#[instrument_input("tracing")]
215fn obs_day(input: &[u8]) -> IResult<&[u8], u32> {
216    delimited(opt(cfws), character::complete::u32, opt(cfws))(input)
217}
218
219///  month           =   "Jan" / "Feb" / "Mar" / "Apr" /
220///                      "May" / "Jun" / "Jul" / "Aug" /
221///                      "Sep" / "Oct" / "Nov" / "Dec"
222fn 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///   year            =   (FWS 4*DIGIT FWS) / obs-year
240#[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///   obs-year        =   [CFWS] 2*DIGIT [CFWS]
259// NOTE: RFC5322 defines obs-year as above, but also defines the interpretation
260// of three digit years (which are not covered by this grammar).
261// The implementation below thus also supports three digit years.
262#[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///   time-of-day     =   hour ":" minute [ ":" second ]
285#[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///   time-of-day     =   hour ":" minute [ ":" second ]
301#[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/// Obsolete zones
326///
327/// ```abnf
328///   zone            =   (FWS ( "+" / "-" ) 4DIGIT) / (FWS obs-zone)
329/// ```
330#[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            // consider zone_hour is to be taken modulo 24h...
344            let zone_hour: i32 = zone_hour.rem_euclid(24);
345            // RFC5322 mandates that zone_min is between 00 and 59; reject the
346            // input if not
347            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/// obsole zone
360///
361///   obs-zone        =   "UT" / "GMT" /     ; Universal Time
362///                                          ; North American UT
363///                                          ; offsets
364///                       "EST" / "EDT" /    ; Eastern:  - 5/ - 4
365///                       "CST" / "CDT" /    ; Central:  - 6/ - 5
366///                       "MST" / "MDT" /    ; Mountain: - 7/ - 6
367///                       "PST" / "PDT" /    ; Pacific:  - 8/ - 7
368///                                          ;
369///                       %d65-73 /          ; Military zones - "A"
370///                       %d75-90 /          ; through "I" and "K"
371///                       %d97-105 /         ; through "Z", both
372///                       %d107-122 /        ; upper and lower case
373///                                          ;
374///                       1*(ALPHA / DIGIT)  ; Unknown legacy timezones
375#[instrument_input("tracing")]
376#[expect(clippy::identity_op)]
377#[expect(clippy::erasing_op)]
378fn obs_zone(input: &[u8]) -> IResult<&[u8], FixedOffset> {
379    // The writing of this function is volontarily verbose
380    // to keep it straightforward to understand.
381    preceded(
382        opt(fws),
383        map_opt(alphanumeric1, |zname: &[u8]| {
384            let zname = zname.to_ascii_lowercase();
385            match zname.as_slice() {
386                // Legacy UTC/GMT
387                b"utc" | b"ut" | b"gmt" => FixedOffset::west_opt(0 * HOUR),
388                // USA Timezones
389                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                // Military Timezone UTC
395                b"z" => FixedOffset::west_opt(0 * HOUR),
396                // Military Timezones East
397                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                // Military Timezones West
410                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                // Unknown timezone
423                _ => FixedOffset::west_opt(0 * HOUR),
424            }
425        }),
426    )(input)
427}
428
429// This is a hack to handle dates that do not specify a timezone. Unfortunately
430// this is quite common.
431#[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}