Skip to main content

mf2_runtime/
datetime.rs

1//! Date/time values (`plans/03-runtime.md` §2.7; `datetime.md`): what an
2//! application passes ([`crate::Arg::DateTime`]), what `mf2-fn-datetime`
3//! resolves (the value with its [`DateTimeOptions`]), the formatting
4//! context's [`TimeZone`], and the request a host's date formatter receives.
5//! The semantics — literal parsing, option rules, zone conversion — are
6//! `mf2-fn-datetime`'s; this module holds the types and the civil-calendar
7//! arithmetic both sides need.
8
9use crate::sink::Sink;
10use crate::text::write_u64;
11
12/// The largest year magnitude a [`Date`] holds (an implementation limit,
13/// wide enough for any instant an `i64` of milliseconds can name).
14const MAX_YEAR: i32 = 999_999;
15
16/// Milliseconds per day.
17const DAY_MS: i64 = 86_400_000;
18
19/// A civil date in the proleptic Gregorian calendar (ISO 8601).
20#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
21pub struct Date {
22    year: i32,
23    month: u8,
24    day: u8,
25}
26
27/// Whether `year` is a Gregorian leap year.
28const fn is_leap(year: i32) -> bool {
29    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
30}
31
32/// The number of days of `month` (1–12) in `year`.
33const fn month_days(year: i32, month: u8) -> u8 {
34    match month {
35        2 if is_leap(year) => 29,
36        2 => 28,
37        4 | 6 | 9 | 11 => 30,
38        _ => 31,
39    }
40}
41
42impl Date {
43    /// The date `year-month-day`; `None` unless the month is 1–12, the day
44    /// exists in that month, and `|year|` ≤ 999,999.
45    pub const fn new(year: i32, month: u8, day: u8) -> Option<Date> {
46        if year < -MAX_YEAR || year > MAX_YEAR || month < 1 || month > 12 {
47            return None;
48        }
49        if day < 1 || day > month_days(year, month) {
50            return None;
51        }
52        Some(Date { year, month, day })
53    }
54
55    /// The year (astronomical: 0 is 1 BCE).
56    pub const fn year(self) -> i32 {
57        self.year
58    }
59
60    /// The month, 1–12.
61    pub const fn month(self) -> u8 {
62        self.month
63    }
64
65    /// The day of the month, 1–31.
66    pub const fn day(self) -> u8 {
67        self.day
68    }
69
70    /// Days since 1970-01-01 (negative before it).
71    pub const fn days_since_epoch(self) -> i64 {
72        // Howard Hinnant's `days_from_civil`: exact over the whole range.
73        let m = self.month as i64;
74        let y = self.year as i64 - if m <= 2 { 1 } else { 0 };
75        let era = (if y >= 0 { y } else { y - 399 }) / 400;
76        let yoe = y - era * 400;
77        let mp = if m > 2 { m - 3 } else { m + 9 };
78        let doy = (153 * mp + 2) / 5 + self.day as i64 - 1;
79        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
80        era * 146_097 + doe - 719_468
81    }
82
83    /// The date `days` after 1970-01-01; `None` past the year limit.
84    pub const fn from_days_since_epoch(days: i64) -> Option<Date> {
85        // Hinnant's `civil_from_days`. Past ±400 million days the year
86        // limit is exceeded anyway; the bound keeps the arithmetic in range.
87        if days < -400_000_000 || days > 400_000_000 {
88            return None;
89        }
90        let z = days + 719_468;
91        let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
92        let doe = z - era * 146_097;
93        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
94        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
95        let mp = (5 * doy + 2) / 153;
96        let day = doy - (153 * mp + 2) / 5 + 1;
97        let month = if mp < 10 { mp + 3 } else { mp - 9 };
98        let year = yoe + era * 400 + if month <= 2 { 1 } else { 0 };
99        if year < -(MAX_YEAR as i64) || year > MAX_YEAR as i64 {
100            return None;
101        }
102        // In range by the checks above: 1 ≤ month ≤ 12, 1 ≤ day ≤ 31.
103        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
104        let (year, month, day) = (year as i32, month as u8, day as u8);
105        Date::new(year, month, day)
106    }
107
108    /// The ISO weekday: 1 = Monday … 7 = Sunday.
109    pub const fn weekday(self) -> u8 {
110        // 1970-01-01 was a Thursday (4).
111        let w = (self.days_since_epoch() + 3).rem_euclid(7);
112        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
113        let w = w as u8;
114        w + 1
115    }
116}
117
118/// A wall-clock time, to the millisecond (a date/time literal has at most
119/// three fraction digits).
120#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
121pub struct Time {
122    hour: u8,
123    minute: u8,
124    second: u8,
125    millisecond: u16,
126}
127
128impl Time {
129    /// 00:00:00.000 — the time of a literal that has none (datetime.md).
130    pub const MIDNIGHT: Time = Time {
131        hour: 0,
132        minute: 0,
133        second: 0,
134        millisecond: 0,
135    };
136
137    /// `hour:minute:second.millisecond`; `None` unless 0–23, 0–59, 0–59,
138    /// 0–999.
139    pub const fn new(hour: u8, minute: u8, second: u8, millisecond: u16) -> Option<Time> {
140        if hour > 23 || minute > 59 || second > 59 || millisecond > 999 {
141            return None;
142        }
143        Some(Time {
144            hour,
145            minute,
146            second,
147            millisecond,
148        })
149    }
150
151    /// The hour, 0–23.
152    pub const fn hour(self) -> u8 {
153        self.hour
154    }
155
156    /// The minute, 0–59.
157    pub const fn minute(self) -> u8 {
158        self.minute
159    }
160
161    /// The second, 0–59.
162    pub const fn second(self) -> u8 {
163        self.second
164    }
165
166    /// The millisecond, 0–999.
167    pub const fn millisecond(self) -> u16 {
168        self.millisecond
169    }
170
171    /// Milliseconds since midnight.
172    pub const fn ms_of_day(self) -> i64 {
173        ((self.hour as i64 * 60 + self.minute as i64) * 60 + self.second as i64) * 1000
174            + self.millisecond as i64
175    }
176
177    /// The time `ms` milliseconds after midnight (`0 ≤ ms < 86,400,000`).
178    pub const fn from_ms_of_day(ms: i64) -> Option<Time> {
179        if ms < 0 || ms >= DAY_MS {
180            return None;
181        }
182        // In range by the check: every quotient fits its field.
183        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
184        let (h, m, s, milli) = (
185            (ms / 3_600_000) as u8,
186            (ms / 60_000 % 60) as u8,
187            (ms / 1000 % 60) as u8,
188            (ms % 1000) as u16,
189        );
190        Time::new(h, m, s, milli)
191    }
192}
193
194/// The largest UTC offset magnitude, in seconds (exclusive): a day.
195const MAX_OFFSET: i32 = 86_400;
196
197/// A date/time value: an application's argument ([`crate::Arg::DateTime`],
198/// [`crate::CustomValue::as_date_time`]), a literal a date/time function
199/// parsed, or what `:datetime`, `:date` or `:time` resolved — the value with
200/// its [`DateTimeOptions`]. Build it with the constructors.
201#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
202#[non_exhaustive]
203pub struct DateTime<'a> {
204    /// The civil date (in the value's offset, or floating).
205    pub date: Date,
206    /// The wall-clock time (00:00 when the value has no time).
207    pub time: Time,
208    /// Seconds east of UTC; `None`: floating — no offset, so the formatting
209    /// time zone applies without conversion (datetime.md, "Date and Time
210    /// Operands").
211    pub offset: Option<i32>,
212    /// The IANA time zone the value is in, when it names one.
213    pub zone: Option<&'a str>,
214    /// What a date/time function resolved; empty for an argument, except
215    /// for the override options an application value may carry.
216    pub options: DateTimeOptions<'a>,
217}
218
219impl<'a> DateTime<'a> {
220    /// A floating date and time.
221    pub const fn floating(date: Date, time: Time) -> DateTime<'a> {
222        DateTime {
223            date,
224            time,
225            offset: None,
226            zone: None,
227            options: DateTimeOptions::NONE,
228        }
229    }
230
231    /// The instant `ms` milliseconds after 1970-01-01T00:00:00Z, in UTC
232    /// (datetime.md: an offset from an epoch includes UTC as its zone).
233    pub const fn from_epoch_ms(ms: i64) -> Option<DateTime<'a>> {
234        let Some(date) = Date::from_days_since_epoch(ms.div_euclid(DAY_MS)) else {
235            return None;
236        };
237        let Some(time) = Time::from_ms_of_day(ms.rem_euclid(DAY_MS)) else {
238            return None;
239        };
240        Some(DateTime {
241            date,
242            time,
243            offset: Some(0),
244            zone: None,
245            options: DateTimeOptions::NONE,
246        })
247    }
248
249    /// The instant, in milliseconds since the epoch; `None` when floating.
250    pub const fn to_epoch_ms(&self) -> Option<i64> {
251        match self.offset {
252            Some(o) => Some(
253                self.date.days_since_epoch() * DAY_MS + self.time.ms_of_day() - o as i64 * 1000,
254            ),
255            None => None,
256        }
257    }
258
259    /// The same wall time at UTC offset `seconds`; `None` unless
260    /// `|seconds|` < 86,400.
261    pub const fn with_offset(self, seconds: i32) -> Option<Self> {
262        if seconds <= -MAX_OFFSET || seconds >= MAX_OFFSET {
263            return None;
264        }
265        Some(DateTime {
266            offset: Some(seconds),
267            ..self
268        })
269    }
270
271    /// The same value, in the IANA zone `zone`. A value with an offset is
272    /// an instant, and stays one: a date/time function shows it at the
273    /// zone's wall time, converting through [`Host::zone_offset`] when the
274    /// offset is not the zone's. A floating value's wall time is placed in
275    /// `zone`.
276    ///
277    /// [`Host::zone_offset`]: crate::Host::zone_offset
278    #[must_use]
279    pub const fn in_zone(self, zone: &'a str) -> Self {
280        DateTime {
281            zone: Some(zone),
282            ..self
283        }
284    }
285
286    /// Writes the ISO 8601 / RFC 9557 text: `2006-01-02T15:04:06`, with
287    /// `.mmm` when there are milliseconds, `Z` or `±hh:mm` when the value
288    /// has an offset, `[zone]` when it names one — how an unannotated
289    /// date/time formats (`plans/03-runtime.md` §2.7).
290    pub fn write_iso(&self, out: &mut dyn Sink) {
291        let y = self.date.year;
292        if (0..=9999).contains(&y) {
293            write_padded(y.unsigned_abs(), 4, out);
294        } else {
295            out.push_str(if y < 0 { "-" } else { "+" });
296            write_padded(y.unsigned_abs(), 6, out);
297        }
298        out.push_str("-");
299        write_padded(u32::from(self.date.month), 2, out);
300        out.push_str("-");
301        write_padded(u32::from(self.date.day), 2, out);
302        out.push_str("T");
303        write_padded(u32::from(self.time.hour), 2, out);
304        out.push_str(":");
305        write_padded(u32::from(self.time.minute), 2, out);
306        out.push_str(":");
307        write_padded(u32::from(self.time.second), 2, out);
308        if self.time.millisecond != 0 {
309            out.push_str(".");
310            write_padded(u32::from(self.time.millisecond), 3, out);
311        }
312        match self.offset {
313            Some(0) => out.push_str("Z"),
314            Some(o) => write_offset(o, out),
315            None => {}
316        }
317        if let Some(z) = self.zone {
318            out.push_str("[");
319            out.push_str(z);
320            out.push_str("]");
321        }
322    }
323}
324
325/// Writes `n` with at least `width` digits.
326fn write_padded(n: u32, width: u32, out: &mut dyn Sink) {
327    let mut digits = 1;
328    let mut m = n;
329    while m >= 10 {
330        m /= 10;
331        digits += 1;
332    }
333    while digits < width {
334        out.push_str("0");
335        digits += 1;
336    }
337    write_u64(u64::from(n), out);
338}
339
340/// Writes a UTC offset as `±hh:mm`, with `:ss` when it has seconds.
341pub(crate) fn write_offset(seconds: i32, out: &mut dyn Sink) {
342    out.push_str(if seconds < 0 { "-" } else { "+" });
343    let s = seconds.unsigned_abs();
344    write_padded(s / 3600, 2, out);
345    out.push_str(":");
346    write_padded(s / 60 % 60, 2, out);
347    if !s.is_multiple_of(60) {
348        out.push_str(":");
349        write_padded(s % 60, 2, out);
350    }
351}
352
353/// What `:datetime`, `:date` or `:time` resolved (datetime.md). The
354/// override options (`time_zone`, `hour12`, `calendar`) travel with the
355/// value into a later date/time expression that takes it as its operand.
356#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
357#[non_exhaustive]
358pub struct DateTimeOptions<'a> {
359    /// The date part; `None`: none (`:time`), or not resolved.
360    pub date: Option<DateStyle>,
361    /// The time part; `None`: none (`:date`), or not resolved.
362    pub time: Option<TimePrecision>,
363    /// `timeZoneStyle`; `None`: no time-zone indicator.
364    pub time_zone_style: Option<ZoneStyle>,
365    /// `timeZone`.
366    pub time_zone: Option<ZoneOption<'a>>,
367    /// `hour12`.
368    pub hour12: Option<bool>,
369    /// `calendar`: a Unicode calendar identifier.
370    pub calendar: Option<&'a str>,
371}
372
373impl DateTimeOptions<'_> {
374    /// No options.
375    pub const NONE: DateTimeOptions<'static> = DateTimeOptions {
376        date: None,
377        time: None,
378        time_zone_style: None,
379        time_zone: None,
380        hour12: None,
381        calendar: None,
382    };
383
384    /// Whether a date/time function resolved the value (it has a date or a
385    /// time part).
386    pub const fn is_resolved(&self) -> bool {
387        self.date.is_some() || self.time.is_some()
388    }
389}
390
391/// The date part: `dateFields` / `fields` and `dateLength` / `length`.
392#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
393pub struct DateStyle {
394    /// The fields shown.
395    pub fields: DateFields,
396    /// Their length.
397    pub length: DateLength,
398}
399
400/// `dateFields` / `fields`.
401#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
402#[non_exhaustive]
403pub enum DateFields {
404    /// `weekday`.
405    Weekday,
406    /// `day-weekday`.
407    DayWeekday,
408    /// `month-day`.
409    MonthDay,
410    /// `month-day-weekday`.
411    MonthDayWeekday,
412    /// `year-month-day` (the default).
413    YearMonthDay,
414    /// `year-month-day-weekday`.
415    YearMonthDayWeekday,
416}
417
418/// `dateLength` / `length`.
419#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
420#[non_exhaustive]
421pub enum DateLength {
422    /// `long`.
423    Long,
424    /// `medium` (the default).
425    Medium,
426    /// `short`.
427    Short,
428}
429
430/// `timePrecision` / `precision`.
431#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
432#[non_exhaustive]
433pub enum TimePrecision {
434    /// `hour`.
435    Hour,
436    /// `minute` (the default).
437    Minute,
438    /// `second`.
439    Second,
440}
441
442/// `timeZoneStyle`.
443#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
444#[non_exhaustive]
445pub enum ZoneStyle {
446    /// `long`.
447    Long,
448    /// `short`.
449    Short,
450}
451
452/// A time zone as an option value (`timeZone`) or a formatting target.
453#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
454#[non_exhaustive]
455pub enum ZoneOption<'a> {
456    /// `input`: the operand's own zone.
457    Input,
458    /// `UTC`.
459    Utc,
460    /// A UTC offset, in seconds east.
461    Offset(i32),
462    /// An IANA zone: a well-formed RFC 9557 `time-zone-name`.
463    Named(&'a str),
464}
465
466/// The longest zone name a [`TimeZone`] holds (IANA's longest is 32).
467const MAX_ZONE_NAME: usize = 64;
468
469/// The formatting context's time zone: the default of `timeZone`
470/// (`plans/03-runtime.md` §6). Owned — a per-request zone need not be
471/// `'static`.
472#[derive(Clone, Copy, PartialEq, Eq, Hash)]
473pub struct TimeZone {
474    repr: Repr,
475}
476
477#[derive(Clone, Copy, PartialEq, Eq, Hash)]
478enum Repr {
479    Utc,
480    Offset(i32),
481    Named { len: u8, name: [u8; MAX_ZONE_NAME] },
482}
483
484impl TimeZone {
485    /// UTC.
486    pub const UTC: TimeZone = TimeZone { repr: Repr::Utc };
487
488    /// A fixed offset, in seconds east of UTC; `None` unless `|seconds|` <
489    /// 86,400.
490    pub const fn offset(seconds: i32) -> Option<TimeZone> {
491        if seconds <= -MAX_OFFSET || seconds >= MAX_OFFSET {
492            return None;
493        }
494        Some(TimeZone {
495            repr: Repr::Offset(seconds),
496        })
497    }
498
499    /// The IANA zone `name`; `None` unless it is a well-formed RFC 9557
500    /// `time-zone-name` of at most 64 bytes.
501    pub fn named(name: &str) -> Option<TimeZone> {
502        if !is_zone_name(name) || name.len() > MAX_ZONE_NAME {
503            return None;
504        }
505        let mut buf = [0u8; MAX_ZONE_NAME];
506        for (b, &c) in buf.iter_mut().zip(name.as_bytes()) {
507            *b = c;
508        }
509        Some(TimeZone {
510            repr: Repr::Named {
511                len: u8::try_from(name.len()).ok()?,
512                name: buf,
513            },
514        })
515    }
516
517    /// The zone as an option value: `Utc`, `Offset` or `Named`.
518    pub fn as_option(&self) -> ZoneOption<'_> {
519        match &self.repr {
520            Repr::Utc => ZoneOption::Utc,
521            Repr::Offset(s) => ZoneOption::Offset(*s),
522            Repr::Named { len, name } => ZoneOption::Named(
523                name.get(..usize::from(*len))
524                    .and_then(|b| core::str::from_utf8(b).ok())
525                    .unwrap_or(""),
526            ),
527        }
528    }
529}
530
531impl core::fmt::Debug for TimeZone {
532    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
533        match self.as_option() {
534            ZoneOption::Named(n) => f.debug_tuple("TimeZone").field(&n).finish(),
535            other => f.debug_tuple("TimeZone").field(&other).finish(),
536        }
537    }
538}
539
540impl Default for TimeZone {
541    fn default() -> Self {
542        TimeZone::UTC
543    }
544}
545
546/// Whether `s` is a well-formed RFC 9557 `time-zone-name`: parts separated
547/// by `/`, each starting with a letter, `.` or `_`, then letters, digits,
548/// `.`, `_`, `-` or `+`, and neither `.` nor `..`.
549pub fn is_zone_name(s: &str) -> bool {
550    let b = s.as_bytes();
551    if b.is_empty() {
552        return false;
553    }
554    let mut start = 0;
555    let mut i = 0;
556    while i <= b.len() {
557        let end = i == b.len();
558        if end || b.get(i) == Some(&b'/') {
559            let part = b.get(start..i).unwrap_or(&[]);
560            if !zone_part(part) {
561                return false;
562            }
563            start = i + 1;
564        }
565        i += 1;
566    }
567    true
568}
569
570fn zone_part(p: &[u8]) -> bool {
571    let initial = |c: u8| c.is_ascii_alphabetic() || c == b'.' || c == b'_';
572    match p {
573        [] | [b'.'] | [b'.', b'.'] => false,
574        [first, rest @ ..] => {
575            initial(*first)
576                && rest
577                    .iter()
578                    .all(|&c| initial(c) || c.is_ascii_digit() || c == b'-' || c == b'+')
579        }
580    }
581}
582
583/// What a host's date formatter receives (`Host::format_date_time`,
584/// `datetime-intl`): an instant and how to show it.
585#[derive(Clone, Copy, Debug)]
586#[non_exhaustive]
587pub struct DateTimeRequest<'r> {
588    /// The instant, in milliseconds since the epoch. For a floating value
589    /// shown without a zone: its wall time read as UTC, with `zone` = UTC.
590    pub epoch_ms: i64,
591    /// The zone to show it in; never [`ZoneOption::Input`].
592    pub zone: ZoneOption<'r>,
593    /// The resolved options: the date and time parts, the zone style,
594    /// `hour12`, `calendar`.
595    pub options: &'r DateTimeOptions<'r>,
596}
597
598impl<'r> DateTimeRequest<'r> {
599    /// The request to show `epoch_ms` in `zone` with `options` (a date
600    /// backend makes it; a later version may add fields).
601    pub const fn new(
602        epoch_ms: i64,
603        zone: ZoneOption<'r>,
604        options: &'r DateTimeOptions<'r>,
605    ) -> DateTimeRequest<'r> {
606        DateTimeRequest {
607            epoch_ms,
608            zone,
609            options,
610        }
611    }
612}
613
614#[cfg(test)]
615#[allow(clippy::unwrap_used)]
616mod tests {
617    use super::*;
618    use alloc::string::String;
619
620    #[test]
621    fn civil_days_round_trip() {
622        for days in [-719_468i64, -1, 0, 1, 10_957, 13_150, 2_932_896, -2_932_897] {
623            let d = Date::from_days_since_epoch(days).unwrap();
624            assert_eq!(d.days_since_epoch(), days, "{d:?}");
625        }
626        let d = Date::new(2006, 1, 2).unwrap();
627        assert_eq!(d.days_since_epoch(), 13_150);
628        assert_eq!(d.weekday(), 1); // a Monday
629        assert_eq!(Date::new(1970, 1, 1).unwrap().weekday(), 4);
630        assert!(Date::new(2023, 2, 29).is_none());
631        assert!(Date::new(2024, 2, 29).is_some());
632        assert!(Date::new(1900, 2, 29).is_none());
633        assert!(Date::new(2000, 2, 29).is_some());
634        assert!(Date::new(1_000_000, 1, 1).is_none());
635    }
636
637    #[test]
638    fn epoch_round_trip() {
639        for ms in [
640            0i64,
641            1,
642            -1,
643            1_136_214_246_000,
644            -62_135_596_800_000,
645            253_402_300_799_999,
646        ] {
647            let dt = DateTime::from_epoch_ms(ms).unwrap();
648            assert_eq!(dt.to_epoch_ms(), Some(ms));
649        }
650        let dt = DateTime::from_epoch_ms(1_136_214_246_000).unwrap();
651        let mut s = String::new();
652        dt.write_iso(&mut s);
653        assert_eq!(s, "2006-01-02T15:04:06Z");
654        let dt = dt.with_offset(-7 * 3600).unwrap().in_zone("America/Denver");
655        s.clear();
656        dt.write_iso(&mut s);
657        assert_eq!(s, "2006-01-02T15:04:06-07:00[America/Denver]");
658        assert_eq!(dt.to_epoch_ms(), Some(1_136_214_246_000 + 7 * 3_600_000));
659        assert!(DateTime::from_epoch_ms(i64::MAX).is_none());
660        assert!(dt.with_offset(86_400).is_none());
661    }
662
663    #[test]
664    fn iso_text() {
665        let mut s = String::new();
666        let dt = DateTime::floating(
667            Date::new(-44, 3, 15).unwrap(),
668            Time::new(9, 5, 0, 7).unwrap(),
669        );
670        dt.write_iso(&mut s);
671        assert_eq!(s, "-000044-03-15T09:05:00.007");
672    }
673
674    #[test]
675    fn zones() {
676        for good in [
677            "UTC",
678            "America/New_York",
679            "Etc/GMT+5",
680            "America/Argentina/ComodRivadavia",
681            "._x",
682        ] {
683            assert!(is_zone_name(good), "{good}");
684            let z = TimeZone::named(good).unwrap();
685            assert_eq!(z.as_option(), ZoneOption::Named(good));
686        }
687        for bad in ["", "/", "a/", "/a", "a//b", ".", "a/..", "1a", "a b", "é"] {
688            assert!(!is_zone_name(bad), "{bad}");
689            assert!(TimeZone::named(bad).is_none());
690        }
691        assert_eq!(TimeZone::default().as_option(), ZoneOption::Utc);
692        assert_eq!(
693            TimeZone::offset(3600).unwrap().as_option(),
694            ZoneOption::Offset(3600)
695        );
696        assert!(TimeZone::offset(-86_400).is_none());
697    }
698}