strftime/format/
mod.rs

1//! Module containing the formatting logic.
2
3mod assert;
4mod check;
5mod utils;
6mod week;
7mod write;
8
9use core::ffi::c_int;
10use core::fmt;
11use core::num::IntErrorKind;
12use core::str;
13
14use crate::Error;
15use assert::{assert_sorted, assert_sorted_elem_0, assert_to_ascii_uppercase};
16use check::CheckedTime;
17use utils::{Cursor, SizeLimiter};
18use week::{iso_8601_year_and_week_number, week_number, WeekStart};
19use write::Write;
20
21pub(crate) use write::FmtWrite;
22#[cfg(feature = "std")]
23pub(crate) use write::IoWrite;
24
25/// List of weekday names.
26const DAYS: [&str; 7] = [
27    "Sunday",
28    "Monday",
29    "Tuesday",
30    "Wednesday",
31    "Thursday",
32    "Friday",
33    "Saturday",
34];
35
36/// List of uppercase weekday names.
37const DAYS_UPPER: [&str; 7] = [
38    "SUNDAY",
39    "MONDAY",
40    "TUESDAY",
41    "WEDNESDAY",
42    "THURSDAY",
43    "FRIDAY",
44    "SATURDAY",
45];
46
47/// List of month names.
48const MONTHS: [&str; 12] = [
49    "January",
50    "February",
51    "March",
52    "April",
53    "May",
54    "June",
55    "July",
56    "August",
57    "September",
58    "October",
59    "November",
60    "December",
61];
62
63/// List of uppercase month names.
64const MONTHS_UPPER: [&str; 12] = [
65    "JANUARY",
66    "FEBRUARY",
67    "MARCH",
68    "APRIL",
69    "MAY",
70    "JUNE",
71    "JULY",
72    "AUGUST",
73    "SEPTEMBER",
74    "OCTOBER",
75    "NOVEMBER",
76    "DECEMBER",
77];
78
79// Check day and month tables
80const _: () = {
81    assert_to_ascii_uppercase(&DAYS, &DAYS_UPPER);
82    assert_to_ascii_uppercase(&MONTHS, &MONTHS_UPPER);
83};
84
85/// Formatting flag.
86#[repr(u8)]
87#[derive(Debug, Copy, Clone, Eq, PartialEq)]
88enum Flag {
89    /// Use left padding, removing all other padding options in most cases.
90    LeftPadding = 1 << 0,
91    /// Change case for a string value.
92    ChangeCase = 1 << 1,
93    /// Convert a string value to uppercase.
94    UpperCase = 1 << 2,
95}
96
97/// Combination of formatting flags.
98#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)]
99struct Flags(u8);
100
101impl Flags {
102    /// Checks if a flag is set.
103    #[must_use]
104    fn contains(self, flag: Flag) -> bool {
105        let flag = flag as u8;
106        (self.0 & flag) == flag
107    }
108
109    /// Sets a flag.
110    fn set(&mut self, flag: Flag) {
111        self.0 |= flag as u8;
112    }
113
114    /// Checks if one of the case flags is set.
115    #[must_use]
116    fn has_change_or_upper_case(self) -> bool {
117        let flags = Flag::ChangeCase as u8 | Flag::UpperCase as u8;
118        self.0 & flags != 0
119    }
120}
121
122/// Padding method.
123#[derive(Debug, Copy, Clone, Eq, PartialEq)]
124enum Padding {
125    /// Left padding.
126    Left,
127    /// Padding with spaces.
128    Spaces,
129    /// Padding with zeros.
130    Zeros,
131}
132
133/// Formatting specifier.
134#[derive(Debug, Copy, Clone, Eq, PartialEq)]
135enum Spec {
136    /// `"%Y"`: Year with century if provided, zero-padded to at least 4 digits
137    /// plus the possible negative sign.
138    Year4Digits,
139    /// `"%C"`: `Year / 100` using Euclidean division, zero-padded to at least 2
140    /// digits.
141    YearDiv100,
142    /// `"%y"`: `Year % 100` in `00..=99`, using Euclidean remainder, zero-padded
143    /// to 2 digits.
144    YearRem100,
145    /// `"%m"`: Month of the year in `01..=12`, zero-padded to 2 digits.
146    Month,
147    /// `"%B"`: Locale independent full month name.
148    MonthName,
149    /// `"%b"` and `"%h"`: Locale independent abbreviated month name, using the
150    /// first 3 letters.
151    MonthNameAbbr,
152    /// `"%d"`: Day of the month in `01..=31`, zero-padded to 2 digits.
153    MonthDayZero,
154    /// `"%e"`: Day of the month in ` 1..=31`, blank-padded to 2 digits.
155    MonthDaySpace,
156    /// `"%j"`: Day of the year in `001..=366`, zero-padded to 3 digits.
157    YearDay,
158    /// `"%H"`: Hour of the day (24-hour clock) in `00..=23`, zero-padded to 2
159    /// digits.
160    Hour24hZero,
161    /// `"%k"`: Hour of the day (24-hour clock) in ` 0..=23`, blank-padded to 2
162    /// digits.
163    Hour24hSpace,
164    /// `"%I"`: Hour of the day (12-hour clock) in `01..=12`, zero-padded to 2
165    /// digits.
166    Hour12hZero,
167    /// `"%l"`: Hour of the day (12-hour clock) in ` 1..=12`, blank-padded to 2
168    /// digits.
169    Hour12hSpace,
170    /// `"%P"`: Lowercase meridian indicator (`"am"` or `"pm"`).
171    MeridianLower,
172    /// `"%p"`: Uppercase meridian indicator (`"AM"` or `"PM"`).
173    MeridianUpper,
174    /// `"%M"`: Minute of the hour in `00..=59`, zero-padded to 2 digits.
175    Minute,
176    /// `"%S"`: Second of the minute in `00..=60`, zero-padded to 2 digits.
177    Second,
178    /// `"%L"`: Truncated fractional seconds digits, with 3 digits by default.
179    /// Number of digits is specified by the width field.
180    MilliSecond,
181    /// `"%N"`: Truncated fractional seconds digits, with 9 digits by default.
182    /// Number of digits is specified by the width field.
183    FractionalSecond,
184    /// `"%z"`: Zero-padded signed time zone UTC hour and minute offsets
185    /// (`+hhmm`).
186    TimeZoneOffsetHourMinute,
187    /// `"%:z"`: Zero-padded signed time zone UTC hour and minute offsets with
188    /// colons (`+hh:mm`).
189    TimeZoneOffsetHourMinuteColon,
190    /// `"%::z"`: Zero-padded signed time zone UTC hour, minute and second
191    /// offsets with colons (`+hh:mm:ss`).
192    TimeZoneOffsetHourMinuteSecondColon,
193    /// `"%:::z"`: Zero-padded signed time zone UTC hour offset, with optional
194    /// minute and second offsets with colons (`+hh[:mm[:ss]]`).
195    TimeZoneOffsetColonMinimal,
196    /// `"%Z"`: Platform-dependent abbreviated time zone name.
197    TimeZoneName,
198    /// `"%A"`: Locale independent full weekday name.
199    WeekDayName,
200    /// `"%a"`: Locale independent abbreviated weekday name, using the first 3
201    /// letters.
202    WeekDayNameAbbr,
203    /// `"%u"`: Day of the week from Monday in `1..=7`, zero-padded to 1 digit.
204    WeekDayFrom1,
205    /// `"%w"`: Day of the week from Sunday in `0..=6`, zero-padded to 1 digit.
206    WeekDayFrom0,
207    /// `"%G"`: Same as `%Y`, but using the ISO 8601 week-based year.
208    YearIso8601,
209    /// `"%g"`: Same as `%y`, but using the ISO 8601 week-based year.
210    YearIso8601Rem100,
211    /// `"%V"`: ISO 8601 week number in `01..=53`, zero-padded to 2 digits.
212    WeekNumberIso8601,
213    /// `"%U"`: Week number from Sunday in `00..=53`, zero-padded to 2 digits.
214    /// The week `1` starts with the first Sunday of the year.
215    WeekNumberFromSunday,
216    /// `"%W"`: Week number from Monday in `00..=53`, zero-padded to 2 digits.
217    /// The week `1` starts with the first Monday of the year.
218    WeekNumberFromMonday,
219    /// `"%s"`: Number of seconds since `1970-01-01 00:00:00 UTC`, zero-padded
220    /// to at least 1 digit.
221    SecondsSinceEpoch,
222    /// `"%n"`: Newline character `'\n'`.
223    Newline,
224    /// `"%t"`: Tab character `'\t'`.
225    Tabulation,
226    /// `"%%"`: Literal `'%'` character.
227    Percent,
228    /// `"%c"`: Date and time, equivalent to `"%a %b %e %H:%M:%S %Y"`.
229    CombinationDateTime,
230    /// `"%D"` and `"%x"`: Date, equivalent to `"%m/%d/%y"`.
231    CombinationDate,
232    /// `"%F"`: ISO 8601 date, equivalent to `"%Y-%m-%d"`.
233    CombinationIso8601,
234    /// `"%v"`: VMS date, equivalent to `"%e-%^b-%4Y"`.
235    CombinationVmsDate,
236    /// `"%r"`: 12-hour time, equivalent to `"%I:%M:%S %p"`.
237    CombinationTime12h,
238    /// `"%R"`: 24-hour time without seconds, equivalent to `"%H:%M"`.
239    CombinationHourMinute24h,
240    /// `"%T"` and `"%X"`: 24-hour time, equivalent to `"%H:%M:%S"`.
241    CombinationTime24h,
242}
243
244/// UTC offset parts.
245#[derive(Debug)]
246struct UtcOffset {
247    /// Signed hour.
248    hour: f64,
249    /// Minute.
250    minute: u32,
251    /// Second.
252    second: u32,
253}
254
255impl UtcOffset {
256    /// Construct a new `UtcOffset`.
257    fn new(hour: f64, minute: u32, second: u32) -> Self {
258        Self {
259            hour,
260            minute,
261            second,
262        }
263    }
264}
265
266/// Formatting directive.
267#[derive(Debug)]
268struct Piece {
269    /// Optional width.
270    width: Option<usize>,
271    /// Padding method.
272    padding: Padding,
273    /// Combination of formatting flags.
274    flags: Flags,
275    /// Formatting specifier.
276    spec: Spec,
277}
278
279impl Piece {
280    /// Construct a new `Piece`.
281    fn new(width: Option<usize>, padding: Padding, flags: Flags, spec: Spec) -> Self {
282        Self {
283            width,
284            padding,
285            flags,
286            spec,
287        }
288    }
289
290    /// Format a numerical value, padding with zeros by default.
291    fn format_num_zeros(
292        &self,
293        f: &mut SizeLimiter<'_>,
294        value: impl fmt::Display,
295        default_width: usize,
296    ) -> Result<(), Error> {
297        if self.flags.contains(Flag::LeftPadding) {
298            write!(f, "{value}")
299        } else if self.padding == Padding::Spaces {
300            let width = self.pad_width(f, b' ', default_width)?;
301            write!(f, "{value: >width$}")
302        } else {
303            let width = self.pad_width(f, b'0', default_width)?;
304            write!(f, "{value:0width$}")
305        }
306    }
307
308    /// Format a numerical value, padding with spaces by default.
309    fn format_num_spaces(
310        &self,
311        f: &mut SizeLimiter<'_>,
312        value: impl fmt::Display,
313        default_width: usize,
314    ) -> Result<(), Error> {
315        if self.flags.contains(Flag::LeftPadding) {
316            write!(f, "{value}")
317        } else if self.padding == Padding::Zeros {
318            let width = self.pad_width(f, b'0', default_width)?;
319            write!(f, "{value:0width$}")
320        } else {
321            let width = self.pad_width(f, b' ', default_width)?;
322            write!(f, "{value: >width$}")
323        }
324    }
325
326    /// Returns the width to use for the padding.
327    ///
328    /// Prints any excessive padding directly.
329    fn pad_width(&self, f: &mut SizeLimiter<'_>, pad: u8, default: usize) -> Result<usize, Error> {
330        let width = self.width.unwrap_or(default);
331        f.pad(pad, width.saturating_sub(u16::MAX.into()))?;
332        Ok(width.min(u16::MAX.into()))
333    }
334
335    /// Format nanoseconds with the specified precision.
336    fn format_nanoseconds(
337        &self,
338        f: &mut SizeLimiter<'_>,
339        nanoseconds: u32,
340        default_width: usize,
341    ) -> Result<(), Error> {
342        let width = self.width.unwrap_or(default_width);
343
344        match u32::try_from(width) {
345            Ok(w) if w <= 9 => {
346                let value = nanoseconds / 10_u32.pow(9 - w);
347                write!(f, "{value:0width$}")
348            }
349            Ok(_) | Err(_) => {
350                write!(f, "{nanoseconds:09}")?;
351                f.pad(b'0', width - 9)
352            }
353        }
354    }
355
356    /// Format a string value.
357    fn format_string(&self, f: &mut SizeLimiter<'_>, s: &str) -> Result<(), Error> {
358        if !self.flags.contains(Flag::LeftPadding) {
359            self.write_padding(f, s.len())?;
360        }
361
362        write!(f, "{s}")
363    }
364
365    /// Write padding separately.
366    fn write_padding(&self, f: &mut SizeLimiter<'_>, min_width: usize) -> Result<(), Error> {
367        let Some(width) = self.width else {
368            return Ok(());
369        };
370
371        let n = width.saturating_sub(min_width);
372
373        let pad = match self.padding {
374            Padding::Zeros => b'0',
375            Padding::Left | Padding::Spaces => b' ',
376        };
377
378        f.pad(pad, n)?;
379
380        Ok(())
381    }
382
383    /// Compute UTC offset parts for the `%z` specifier.
384    fn compute_offset_parts(&self, time: &impl CheckedTime) -> UtcOffset {
385        let utc_offset = time.utc_offset();
386        let utc_offset_abs = utc_offset.unsigned_abs();
387
388        // UTC is represented as "-00:00" if the '-' flag is set
389        let sign = if utc_offset < 0 || time.is_utc() && self.flags.contains(Flag::LeftPadding) {
390            -1.0
391        } else {
392            1.0
393        };
394
395        // Convert to `f64` to have signed zero
396        let hour = sign * f64::from(utc_offset_abs / 3600);
397        let minute = (utc_offset_abs / 60) % 60;
398        let second = utc_offset_abs % 60;
399
400        UtcOffset::new(hour, minute, second)
401    }
402
403    /// Write the hour sign.
404    fn write_hour_sign(f: &mut SizeLimiter<'_>, hour: f64) -> Result<(), Error> {
405        if hour.is_sign_negative() {
406            write!(f, "-")?;
407        } else {
408            write!(f, "+")?;
409        }
410
411        Ok(())
412    }
413
414    /// Write the hour with padding for the `%z` specifier.
415    fn write_offset_hour(&self, f: &mut SizeLimiter<'_>, hour: f64, w: usize) -> Result<(), Error> {
416        let mut pad = self.width.unwrap_or(0).saturating_sub(w);
417
418        if hour < 10.0 {
419            pad += 1;
420        }
421
422        if self.padding == Padding::Spaces {
423            f.pad(b' ', pad)?;
424            Self::write_hour_sign(f, hour)?;
425        } else {
426            Self::write_hour_sign(f, hour)?;
427            f.pad(b'0', pad)?;
428        }
429
430        write!(f, "{:.0}", hour.abs())
431    }
432
433    /// Write the time zone UTC offset as `"+hh"`.
434    fn write_offset_hh(
435        &self,
436        f: &mut SizeLimiter<'_>,
437        utc_offset: &UtcOffset,
438    ) -> Result<(), Error> {
439        self.write_offset_hour(f, utc_offset.hour, "+hh".len())
440    }
441
442    /// Write the time zone UTC offset as `"+hhmm"`.
443    fn write_offset_hhmm(
444        &self,
445        f: &mut SizeLimiter<'_>,
446        utc_offset: &UtcOffset,
447    ) -> Result<(), Error> {
448        let UtcOffset { hour, minute, .. } = *utc_offset;
449
450        self.write_offset_hour(f, hour, "+hhmm".len())?;
451        write!(f, "{minute:02}")
452    }
453
454    /// Write the time zone UTC offset as `"+hh:mm"`.
455    fn write_offset_hh_mm(
456        &self,
457        f: &mut SizeLimiter<'_>,
458        utc_offset: &UtcOffset,
459    ) -> Result<(), Error> {
460        let UtcOffset { hour, minute, .. } = *utc_offset;
461
462        self.write_offset_hour(f, hour, "+hh:mm".len())?;
463        write!(f, ":{minute:02}")
464    }
465
466    /// Write the time zone UTC offset as `"+hh:mm:ss"`.
467    fn write_offset_hh_mm_ss(
468        &self,
469        f: &mut SizeLimiter<'_>,
470        utc_offset: &UtcOffset,
471    ) -> Result<(), Error> {
472        let UtcOffset {
473            hour,
474            minute,
475            second,
476        } = *utc_offset;
477
478        self.write_offset_hour(f, hour, "+hh:mm:ss".len())?;
479        write!(f, ":{minute:02}:{second:02}")
480    }
481
482    /// Format time using the formatting directive.
483    #[expect(clippy::too_many_lines, reason = "must handle all enum cases")]
484    fn fmt(&self, f: &mut SizeLimiter<'_>, time: &impl CheckedTime) -> Result<(), Error> {
485        match self.spec {
486            Spec::Year4Digits => {
487                let year = time.year();
488                let default_width = if year < 0 { 5 } else { 4 };
489                self.format_num_zeros(f, year, default_width)
490            }
491            Spec::YearDiv100 => self.format_num_zeros(f, time.year().div_euclid(100), 2),
492            Spec::YearRem100 => self.format_num_zeros(f, time.year().rem_euclid(100), 2),
493            Spec::Month => self.format_num_zeros(f, time.month()?, 2),
494            #[expect(
495                clippy::indexing_slicing,
496                reason = "month is validated to be in range 1..=12"
497            )]
498            Spec::MonthName => {
499                let index = usize::from(time.month()? - 1);
500                if self.flags.has_change_or_upper_case() {
501                    self.format_string(f, MONTHS_UPPER[index])
502                } else {
503                    self.format_string(f, MONTHS[index])
504                }
505            }
506            #[expect(clippy::string_slice, reason = "month names values are all ASCII")]
507            #[expect(
508                clippy::indexing_slicing,
509                reason = "month is validated to be in range 1..=12"
510            )]
511            Spec::MonthNameAbbr => {
512                let index = usize::from(time.month()? - 1);
513                if self.flags.has_change_or_upper_case() {
514                    self.format_string(f, &MONTHS_UPPER[index][..3])
515                } else {
516                    self.format_string(f, &MONTHS[index][..3])
517                }
518            }
519            Spec::MonthDayZero => self.format_num_zeros(f, time.day()?, 2),
520            Spec::MonthDaySpace => self.format_num_spaces(f, time.day()?, 2),
521            Spec::YearDay => self.format_num_zeros(f, time.day_of_year()?, 3),
522            Spec::Hour24hZero => self.format_num_zeros(f, time.hour()?, 2),
523            Spec::Hour24hSpace => self.format_num_spaces(f, time.hour()?, 2),
524            Spec::Hour12hZero => {
525                let hour = time.hour()? % 12;
526                let hour = if hour == 0 { 12 } else { hour };
527                self.format_num_zeros(f, hour, 2)
528            }
529            Spec::Hour12hSpace => {
530                let hour = time.hour()? % 12;
531                let hour = if hour == 0 { 12 } else { hour };
532                self.format_num_spaces(f, hour, 2)
533            }
534            Spec::MeridianLower => {
535                let (am, pm) = if self.flags.has_change_or_upper_case() {
536                    ("AM", "PM")
537                } else {
538                    ("am", "pm")
539                };
540                let meridian = if time.hour()? < 12 { am } else { pm };
541                self.format_string(f, meridian)
542            }
543            Spec::MeridianUpper => {
544                let (am, pm) = if self.flags.contains(Flag::ChangeCase) {
545                    ("am", "pm")
546                } else {
547                    ("AM", "PM")
548                };
549                let meridian = if time.hour()? < 12 { am } else { pm };
550                self.format_string(f, meridian)
551            }
552            Spec::Minute => self.format_num_zeros(f, time.minute()?, 2),
553            Spec::Second => self.format_num_zeros(f, time.second()?, 2),
554            Spec::MilliSecond => self.format_nanoseconds(f, time.nanoseconds()?, 3),
555            Spec::FractionalSecond => self.format_nanoseconds(f, time.nanoseconds()?, 9),
556            Spec::TimeZoneOffsetHourMinute => {
557                self.write_offset_hhmm(f, &self.compute_offset_parts(time))
558            }
559            Spec::TimeZoneOffsetHourMinuteColon => {
560                self.write_offset_hh_mm(f, &self.compute_offset_parts(time))
561            }
562            Spec::TimeZoneOffsetHourMinuteSecondColon => {
563                self.write_offset_hh_mm_ss(f, &self.compute_offset_parts(time))
564            }
565            Spec::TimeZoneOffsetColonMinimal => {
566                let utc_offset = self.compute_offset_parts(time);
567
568                if utc_offset.second != 0 {
569                    self.write_offset_hh_mm_ss(f, &utc_offset)
570                } else if utc_offset.minute != 0 {
571                    self.write_offset_hh_mm(f, &utc_offset)
572                } else {
573                    self.write_offset_hh(f, &utc_offset)
574                }
575            }
576            Spec::TimeZoneName => {
577                let tz_name = time.time_zone()?;
578                if !tz_name.is_empty() {
579                    if !self.flags.contains(Flag::LeftPadding) {
580                        self.write_padding(f, tz_name.len())?;
581                    }
582
583                    // The time zone name is guaranteed to be ASCII at this point.
584                    let convert: fn(&u8) -> u8 = if self.flags.contains(Flag::ChangeCase) {
585                        u8::to_ascii_lowercase
586                    } else if self.flags.contains(Flag::UpperCase) {
587                        u8::to_ascii_uppercase
588                    } else {
589                        |&x| x
590                    };
591
592                    for x in tz_name.as_bytes() {
593                        f.write_all(&[convert(x)])?;
594                    }
595                }
596                Ok(())
597            }
598            #[expect(
599                clippy::indexing_slicing,
600                reason = "day of week is validated to be in range 0..=6"
601            )]
602            Spec::WeekDayName => {
603                let index = usize::from(time.day_of_week()?);
604                if self.flags.has_change_or_upper_case() {
605                    self.format_string(f, DAYS_UPPER[index])
606                } else {
607                    self.format_string(f, DAYS[index])
608                }
609            }
610            #[expect(clippy::string_slice, reason = "day names values are all ASCII")]
611            #[expect(
612                clippy::indexing_slicing,
613                reason = "day of week is validated to be in range 0..=6"
614            )]
615            Spec::WeekDayNameAbbr => {
616                let index = usize::from(time.day_of_week()?);
617                if self.flags.has_change_or_upper_case() {
618                    self.format_string(f, &DAYS_UPPER[index][..3])
619                } else {
620                    self.format_string(f, &DAYS[index][..3])
621                }
622            }
623            Spec::WeekDayFrom1 => {
624                let day_of_week = time.day_of_week()?;
625                let day_of_week = if day_of_week == 0 { 7 } else { day_of_week };
626                self.format_num_zeros(f, day_of_week, 1)
627            }
628            Spec::WeekDayFrom0 => self.format_num_zeros(f, time.day_of_week()?, 1),
629            Spec::YearIso8601 => {
630                let (iso_year, _) = iso_8601_year_and_week_number(
631                    time.year().into(),
632                    time.day_of_week()?.into(),
633                    time.day_of_year()?.into(),
634                );
635                let default_width = if iso_year < 0 { 5 } else { 4 };
636                self.format_num_zeros(f, iso_year, default_width)
637            }
638            Spec::YearIso8601Rem100 => {
639                let (iso_year, _) = iso_8601_year_and_week_number(
640                    time.year().into(),
641                    time.day_of_week()?.into(),
642                    time.day_of_year()?.into(),
643                );
644                self.format_num_zeros(f, iso_year.rem_euclid(100), 2)
645            }
646            Spec::WeekNumberIso8601 => {
647                let (_, iso_week_number) = iso_8601_year_and_week_number(
648                    time.year().into(),
649                    time.day_of_week()?.into(),
650                    time.day_of_year()?.into(),
651                );
652                self.format_num_zeros(f, iso_week_number, 2)
653            }
654            Spec::WeekNumberFromSunday => {
655                let week_number = week_number(
656                    time.day_of_week()?.into(),
657                    time.day_of_year()?.into(),
658                    WeekStart::Sunday,
659                );
660                self.format_num_zeros(f, week_number, 2)
661            }
662            Spec::WeekNumberFromMonday => {
663                let week_number = week_number(
664                    time.day_of_week()?.into(),
665                    time.day_of_year()?.into(),
666                    WeekStart::Monday,
667                );
668                self.format_num_zeros(f, week_number, 2)
669            }
670            Spec::SecondsSinceEpoch => self.format_num_zeros(f, time.to_int(), 1),
671            Spec::Newline => self.format_string(f, "\n"),
672            Spec::Tabulation => self.format_string(f, "\t"),
673            Spec::Percent => self.format_string(f, "%"),
674            #[expect(clippy::string_slice, reason = "month and names values are all ASCII")]
675            #[expect(
676                clippy::indexing_slicing,
677                reason = "month and day are validated to be in range"
678            )]
679            Spec::CombinationDateTime => {
680                const MIN_WIDTH_NO_YEAR: usize = "www mmm dd HH:MM:SS ".len();
681
682                let year = time.year();
683                let default_year_width = if year < 0 { 5 } else { 4 };
684                let min_width = MIN_WIDTH_NO_YEAR + year_width(year).max(default_year_width);
685                self.write_padding(f, min_width)?;
686
687                let (day_names, month_names) = if self.flags.contains(Flag::UpperCase) {
688                    (&DAYS_UPPER, &MONTHS_UPPER)
689                } else {
690                    (&DAYS, &MONTHS)
691                };
692
693                let week_day_name = &day_names[usize::from(time.day_of_week()?)][..3];
694                let month_name = &month_names[usize::from(time.month()? - 1)][..3];
695                let day = time.day()?;
696                let (hour, minute, second) = (time.hour()?, time.minute()?, time.second()?);
697
698                write!(f, "{week_day_name} {month_name} ")?;
699                write!(f, "{day: >2} {hour:02}:{minute:02}:{second:02} ")?;
700                write!(f, "{year:0default_year_width$}")
701            }
702            Spec::CombinationDate => {
703                self.write_padding(f, "mm/dd/yy".len())?;
704
705                let year = time.year().rem_euclid(100);
706                let month = time.month()?;
707                let day = time.day()?;
708
709                write!(f, "{month:02}/{day:02}/{year:02}")
710            }
711            Spec::CombinationIso8601 => {
712                const MIN_WIDTH_NO_YEAR: usize = "-mm-dd".len();
713
714                let year = time.year();
715                let default_year_width = if year < 0 { 5 } else { 4 };
716                let min_width = MIN_WIDTH_NO_YEAR + year_width(year).max(default_year_width);
717                self.write_padding(f, min_width)?;
718
719                let month = time.month()?;
720                let day = time.day()?;
721
722                write!(f, "{year:0default_year_width$}-{month:02}-{day:02}")
723            }
724            #[expect(clippy::string_slice, reason = "month names values are all ASCII")]
725            #[expect(clippy::indexing_slicing, reason = "month is validated to be in range")]
726            Spec::CombinationVmsDate => {
727                let year = time.year();
728                self.write_padding(f, "dd-mmm-".len() + year_width(year).max(4))?;
729
730                let month_name = &MONTHS_UPPER[usize::from(time.month()? - 1)][..3];
731                let day = time.day()?;
732
733                write!(f, "{day: >2}-{month_name}-{year:04}")
734            }
735            Spec::CombinationTime12h => {
736                self.write_padding(f, "HH:MM:SS PM".len())?;
737
738                let hour = time.hour()? % 12;
739                let hour = if hour == 0 { 12 } else { hour };
740
741                let (minute, second) = (time.minute()?, time.second()?);
742                let meridian = if time.hour()? < 12 { "AM" } else { "PM" };
743
744                write!(f, "{hour:02}:{minute:02}:{second:02} {meridian}")
745            }
746            Spec::CombinationHourMinute24h => {
747                self.write_padding(f, "HH:MM".len())?;
748                let (hour, minute) = (time.hour()?, time.minute()?);
749                write!(f, "{hour:02}:{minute:02}")
750            }
751            Spec::CombinationTime24h => {
752                self.write_padding(f, "HH:MM:SS".len())?;
753                let (hour, minute, second) = (time.hour()?, time.minute()?, time.second()?);
754                write!(f, "{hour:02}:{minute:02}:{second:02}")
755            }
756        }
757    }
758}
759
760/// Wrapper struct for formatting time with the provided format string.
761pub(crate) struct TimeFormatter<'t, 'f, T> {
762    /// Time implementation
763    time: &'t T,
764    /// Format string
765    format: &'f [u8],
766}
767
768impl<'t, 'f, T: CheckedTime> TimeFormatter<'t, 'f, T> {
769    /// Construct a new `TimeFormatter` wrapper.
770    pub(crate) fn new<F: AsRef<[u8]> + ?Sized>(time: &'t T, format: &'f F) -> Self {
771        Self {
772            time,
773            format: format.as_ref(),
774        }
775    }
776
777    /// Format time using the format string.
778    #[expect(
779        clippy::indexing_slicing,
780        reason = "cursor is always within the bounds of the format string"
781    )]
782    pub(crate) fn fmt(&self, buf: &mut dyn Write) -> Result<(), Error> {
783        // Do nothing if the format string is empty
784        if self.format.is_empty() {
785            return Ok(());
786        }
787
788        // Use a size limiter to limit the maximum size of the resulting
789        // formatted string
790        let size_limit = self.format.len().saturating_mul(512 * 1024);
791        let mut f = SizeLimiter::new(buf, size_limit);
792
793        let mut cursor = Cursor::new(self.format);
794
795        loop {
796            f.write_all(cursor.read_until(|&x| x == b'%'))?;
797
798            let remaining_before = cursor.remaining();
799
800            // Read the '%' character
801            if cursor.next().is_none() {
802                break;
803            }
804
805            if let Some(piece) = Self::parse_spec(&mut cursor)? {
806                piece.fmt(&mut f, self.time)?;
807            } else {
808                // No valid format specifier was found
809                let remaining_after = cursor.remaining();
810                let text = &remaining_before[..remaining_before.len() - remaining_after.len()];
811                f.write_all(text)?;
812            }
813        }
814
815        Ok(())
816    }
817
818    /// Parse a formatting directive.
819    #[expect(clippy::too_many_lines, reason = "must handle all enum cases")]
820    fn parse_spec(cursor: &mut Cursor<'_>) -> Result<Option<Piece>, Error> {
821        // Parse flags
822        let mut padding = Padding::Left;
823        let mut flags = Flags::default();
824
825        loop {
826            // The left padding overrides the other padding options for most cases.
827            // It is also used for the hour sign in the `%z` specifier.
828            //
829            // Similarly, the change case flag overrides the upper case flag,
830            // except when using combination specifiers (`%c`, `%D`, `%x`, `%F`,
831            // `%v`, `%r`, `%R`, `%T`, `%X`).
832            match cursor.remaining().first() {
833                Some(&b'-') => {
834                    padding = Padding::Left;
835                    flags.set(Flag::LeftPadding);
836                }
837                Some(&b'_') => {
838                    padding = Padding::Spaces;
839                }
840                Some(&b'0') => {
841                    padding = Padding::Zeros;
842                }
843                Some(&b'^') => {
844                    flags.set(Flag::UpperCase);
845                }
846                Some(&b'#') => {
847                    flags.set(Flag::ChangeCase);
848                }
849                _ => break,
850            }
851            cursor.next();
852        }
853
854        // Parse width
855        let width_digits = str::from_utf8(cursor.read_while(u8::is_ascii_digit))
856            .expect("reading ASCII digits should yield a valid UTF-8 slice");
857
858        let width = match width_digits.parse::<usize>() {
859            Ok(width) if c_int::try_from(width).is_ok() => Some(width),
860            Err(err) if *err.kind() == IntErrorKind::Empty => None,
861            _ => return Ok(None),
862        };
863
864        // Ignore POSIX locale extensions per MRI 3.1.2:
865        //
866        // <https://github.com/ruby/ruby/blob/v3_1_2/strftime.c#L713-L722>
867        if let Some(&[ext, spec]) = cursor.remaining().get(..2) {
868            const EXT_E_SPECS: &[u8] = assert_sorted(b"CXYcxy");
869            const EXT_O_SPECS: &[u8] = assert_sorted(b"HIMSUVWdeklmuwy");
870
871            match ext {
872                b'E' if EXT_E_SPECS.binary_search(&spec).is_ok() => {
873                    cursor.next();
874                }
875                b'O' if EXT_O_SPECS.binary_search(&spec).is_ok() => {
876                    cursor.next();
877                }
878                _ => {}
879            };
880        }
881
882        // Parse spec
883        let colons = cursor.read_while(|&x| x == b':');
884
885        let spec = if colons.is_empty() {
886            const POSSIBLE_SPECS: &[(u8, Spec)] = assert_sorted_elem_0(&[
887                (b'%', Spec::Percent),
888                (b'A', Spec::WeekDayName),
889                (b'B', Spec::MonthName),
890                (b'C', Spec::YearDiv100),
891                (b'D', Spec::CombinationDate),
892                (b'F', Spec::CombinationIso8601),
893                (b'G', Spec::YearIso8601),
894                (b'H', Spec::Hour24hZero),
895                (b'I', Spec::Hour12hZero),
896                (b'L', Spec::MilliSecond),
897                (b'M', Spec::Minute),
898                (b'N', Spec::FractionalSecond),
899                (b'P', Spec::MeridianLower),
900                (b'R', Spec::CombinationHourMinute24h),
901                (b'S', Spec::Second),
902                (b'T', Spec::CombinationTime24h),
903                (b'U', Spec::WeekNumberFromSunday),
904                (b'V', Spec::WeekNumberIso8601),
905                (b'W', Spec::WeekNumberFromMonday),
906                (b'X', Spec::CombinationTime24h),
907                (b'Y', Spec::Year4Digits),
908                (b'Z', Spec::TimeZoneName),
909                (b'a', Spec::WeekDayNameAbbr),
910                (b'b', Spec::MonthNameAbbr),
911                (b'c', Spec::CombinationDateTime),
912                (b'd', Spec::MonthDayZero),
913                (b'e', Spec::MonthDaySpace),
914                (b'g', Spec::YearIso8601Rem100),
915                (b'h', Spec::MonthNameAbbr),
916                (b'j', Spec::YearDay),
917                (b'k', Spec::Hour24hSpace),
918                (b'l', Spec::Hour12hSpace),
919                (b'm', Spec::Month),
920                (b'n', Spec::Newline),
921                (b'p', Spec::MeridianUpper),
922                (b'r', Spec::CombinationTime12h),
923                (b's', Spec::SecondsSinceEpoch),
924                (b't', Spec::Tabulation),
925                (b'u', Spec::WeekDayFrom1),
926                (b'v', Spec::CombinationVmsDate),
927                (b'w', Spec::WeekDayFrom0),
928                (b'x', Spec::CombinationDate),
929                (b'y', Spec::YearRem100),
930                (b'z', Spec::TimeZoneOffsetHourMinute),
931            ]);
932
933            match cursor.next() {
934                Some(x) => match POSSIBLE_SPECS.binary_search_by_key(&x, |&(c, _)| c) {
935                    #[expect(
936                        clippy::indexing_slicing,
937                        reason = "index is returned from binary search"
938                    )]
939                    Ok(index) => Some(POSSIBLE_SPECS[index].1),
940                    Err(_) => None,
941                },
942                None => return Err(Error::InvalidFormatString),
943            }
944        } else if cursor.read_optional_tag(b"z") {
945            match colons.len() {
946                1 => Some(Spec::TimeZoneOffsetHourMinuteColon),
947                2 => Some(Spec::TimeZoneOffsetHourMinuteSecondColon),
948                3 => Some(Spec::TimeZoneOffsetColonMinimal),
949                _ => None,
950            }
951        } else {
952            None
953        };
954
955        Ok(spec.map(|spec| Piece::new(width, padding, flags, spec)))
956    }
957}
958
959/// Compute the width of the string representation of a year.
960fn year_width(year: i32) -> usize {
961    const MINUS_SIGN_WIDTH: usize = 1;
962    let mut n = if year <= 0 { MINUS_SIGN_WIDTH } else { 0 };
963    let mut val = year;
964    while val != 0 {
965        val /= 10;
966        n += 1;
967    }
968    n
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974
975    #[test]
976    fn test_year_width() {
977        assert_eq!(year_width(-100), 4);
978        assert_eq!(year_width(-99), 3);
979        assert_eq!(year_width(-10), 3);
980        assert_eq!(year_width(-9), 2);
981        assert_eq!(year_width(-1), 2);
982        assert_eq!(year_width(0), 1);
983        assert_eq!(year_width(1), 1);
984        assert_eq!(year_width(9), 1);
985        assert_eq!(year_width(10), 2);
986        assert_eq!(year_width(99), 2);
987        assert_eq!(year_width(100), 3);
988        assert_eq!(year_width(2025), 4);
989    }
990
991    #[cfg(feature = "alloc")]
992    #[test]
993    fn test_flag_debug_is_non_empty() {
994        use alloc::format;
995
996        assert!(!format!("{:?}", Flag::LeftPadding).is_empty());
997        assert!(!format!("{:?}", Flag::ChangeCase).is_empty());
998        assert!(!format!("{:?}", Flag::UpperCase).is_empty());
999    }
1000
1001    #[cfg(feature = "alloc")]
1002    #[test]
1003    fn test_flags_debug_is_non_empty() {
1004        use alloc::format;
1005
1006        assert!(!format!("{:?}", Flags::default()).is_empty());
1007    }
1008
1009    #[cfg(feature = "alloc")]
1010    #[test]
1011    fn test_padding_debug_is_non_empty() {
1012        use alloc::format;
1013
1014        assert!(!format!("{:?}", Padding::Left).is_empty());
1015        assert!(!format!("{:?}", Padding::Spaces).is_empty());
1016        assert!(!format!("{:?}", Padding::Zeros).is_empty());
1017    }
1018
1019    #[cfg(feature = "alloc")]
1020    #[test]
1021    fn test_spec_debug_is_non_empty() {
1022        use alloc::format;
1023
1024        assert!(!format!("{:?}", Spec::Year4Digits).is_empty());
1025        assert!(!format!("{:?}", Spec::YearDiv100).is_empty());
1026        assert!(!format!("{:?}", Spec::YearRem100).is_empty());
1027        assert!(!format!("{:?}", Spec::Month).is_empty());
1028        assert!(!format!("{:?}", Spec::MonthName).is_empty());
1029        assert!(!format!("{:?}", Spec::MonthNameAbbr).is_empty());
1030        assert!(!format!("{:?}", Spec::MonthDayZero).is_empty());
1031        assert!(!format!("{:?}", Spec::MonthDaySpace).is_empty());
1032        assert!(!format!("{:?}", Spec::YearDay).is_empty());
1033        assert!(!format!("{:?}", Spec::Hour24hZero).is_empty());
1034        assert!(!format!("{:?}", Spec::Hour24hSpace).is_empty());
1035        assert!(!format!("{:?}", Spec::Hour12hZero).is_empty());
1036        assert!(!format!("{:?}", Spec::Hour12hSpace).is_empty());
1037        assert!(!format!("{:?}", Spec::MeridianLower).is_empty());
1038        assert!(!format!("{:?}", Spec::MeridianUpper).is_empty());
1039        assert!(!format!("{:?}", Spec::Minute).is_empty());
1040        assert!(!format!("{:?}", Spec::Second).is_empty());
1041        assert!(!format!("{:?}", Spec::MilliSecond).is_empty());
1042        assert!(!format!("{:?}", Spec::FractionalSecond).is_empty());
1043        assert!(!format!("{:?}", Spec::TimeZoneOffsetHourMinute).is_empty());
1044        assert!(!format!("{:?}", Spec::TimeZoneOffsetHourMinuteColon).is_empty());
1045        assert!(!format!("{:?}", Spec::TimeZoneOffsetHourMinuteSecondColon).is_empty());
1046        assert!(!format!("{:?}", Spec::TimeZoneOffsetColonMinimal).is_empty());
1047        assert!(!format!("{:?}", Spec::TimeZoneName).is_empty());
1048        assert!(!format!("{:?}", Spec::WeekDayName).is_empty());
1049        assert!(!format!("{:?}", Spec::WeekDayNameAbbr).is_empty());
1050        assert!(!format!("{:?}", Spec::WeekDayFrom1).is_empty());
1051        assert!(!format!("{:?}", Spec::WeekDayFrom0).is_empty());
1052        assert!(!format!("{:?}", Spec::YearIso8601).is_empty());
1053        assert!(!format!("{:?}", Spec::YearIso8601Rem100).is_empty());
1054        assert!(!format!("{:?}", Spec::WeekNumberIso8601).is_empty());
1055        assert!(!format!("{:?}", Spec::WeekNumberFromSunday).is_empty());
1056        assert!(!format!("{:?}", Spec::WeekNumberFromMonday).is_empty());
1057        assert!(!format!("{:?}", Spec::SecondsSinceEpoch).is_empty());
1058        assert!(!format!("{:?}", Spec::Newline).is_empty());
1059        assert!(!format!("{:?}", Spec::Tabulation).is_empty());
1060        assert!(!format!("{:?}", Spec::Percent).is_empty());
1061        assert!(!format!("{:?}", Spec::CombinationDateTime).is_empty());
1062        assert!(!format!("{:?}", Spec::CombinationDate).is_empty());
1063        assert!(!format!("{:?}", Spec::CombinationIso8601).is_empty());
1064        assert!(!format!("{:?}", Spec::CombinationVmsDate).is_empty());
1065        assert!(!format!("{:?}", Spec::CombinationTime12h).is_empty());
1066        assert!(!format!("{:?}", Spec::CombinationHourMinute24h).is_empty());
1067        assert!(!format!("{:?}", Spec::CombinationTime24h).is_empty());
1068    }
1069
1070    #[cfg(feature = "alloc")]
1071    #[test]
1072    fn test_utc_offset_debug_is_non_empty() {
1073        use alloc::format;
1074
1075        assert!(!format!("{:?}", UtcOffset::new(0.0, 0, 0)).is_empty());
1076    }
1077
1078    #[cfg(feature = "alloc")]
1079    #[test]
1080    fn test_piece_debug_is_non_empty() {
1081        use alloc::format;
1082
1083        let piece = Piece::new(
1084            None,
1085            Padding::Spaces,
1086            Flags::default(),
1087            Spec::CombinationTime24h,
1088        );
1089
1090        assert!(!format!("{piece:?}").is_empty());
1091    }
1092}