liquid_core/model/scalar/datetime/
strftime.rs

1use std::fmt::{self, Write};
2
3// std::fmt::Write is infallible for String https://doc.rust-lang.org/src/alloc/string.rs.html#2726
4// and would only ever fail if we were OOM which Rust won't handle regardless
5// so we simplify writes code since we know it can't fail
6macro_rules! w {
7    ($output:expr, $($arg:tt)*) => {
8        $output.write_fmt(format_args!($($arg)*)).unwrap()
9    }
10}
11
12#[derive(Debug, PartialEq, Eq)]
13pub enum DateFormatError {
14    /// A % was not followed by any format specifier
15    NoFormatSpecifier,
16    /// The pad width could not be parsed
17    InvalidWidth(std::num::ParseIntError),
18    /// An 'E' or 'O' modifier was encountered and ignored, but there was no
19    /// format specifier after it
20    NoFormatSpecifierAfterModifier,
21}
22
23impl std::error::Error for DateFormatError {
24    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
25        match self {
26            Self::InvalidWidth(e) => Some(e),
27            _ => None,
28        }
29    }
30}
31
32impl fmt::Display for DateFormatError {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::NoFormatSpecifier => f.write_str("no format specifier following '%'"),
36            Self::NoFormatSpecifierAfterModifier => {
37                f.write_str("no format specifier following '%' with a format modifier")
38            }
39            Self::InvalidWidth(err) => {
40                write!(f, "failed to parse padding width: {}", err)
41            }
42        }
43    }
44}
45
46/// An implementation of [stftime](https://man7.org/linux/man-pages/man3/strftime.3.html) style formatting.
47///
48/// Note that in liquid's case we implement the variant
49/// [Ruby](https://ruby-doc.org/core-3.0.0/Time.html#method-i-strftime) in
50/// particular supports, which may have some deviations from eg C or python etc
51///
52/// Know exceptions are listed below:
53///
54/// - `%Z` is used to print the (possibly) abbreviated time zone name. `chrono`
55///   did not actually implement this and instead just put the UTC offset with a
56///   colon, ie +/-HH:MM, and Ruby itself recommends _not_ using `%Z` as it is
57///   OS-dependent on what the string will be, in addition to the abbreviated time
58///   zone names being ambiguous. `Z` is also not supported at all by liquidjs.
59pub fn strftime(ts: time::OffsetDateTime, fmt: &str) -> Result<String, DateFormatError> {
60    let mut output = String::new();
61    let mut fmt_iter = fmt.char_indices().peekable();
62
63    while let Some((ind, c)) = fmt_iter.next() {
64        if c != '%' {
65            output.push(c);
66            continue;
67        }
68
69        // Keep track of where the '%' was located, if an unknown format specifier
70        // is used we backtrack and copy the whole string directly to the output
71        let fmt_pos = ind;
72        let mut cursor = ind;
73
74        macro_rules! next {
75            () => {{
76                let next = fmt_iter.next();
77                if let Some(nxt) = next {
78                    cursor = nxt.0;
79                }
80                next
81            }};
82        }
83
84        // Padding is enabled by default, but once it is turned off with `-`
85        // it can't be turned on again. Note that the Ruby docs say "don't pad
86        // numerical output" but it applies to all format specifiers
87        let mut use_padding = true;
88        // Numbers are padding with 0 by default, alphabetical by space. At
89        // least in the Ruby, the `_` and `0` flags that affect the padding
90        // character used can be specified multiple times, but the last one always wins
91        let mut padding_style = PaddingStyle::Default;
92        // Alphabetical characters will have a default casing eg. "Thu",
93        // but can have it changed to uppercase with `^` or inverted with `#`,
94        // however note that `#` does not apply to all format specifiers, eg.
95        // "Thu" becomes "THU" not "tHU". Like the `_` and `0` flag, the last
96        // one wins.
97        let mut casing = Casing::Default;
98
99        let (ind, c) = loop {
100            match fmt_iter.peek() {
101                // whether output is padded or not
102                Some((_, '-')) => use_padding = false,
103                // use spaces for padding
104                Some((_, '_')) => padding_style = PaddingStyle::Space,
105                // use zeros for padding
106                Some((_, '0')) => padding_style = PaddingStyle::Zero,
107                // upcase the result string
108                Some((_, '^')) => casing = Casing::Upper,
109                // change case
110                Some((_, '#')) => casing = Casing::Change,
111                None => {
112                    return Err(DateFormatError::NoFormatSpecifier);
113                }
114                // NOTE: Even though in eg. Ruby they say that ':' is a flag,
115                // it actually can't come before any width specification, it
116                // also doesn't work in conjunction with the (ignored) E/O
117                // modifiers so we just parse it as a special case
118                Some(next) => break *next,
119            }
120
121            next!();
122        };
123
124        let padding = if c.is_ascii_digit() {
125            loop {
126                match fmt_iter.peek() {
127                    Some((_, c)) if c.is_ascii_digit() => {
128                        next!();
129                    }
130                    Some((dind, _c)) => {
131                        let padding: usize = fmt[ind..*dind]
132                            .parse()
133                            .map_err(DateFormatError::InvalidWidth)?;
134
135                        break Some(padding);
136                    }
137                    None => {
138                        return Err(DateFormatError::NoFormatSpecifier);
139                    }
140                }
141            }
142        } else {
143            None
144        };
145
146        let (_ind, fmt_char) = {
147            let (ind, fmt_char) = next!().ok_or(DateFormatError::NoFormatSpecifier)?;
148            // The E and O modifiers are recognized by Ruby, but ignored
149            if fmt_char == 'E' || fmt_char == 'O' {
150                next!().ok_or(DateFormatError::NoFormatSpecifierAfterModifier)?
151            } else {
152                (ind, fmt_char)
153            }
154        };
155
156        enum Formats {
157            Numeric(i64, usize),
158            Alphabetical(&'static str),
159            Formatted,
160            Literal(char),
161            Unknown,
162        }
163
164        let out_cur = output.len();
165
166        macro_rules! write_padding {
167            (num $pad_width:expr) => {
168                for _ in 0..$pad_width {
169                    output.push(match padding_style {
170                        PaddingStyle::Default | PaddingStyle::Zero => '0',
171                        PaddingStyle::Space => ' ',
172                    });
173                }
174            };
175            (comp $pad_width:expr) => {
176                if let Some(padding) = padding {
177                    for _ in 0..padding.saturating_sub($pad_width) {
178                        output.push(match padding_style {
179                            PaddingStyle::Default | PaddingStyle::Space => ' ',
180                            PaddingStyle::Zero => '0',
181                        });
182                    }
183                }
184            };
185            ($pad_width:expr) => {
186                for _ in 0..$pad_width {
187                    output.push(match padding_style {
188                        PaddingStyle::Default | PaddingStyle::Space => ' ',
189                        PaddingStyle::Zero => '0',
190                    });
191                }
192            };
193        }
194
195        let format = match fmt_char {
196            // The full proleptic Gregorian year, zero-padded to 4 digits
197            'Y' => Formats::Numeric(ts.year() as _, 4),
198            // The proleptic Gregorian year divided by 100, zero-padded to 2 digits.
199            'C' => Formats::Numeric(ts.year() as i64 / 100, 2),
200            // The proleptic Gregorian year modulo 100, zero-padded to 2 digits
201            'y' => Formats::Numeric(ts.year() as i64 % 100, 2),
202            // Month number (01--12), zero-padded to 2 digits.
203            'm' => Formats::Numeric(ts.month() as _, 2),
204            // Day number (01--31), zero-padded to 2 digits.
205            // Same as %d but space-padded. Same as %_d.
206            'd' | 'e' => {
207                if fmt_char == 'e' && padding_style == PaddingStyle::Default {
208                    padding_style = PaddingStyle::Space;
209                }
210                Formats::Numeric(ts.day() as _, 2)
211            }
212            // Sunday = 0, Monday = 1, ..., Saturday = 6.
213            'w' => Formats::Numeric(ts.weekday().number_days_from_sunday() as _, 0),
214            // Monday = 1, Tuesday = 2, ..., Sunday = 7. (ISO 8601)
215            'u' => Formats::Numeric(ts.weekday().number_from_monday() as _, 0),
216            // Week number starting with Sunday (00--53), zero-padded to 2 digits.
217            'U' => Formats::Numeric(ts.sunday_based_week() as _, 2),
218            // Same as %U, but week 1 starts with the first Monday in that year instead.
219            'W' => Formats::Numeric(ts.monday_based_week() as _, 2),
220            // Same as %Y but uses the year number in ISO 8601 week date.
221            'G' => Formats::Numeric(ts.to_iso_week_date().0 as _, 4),
222            // Same as %y but uses the year number in ISO 8601 week date.
223            'g' => Formats::Numeric(ts.to_iso_week_date().0 as i64 % 100, 2),
224            // Same as %U but uses the week number in ISO 8601 week date (01--53).
225            'V' => Formats::Numeric(ts.to_iso_week_date().1 as _, 2),
226            // Day of the year (001--366), zero-padded to 3 digits.
227            'j' => Formats::Numeric(ts.ordinal() as _, 3),
228            // Hour number (00--23), zero-padded to 2 digits.
229            // Same as %H but space-padded.
230            'H' | 'k' => {
231                if fmt_char == 'k' && padding_style == PaddingStyle::Default {
232                    padding_style = PaddingStyle::Space;
233                }
234                Formats::Numeric(ts.hour() as _, 2)
235            }
236            // Hour number in 12-hour clocks (01--12), zero-padded to 2 digits.
237            // OR
238            // Same as %I but space-padded.
239            'I' | 'l' => {
240                let hour = match ts.hour() {
241                    0 | 12 => 12,
242                    hour @ 1..=11 => hour,
243                    over => over - 12,
244                };
245
246                if fmt_char == 'l' && padding_style == PaddingStyle::Default {
247                    padding_style = PaddingStyle::Space;
248                }
249                Formats::Numeric(hour as _, 2)
250            }
251            // Minute number (00--59), zero-padded to 2 digits.
252            'M' => Formats::Numeric(ts.minute() as _, 2),
253            // Second number (00--60), zero-padded to 2 digits.
254            'S' => Formats::Numeric(ts.second() as _, 2),
255            // Number of seconds since UNIX_EPOCH
256            's' => Formats::Numeric(ts.unix_timestamp(), 0),
257            // Abbreviated month name. Always 3 letters.
258            'b' | 'h' => Formats::Alphabetical(&(MONTH_NAMES[ts.month() as usize - 1])[..3]),
259            // Full month name
260            'B' => Formats::Alphabetical(MONTH_NAMES[ts.month() as usize - 1]),
261            // Abbreviated weekday name. Always 3 letters.
262            'a' => Formats::Alphabetical(&(WEEKDAY_NAMES[ts.weekday() as usize])[..3]),
263            // Full weekday name.
264            'A' => Formats::Alphabetical(WEEKDAY_NAMES[ts.weekday() as usize]),
265            // `am` or `pm` in 12-hour clocks.
266            // OR
267            // `AM` or `PM` in 12-hour clocks.
268            //
269            // Note that the case of the result is inverted from the
270            // format specifier :bleedingeyes:
271            'P' | 'p' => {
272                let is_am = ts.hour() < 12;
273
274                let s = if (fmt_char == 'p' && casing != Casing::Change)
275                    || (fmt_char == 'P' && casing != Casing::Default)
276                {
277                    if is_am {
278                        "AM"
279                    } else {
280                        "PM"
281                    }
282                } else if is_am {
283                    "am"
284                } else {
285                    "pm"
286                };
287
288                casing = Casing::Default;
289                Formats::Alphabetical(s)
290            }
291            // Year-month-day format (ISO 8601). Same as %Y-%m-%d.
292            'F' => {
293                write_padding!(comp 10);
294                w!(
295                    output,
296                    "{:04}-{:02}-{:02}",
297                    ts.year(),
298                    ts.month() as u8,
299                    ts.day(),
300                );
301                Formats::Formatted
302            }
303            // Day-month-year format. Same as %e-%^b-%Y.
304            'v' => {
305                // special case where the month is always uppercased
306                casing = Casing::Upper;
307                write_padding!(comp 11);
308                w!(
309                    output,
310                    "{:>2}-{}-{:04}",
311                    ts.day(),
312                    &(MONTH_NAMES[ts.month() as usize - 1])[..3],
313                    ts.year(),
314                );
315                Formats::Formatted
316            }
317            // Hour-minute format. Same as %H:%M.
318            'R' => {
319                write_padding!(comp 5);
320                w!(output, "{:02}:{:02}", ts.hour(), ts.minute());
321                Formats::Formatted
322            }
323            // Month-day-year format. Same as %m/%d/%y
324            'D' | 'x' => {
325                write_padding!(comp 8);
326                w!(
327                    output,
328                    "{:02}/{:02}/{:02}",
329                    ts.month() as u8,
330                    ts.day(),
331                    ts.year() % 100,
332                );
333                Formats::Formatted
334            }
335            // Hour-minute-second format. Same as %H:%M:%S.
336            'T' | 'X' => {
337                write_padding!(comp 8);
338                w!(
339                    output,
340                    "{:02}:{:02}:{:02}",
341                    ts.hour(),
342                    ts.minute(),
343                    ts.second()
344                );
345                Formats::Formatted
346            }
347            // Hour-minute-second format in 12-hour clocks. Same as %I:%M:%S %p.
348            'r' => {
349                let hour = match ts.hour() {
350                    0 | 12 => 12,
351                    hour @ 1..=11 => hour,
352                    over => over - 12,
353                };
354
355                let is_am = ts.hour() < 12;
356
357                write_padding!(comp 11);
358                w!(
359                    output,
360                    "{:02}:{:02}:{:02} {}",
361                    hour,
362                    ts.minute(),
363                    ts.second(),
364                    if is_am { "AM" } else { "PM" },
365                );
366                Formats::Formatted
367            }
368            // Date and time. Same as %a %b %e %T %Y
369            'c' => {
370                write_padding!(comp 24);
371                w!(
372                    output,
373                    "{} {} {:>2} {:02}:{:02}:{:02} {:04}",
374                    &(WEEKDAY_NAMES[ts.weekday() as usize])[..3],
375                    &(MONTH_NAMES[ts.month() as usize - 1])[..3],
376                    ts.day(),
377                    ts.hour(),
378                    ts.minute(),
379                    ts.second(),
380                    ts.year()
381                );
382                Formats::Formatted
383            }
384            // Literals
385            '%' => Formats::Literal('%'),
386            'n' => Formats::Literal('\n'),
387            't' => Formats::Literal('\t'),
388            // L
389            // Millisecond of the second (000..999) this one was not supported by chrono
390            // N
391            // Fractional seconds digits, default is 9 digits (nanosecond)
392            // For some reason Ruby says it supports printing out pico to yocto
393            // seconds but we only have nanosecond precision, and I think they probably
394            // do as well, so we just well, pretend if the user gives us something
395            // that ridiculous
396            //
397            // Note that this is a special case where the padding width that applies
398            // to other specifiers actually means the number of digits to print, and
399            // any digits above (in our case) nanosecond are always to the right and
400            // always 0, not spaces, so the normal format specifiers are ignored
401            'L' | 'N' => {
402                let nanos = ts.nanosecond();
403                let digits = padding.unwrap_or(if fmt_char == 'L' { 3 } else { 9 });
404
405                w!(
406                    output,
407                    "{:0<width$}",
408                    if digits <= 9 {
409                        nanos / 10u32.pow(9 - digits as u32)
410                    } else {
411                        nanos
412                    },
413                    width = digits
414                );
415
416                continue;
417            }
418            // %z - Time zone as hour and minute offset from UTC (e.g. +0900)
419            // %:z - hour and minute offset from UTC with a colon (e.g. +09:00)
420            // %::z - hour, minute and second offset from UTC (e.g. +09:00:00)
421            'z' | 'Z' | ':' => {
422                // So Ruby _supposedly_ outputs the (OS dependent) time zone name/abbreviation
423                // however in my testing Z was instead completely ignored. In this
424                // case we preserve the previous chrono behavior of just output +/-HH:MM
425                let hm_sep = matches!(fmt_char, 'Z' | ':');
426                let mut ms_sep = false;
427
428                let mut handle_colons = || {
429                    if fmt_char == ':' {
430                        match next!() {
431                            Some((_, 'z')) => {
432                                return true;
433                            }
434                            Some((_, ':')) => {
435                                if let Some((_, 'z')) = next!() {
436                                    ms_sep = true;
437                                    return true;
438                                } else {
439                                    return false;
440                                }
441                            }
442                            _ => return false,
443                        }
444                    }
445
446                    true
447                };
448
449                if handle_colons() {
450                    let offset = ts.offset();
451
452                    // The timezone padding is calculated by the total size of the
453                    // output, but for rust fmt strings it only applies to the hour
454                    // component
455                    let output_size = 1 // +/-
456                        + 2 // HH
457                        + if hm_sep { 1 } else { 0 } // :
458                        + 2 // MM
459                        + if ms_sep {
460                            1 + 2 // :ss
461                        } else {
462                            0
463                        };
464
465                    // Note that z doesn't respect `-` even if it is numeric, mostly
466                    let pad_width = std::cmp::max(
467                        padding.unwrap_or_default().saturating_sub(output_size) + 2,
468                        2,
469                    );
470
471                    if padding_style != PaddingStyle::Space {
472                        // So 0 filling to the left with a sign doesn't do at all
473                        // what you would expect, eg +0600 becomes 0+600, so we
474                        // do it manually
475
476                        w!(
477                            output,
478                            "{}{:0>width$}",
479                            if offset.is_negative() { '-' } else { '+' },
480                            offset.whole_hours().abs(),
481                            width = pad_width,
482                        );
483                    } else {
484                        w!(
485                            output,
486                            "{: >+width$}",
487                            offset.whole_hours(),
488                            width = pad_width
489                        );
490                    }
491
492                    w!(
493                        output,
494                        "{}{:02}",
495                        if hm_sep { ":" } else { "" },
496                        offset.minutes_past_hour().abs()
497                    );
498
499                    if ms_sep {
500                        w!(output, ":{:02}", offset.seconds_past_minute().abs());
501                    }
502
503                    continue;
504                }
505
506                Formats::Unknown
507            }
508            // Unknown format specifier
509            _ => Formats::Unknown,
510        };
511
512        match format {
513            Formats::Numeric(value, def_padding) => {
514                if use_padding {
515                    let mut digits = match value {
516                        0 => 1,
517                        neg if neg < 0 => 1,
518                        _ => 0,
519                    };
520                    let mut v = value;
521
522                    while v != 0 {
523                        v /= 10;
524                        digits += 1;
525                    }
526
527                    if value < 0 && padding_style != PaddingStyle::Space {
528                        output.push('-');
529                    }
530
531                    write_padding!(num padding.unwrap_or(def_padding + if value < 0 { 1 } else { 0 }).saturating_sub(digits));
532
533                    if value < 0 && padding_style == PaddingStyle::Space {
534                        output.push('-');
535                    }
536                } else if value < 0 {
537                    output.push('-');
538                }
539
540                w!(output, "{}", value.abs());
541            }
542            Formats::Alphabetical(s) => {
543                if use_padding && padding.is_some() {
544                    write_padding!(padding.unwrap_or_default().saturating_sub(s.len()));
545                }
546                output.push_str(s);
547                if casing != Casing::Default {
548                    output[out_cur..].make_ascii_uppercase();
549                }
550            }
551            Formats::Formatted => {
552                if casing != Casing::Default {
553                    output[out_cur..].make_ascii_uppercase();
554                }
555            }
556            Formats::Literal(lit) => {
557                if use_padding && padding.is_some() {
558                    write_padding!(padding.unwrap_or_default().saturating_sub(1));
559                }
560                output.push(lit);
561            }
562            Formats::Unknown => {
563                output.push_str(&fmt[fmt_pos..=cursor]);
564                continue;
565            }
566        };
567    }
568
569    Ok(output)
570}
571
572#[derive(Copy, Clone, PartialEq)]
573enum PaddingStyle {
574    /// Use 0 for numeric outputs and spaces for alphabetical ones
575    Default,
576    /// Use 0 for padding
577    Zero,
578    /// Use space for padding
579    Space,
580}
581
582#[derive(Copy, Clone, PartialEq)]
583enum Casing {
584    /// Alphabetical characters should be outputted per their defaults
585    Default,
586    /// The `^` flag has been used, so all ascii alphabetical characters should be uppercase
587    Upper,
588    /// The `#` flag has been used, so all ascii alphabetical characters should have their case changed
589    Change,
590}
591
592// time unfortunately only implements `Display` for Month and hides
593// its more sophisticated formatting internally, so we just have our own table
594const MONTH_NAMES: &[&str] = &[
595    "January",
596    "February",
597    "March",
598    "April",
599    "May",
600    "June",
601    "July",
602    "August",
603    "September",
604    "October",
605    "November",
606    "December",
607];
608
609// Ditto
610const WEEKDAY_NAMES: &[&str] = &[
611    "Monday",
612    "Tuesday",
613    "Wednesday",
614    "Thursday",
615    "Friday",
616    "Saturday",
617    "Sunday",
618];
619
620#[cfg(test)]
621mod test {
622    use super::*;
623
624    const SIMPLE: time::OffsetDateTime =
625        time::macros::datetime!(2022-11-03 07:56:37.666_777_888 +06:00);
626
627    macro_rules! eq {
628        ($ts:expr => [$($fmt:expr => $exp:expr),+$(,)?]) => {
629            $(
630                match strftime($ts, $fmt) {
631                    Ok(formatted) => {
632                        assert_eq!(formatted, $exp, "format string '{}' gave unexpected results", stringify!($fmt));
633                    }
634                    Err(err) => {
635                        panic!("failed to format with '{}': {}", stringify!($fmt), err);
636                    }
637                }
638            )+
639        };
640    }
641
642    #[test]
643    fn basic() {
644        eq!(SIMPLE => [
645            // year
646            "%Y" => "2022",
647            "%C" => "20",
648            "%y" => "22",
649
650            // month
651            "%m" => "11",
652            "%B" => "November",
653            "%b" => "Nov",
654            "%h" => "Nov",
655
656            // day
657            "%d" => "03",
658            "%e" => " 3",
659            "%j" => "307",
660
661            // time
662            "%H" => "07",
663            "%k" => " 7",
664            "%I" => "07",
665            "%l" => " 7",
666
667            "%P" => "am",
668            "%p" => "AM",
669
670            "%M" => "56",
671            "%S" => "37",
672            "%L" => "666",
673
674            "%N" => "666777888",
675            "%1N" => "6",
676            "%3N" => "666",
677            "%6N" => "666777",
678            "%9N" => "666777888",
679            "%12N" => "666777888000",
680            "%24N" => "666777888000000000000000",
681
682            // timezone
683            "%z" => "+0600",
684            "%Z" => "+06:00",
685
686            // weekday
687            "%A" => "Thursday",
688            "%a" => "Thu",
689            "%u" => "4",
690            "%w" => "4",
691
692            // ISO
693            "%G" => "2022",
694            "%g" => "22",
695            "%V" => "44",
696
697            // Week number
698            "%U" => "44",
699            "%W" => "44",
700
701            // UNIX timestamp
702            "%s" => "1667440597",
703
704            // Literals
705            "%n" => "\n",
706            "%t" => "\t",
707            "%%" => "%",
708
709            // Composites
710            "%c" => "Thu Nov  3 07:56:37 2022",
711            "%D" => "11/03/22",
712            "%F" => "2022-11-03",
713            "%v" => " 3-NOV-2022",
714            "%x" => "11/03/22",
715            "%T" => "07:56:37",
716            "%X" => "07:56:37",
717            "%r" => "07:56:37 AM",
718            "%R" => "07:56",
719        ]);
720    }
721
722    /// ISO composite formats taken directly from https://ruby-doc.org/core-3.0.0/Time.html#method-i-strftime
723    #[test]
724    fn iso_composites() {
725        eq!(time::macros::datetime!(2007-11-19 08:37:48 -06:00) => [
726            "%Y%m%d"            => "20071119",                  // Calendar date (basic)
727            "%F"                => "2007-11-19",                // Calendar date (extended)
728            "%Y-%m"             => "2007-11",                   // Calendar date, reduced accuracy, specific month
729            "%Y"                => "2007",                      // Calendar date, reduced accuracy, specific year
730            "%C"                => "20",                        // Calendar date, reduced accuracy, specific century
731            "%Y%j"              => "2007323",                   // Ordinal date (basic)
732            "%Y-%j"             => "2007-323",                  // Ordinal date (extended)
733            "%GW%V%u"           => "2007W471",                  // Week date (basic)
734            "%G-W%V-%u"         => "2007-W47-1",                // Week date (extended)
735            "%GW%V"             => "2007W47",                   // Week date, reduced accuracy, specific week (basic)
736            "%G-W%V"            => "2007-W47",                  // Week date, reduced accuracy, specific week (extended)
737            "%H%M%S"            => "083748",                    // Local time (basic)
738            "%T"                => "08:37:48",                  // Local time (extended)
739            "%H%M"              => "0837",                      // Local time, reduced accuracy, specific minute (basic)
740            "%H:%M"             => "08:37",                     // Local time, reduced accuracy, specific minute (extended)
741            "%H"                => "08",                        // Local time, reduced accuracy, specific hour
742            "%H%M%S,%L"         => "083748,000",                // Local time with decimal fraction, comma as decimal sign (basic)
743            "%T,%L"             => "08:37:48,000",              // Local time with decimal fraction, comma as decimal sign (extended)
744            "%H%M%S.%L"         => "083748.000",                // Local time with decimal fraction, full stop as decimal sign (basic)
745            "%T.%L"             => "08:37:48.000",              // Local time with decimal fraction, full stop as decimal sign (extended)
746            "%H%M%S%z"          => "083748-0600",               // Local time and the difference from UTC (basic)
747            "%T%:z"             => "08:37:48-06:00",            // Local time and the difference from UTC (extended)
748            "%Y%m%dT%H%M%S%z"   => "20071119T083748-0600",      // Date and time of day for calendar date (basic)
749            "%FT%T%:z"          => "2007-11-19T08:37:48-06:00", // Date and time of day for calendar date (extended)
750            "%Y%jT%H%M%S%z"     => "2007323T083748-0600",       // Date and time of day for ordinal date (basic)
751            "%Y-%jT%T%:z"       => "2007-323T08:37:48-06:00",   // Date and time of day for ordinal date (extended)
752            "%GW%V%uT%H%M%S%z"  => "2007W471T083748-0600",      // Date and time of day for week date (basic)
753            "%G-W%V-%uT%T%:z"   => "2007-W47-1T08:37:48-06:00", // Date and time of day for week date (extended)
754            "%Y%m%dT%H%M"       => "20071119T0837",             // Calendar date and local time (basic)
755            "%FT%R"             => "2007-11-19T08:37",          // Calendar date and local time (extended)
756            "%Y%jT%H%MZ"        => "2007323T0837Z",             // Ordinal date and UTC of day (basic)
757            "%Y-%jT%RZ"         => "2007-323T08:37Z",           // Ordinal date and UTC of day (extended)
758            "%GW%V%uT%H%M%z"    => "2007W471T0837-0600",        // Week date and local time and difference from UTC (basic)
759            "%G-W%V-%uT%R%:z"   => "2007-W47-1T08:37-06:00",    // Week date and local time and difference from UTC (extended)
760        ]);
761    }
762
763    #[test]
764    fn upper_flag() {
765        eq!(time::macros::datetime!(2007-01-19 08:37:48 -06:00) => [
766            "%^b" => "JAN",
767            "%^h" => "JAN",
768            "%^B" => "JANUARY",
769            "%^a" => "FRI",
770            "%^A" => "FRIDAY",
771            "%^p" => "AM",
772            "%^P" => "AM",
773            "%^v" => "19-JAN-2007",
774        ]);
775    }
776
777    #[test]
778    fn change_flag() {
779        eq!(time::macros::datetime!(2007-12-19 18:37:48 +08:00) => [
780            "%#b" => "DEC",
781            "%#h" => "DEC",
782            "%#B" => "DECEMBER",
783            "%#a" => "WED",
784            "%#A" => "WEDNESDAY",
785            "%#p" => "pm",
786            "%#P" => "PM",
787            "%#v" => "19-DEC-2007",
788        ]);
789    }
790
791    #[test]
792    fn padding() {
793        eq!(time::macros::datetime!(2022-01-03 07:56:37.666_777_888 +06:00) => [
794            // year
795            "%8Y" => "00002022",
796            "%_11Y" => "       2022",
797            "%1C" => "20",
798            "%2C" => "20",
799            "%3C" => "020",
800            "%_4C" => "  20",
801            "%_5y" => "   22",
802            "%-_5y" => "22",
803            "%_-5y" => "22",
804            "%-5y" => "22",
805
806            // month
807            "%13m" => "0000000000001",
808            "%_13m" => "            1",
809            "%7B" => "January",
810            "%8B" => " January",
811            "%_8B" => " January",
812            "%08B" => "0January",
813            "%7b" => "    Jan",
814            "%07h" => "0000Jan",
815            "%-07h" => "Jan",
816
817            // day
818            "%2d" => "03",
819            "%3d" => "003",
820            "%_5d" => "    3",
821            "%3e" => "  3",
822            "%_3e" => "  3",
823            "%03e" => "003",
824            "%j" => "003",
825            "%_j" => "  3",
826            "%1j" => "3",
827            "%2j" => "03",
828            "%_2j" => " 3",
829
830            // time
831            "%_H" => " 7",
832            "%0k" => "07",
833            "%_I" => " 7",
834            "%04l" => "0007",
835
836            "%4P" => "  am",
837            "%04p" => "00AM",
838            "%01p" => "AM",
839
840            "%9M" => "000000056",
841            "%_10S" => "        37",
842            "%_20L" => "66677788800000000000",
843            "%-_20L" => "66677788800000000000",
844
845            "%_N" => "666777888",
846            "%_1N" => "6",
847            "%_3N" => "666",
848            "%_6N" => "666777",
849            "%_9N" => "666777888",
850            "%_12N" => "666777888000",
851            "%-_24N" => "666777888000000000000000",
852
853            // timezone
854            "%1z" => "+0600",
855            "%2z" => "+0600",
856            "%3z" => "+0600",
857            "%4z" => "+0600",
858            "%5z" => "+0600",
859            "%6z" => "+00600",
860            "%10z" => "+000000600",
861            "%10Z" => "+000006:00",
862            "%10::z" => "+006:00:00",
863
864            // weekday
865            "%4A" => "Monday",
866            "%10A" => "    Monday",
867            "%10a" => "       Mon",
868            "%-10a" => "Mon",
869            "%04a" => "0Mon",
870            "%05u" => "00001",
871            "%_5w" => "    1",
872
873            // ISO
874            "%13G" => "0000000002022",
875            "%_13g" => "           22",
876            "%V" => "01",
877
878            // Week number
879            "%U" => "01",
880            "%W" => "01",
881
882            // UNIX timestamp
883            "%10s" => "1641174997",
884            "%20s" => "00000000001641174997",
885
886            // Literals
887            "%2n" => " \n",
888            "%05t" => "0000\t",
889            "%10%" => "         %",
890
891            // Composites
892            "%30c" => "      Mon Jan  3 07:56:37 2022",
893            "%8D" => "01/03/22",
894            "%012F" => "002022-01-03",
895            "%012v" => "0 3-JAN-2022",
896            "%3x" => "01/03/22",
897            "%11T" => "   07:56:37",
898            "%8X" => "07:56:37",
899            "%10X" => "  07:56:37",
900            "%010X" => "0007:56:37",
901            "%12r" => " 07:56:37 AM",
902            "%-6R" => " 07:56",
903        ]);
904
905        eq!(time::macros::datetime!(-20-06-13 17:56:37.666_777_888 -07:25) => [
906            "%Y" => "-0020",
907            "%_Y" => "  -20",
908            "%4Y" => "-020",
909            "%_4Y" => " -20",
910
911            "%1z" => "-0725",
912            "%2z" => "-0725",
913            "%3z" => "-0725",
914            "%4z" => "-0725",
915            "%5z" => "-0725",
916            "%6z" => "-00725",
917            "%10z" => "-000000725",
918            "%10Z" => "-000007:25",
919            "%10::z" => "-007:25:00",
920        ]);
921    }
922
923    #[test]
924    fn handles_unknown() {
925        eq!(time::macros::datetime!(-20-06-13 17:56:37.666_777_888 -06:00) => [
926            "%:b" => "%:b",
927            "%-_::xX%Y" => "%-_::xX-0020",
928            "%-_::xX%4Y" => "%-_::xX-020",
929            "%_0-^#^q" => "%_0-^#^q",
930        ]);
931    }
932
933    #[test]
934    fn errors() {
935        assert_eq!(
936            strftime(SIMPLE, "%9").unwrap_err(),
937            DateFormatError::NoFormatSpecifier
938        );
939        assert_eq!(
940            strftime(SIMPLE, "%9E").unwrap_err(),
941            DateFormatError::NoFormatSpecifierAfterModifier
942        );
943        assert_eq!(
944            strftime(SIMPLE, "%010").unwrap_err(),
945            DateFormatError::NoFormatSpecifier
946        );
947        assert_eq!(
948            strftime(SIMPLE, "X%").unwrap_err(),
949            DateFormatError::NoFormatSpecifier
950        );
951        assert!(matches!(
952            strftime(SIMPLE, "%18446744073709551616d").unwrap_err(),
953            DateFormatError::InvalidWidth(_)
954        ));
955    }
956}