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}