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`. As the
463    /// formatting context's zone only, also a POSIX TZ rule
464    /// ([`TimeZone::rules`]), which the host evaluates as it does a name
465    /// ([`Host::zone_offset`](crate::Host::zone_offset)).
466    Named(&'a str),
467}
468
469/// The longest zone name a [`TimeZone`] holds (IANA's longest is 32), and
470/// the longest POSIX TZ rule.
471const MAX_ZONE_NAME: usize = 64;
472
473/// The formatting context's time zone: the default of `timeZone`
474/// (`plans/03-runtime.md` §6). Owned — a per-request zone need not be
475/// `'static`.
476#[derive(Clone, Copy, PartialEq, Eq, Hash)]
477pub struct TimeZone {
478    repr: Repr,
479}
480
481#[derive(Clone, Copy, PartialEq, Eq, Hash)]
482enum Repr {
483    Utc,
484    Offset(i32),
485    /// An IANA name; or a POSIX TZ rule ([`TimeZone::rules`]), marked by
486    /// [`RULE`] in the buffer's last byte, which no name has (a name is
487    /// ASCII, and a rule at most 63 bytes). Held as a name is, so that a
488    /// build that never makes a rule compiles every use of a zone as it did
489    /// before rules existed.
490    Named {
491        len: u8,
492        name: [u8; MAX_ZONE_NAME],
493    },
494}
495
496/// The last byte of a rule's buffer ([`Repr::Named`]).
497const RULE: u8 = 0xFF;
498
499impl TimeZone {
500    /// UTC.
501    pub const UTC: TimeZone = TimeZone { repr: Repr::Utc };
502
503    /// A fixed offset, in seconds east of UTC; `None` unless `|seconds|` <
504    /// 86,400.
505    pub const fn offset(seconds: i32) -> Option<TimeZone> {
506        if seconds <= -MAX_OFFSET || seconds >= MAX_OFFSET {
507            return None;
508        }
509        Some(TimeZone {
510            repr: Repr::Offset(seconds),
511        })
512    }
513
514    /// The IANA zone `name`; `None` unless it is a well-formed RFC 9557
515    /// `time-zone-name` of at most 64 bytes.
516    pub fn named(name: &str) -> Option<TimeZone> {
517        if !is_zone_name(name) || name.len() > MAX_ZONE_NAME {
518            return None;
519        }
520        let mut buf = [0u8; MAX_ZONE_NAME];
521        for (b, &c) in buf.iter_mut().zip(name.as_bytes()) {
522            *b = c;
523        }
524        Some(TimeZone {
525            repr: Repr::Named {
526                len: u8::try_from(name.len()).ok()?,
527                name: buf,
528            },
529        })
530    }
531
532    /// A zone that follows the POSIX TZ rule `rule`: an offset for standard
533    /// time and, where the rule has one, another for daylight saving time
534    /// between two dates each year (`EST5EDT,M3.2.0,M11.1.0`). What a
535    /// native application's system zone is when it has no IANA name, so
536    /// that its dates follow the system's changes of offset rather than the
537    /// offset in force when it started.
538    ///
539    /// The host evaluates it, as it does an IANA name: `mf2-host-std` does;
540    /// a host that does not (the browser's) leaves a date in it a *Bad
541    /// Option* with a fallback, as for a zone it does not know. `None`
542    /// unless `rule` is 1 to 63 printable ASCII characters; whether it is a
543    /// rule the host can read, only the host knows.
544    pub fn rules(rule: &str) -> Option<TimeZone> {
545        if rule.is_empty()
546            || rule.len() >= MAX_ZONE_NAME
547            || !rule.bytes().all(|b| b.is_ascii_graphic())
548        {
549            return None;
550        }
551        let mut name = [0u8; MAX_ZONE_NAME];
552        name.get_mut(..rule.len())?.copy_from_slice(rule.as_bytes());
553        if let Some(last) = name.last_mut() {
554            *last = RULE;
555        }
556        Some(TimeZone {
557            repr: Repr::Named {
558                len: u8::try_from(rule.len()).ok()?,
559                name,
560            },
561        })
562    }
563
564    /// The zone as an option value: `Utc`, `Offset` or `Named` — a POSIX TZ
565    /// rule ([`TimeZone::rules`]) as `Named`, which the host evaluates.
566    pub fn as_option(&self) -> ZoneOption<'_> {
567        match &self.repr {
568            Repr::Utc => ZoneOption::Utc,
569            Repr::Offset(s) => ZoneOption::Offset(*s),
570            Repr::Named { len, name } => ZoneOption::Named(
571                name.get(..usize::from(*len))
572                    .and_then(|b| core::str::from_utf8(b).ok())
573                    .unwrap_or(""),
574            ),
575        }
576    }
577
578    /// The POSIX TZ rule, for a zone made by [`TimeZone::rules`].
579    fn rule(&self) -> Option<&str> {
580        match &self.repr {
581            Repr::Named { len, name } if name.last() == Some(&RULE) => name
582                .get(..usize::from(*len))
583                .and_then(|b| core::str::from_utf8(b).ok()),
584            _ => None,
585        }
586    }
587}
588
589/// `TimeZone("Europe/Paris")`, `TimeZone(Offset(3600))`, `TimeZone(Utc)`, or
590/// `TimeZone(Rules("EST5EDT,M3.2.0,M11.1.0"))`.
591impl core::fmt::Debug for TimeZone {
592    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
593        if let Some(rule) = self.rule() {
594            return f
595                .write_str("TimeZone(Rules(")
596                .and_then(|()| core::fmt::Debug::fmt(rule, f))
597                .and_then(|()| f.write_str("))"));
598        }
599        match self.as_option() {
600            ZoneOption::Named(n) => f.debug_tuple("TimeZone").field(&n).finish(),
601            other => f.debug_tuple("TimeZone").field(&other).finish(),
602        }
603    }
604}
605
606impl Default for TimeZone {
607    fn default() -> Self {
608        TimeZone::UTC
609    }
610}
611
612/// Whether `s` is a well-formed RFC 9557 `time-zone-name`: parts separated
613/// by `/`, each starting with a letter, `.` or `_`, then letters, digits,
614/// `.`, `_`, `-` or `+`, and neither `.` nor `..`.
615pub fn is_zone_name(s: &str) -> bool {
616    let b = s.as_bytes();
617    if b.is_empty() {
618        return false;
619    }
620    let mut start = 0;
621    let mut i = 0;
622    while i <= b.len() {
623        let end = i == b.len();
624        if end || b.get(i) == Some(&b'/') {
625            let part = b.get(start..i).unwrap_or(&[]);
626            if !zone_part(part) {
627                return false;
628            }
629            start = i + 1;
630        }
631        i += 1;
632    }
633    true
634}
635
636fn zone_part(p: &[u8]) -> bool {
637    let initial = |c: u8| c.is_ascii_alphabetic() || c == b'.' || c == b'_';
638    match p {
639        [] | [b'.'] | [b'.', b'.'] => false,
640        [first, rest @ ..] => {
641            initial(*first)
642                && rest
643                    .iter()
644                    .all(|&c| initial(c) || c.is_ascii_digit() || c == b'-' || c == b'+')
645        }
646    }
647}
648
649/// What a host's date formatter receives (`Host::format_date_time`,
650/// `datetime-intl`): an instant and how to show it.
651#[derive(Clone, Copy, Debug)]
652#[non_exhaustive]
653pub struct DateTimeRequest<'r> {
654    /// The instant, in milliseconds since the epoch. For a floating value
655    /// shown without a zone: its wall time read as UTC, with `zone` = UTC.
656    pub epoch_ms: i64,
657    /// The zone to show it in; never [`ZoneOption::Input`].
658    pub zone: ZoneOption<'r>,
659    /// The resolved options: the date and time parts, the zone style,
660    /// `hour12`, `calendar`.
661    pub options: &'r DateTimeOptions<'r>,
662}
663
664impl<'r> DateTimeRequest<'r> {
665    /// The request to show `epoch_ms` in `zone` with `options` (a date
666    /// backend makes it; a later version may add fields).
667    pub const fn new(
668        epoch_ms: i64,
669        zone: ZoneOption<'r>,
670        options: &'r DateTimeOptions<'r>,
671    ) -> DateTimeRequest<'r> {
672        DateTimeRequest {
673            epoch_ms,
674            zone,
675            options,
676        }
677    }
678}
679
680#[cfg(test)]
681#[allow(clippy::unwrap_used)]
682mod tests {
683    use super::*;
684    use alloc::string::String;
685
686    #[test]
687    fn civil_days_round_trip() {
688        for days in [-719_468i64, -1, 0, 1, 10_957, 13_150, 2_932_896, -2_932_897] {
689            let d = Date::from_days_since_epoch(days).unwrap();
690            assert_eq!(d.days_since_epoch(), days, "{d:?}");
691        }
692        let d = Date::new(2006, 1, 2).unwrap();
693        assert_eq!(d.days_since_epoch(), 13_150);
694        assert_eq!(d.weekday(), 1); // a Monday
695        assert_eq!(Date::new(1970, 1, 1).unwrap().weekday(), 4);
696        assert!(Date::new(2023, 2, 29).is_none());
697        assert!(Date::new(2024, 2, 29).is_some());
698        assert!(Date::new(1900, 2, 29).is_none());
699        assert!(Date::new(2000, 2, 29).is_some());
700        assert!(Date::new(1_000_000, 1, 1).is_none());
701    }
702
703    #[test]
704    fn epoch_round_trip() {
705        for ms in [
706            0i64,
707            1,
708            -1,
709            1_136_214_246_000,
710            -62_135_596_800_000,
711            253_402_300_799_999,
712        ] {
713            let dt = DateTime::from_epoch_ms(ms).unwrap();
714            assert_eq!(dt.to_epoch_ms(), Some(ms));
715        }
716        let dt = DateTime::from_epoch_ms(1_136_214_246_000).unwrap();
717        let mut s = String::new();
718        dt.write_iso(&mut s);
719        assert_eq!(s, "2006-01-02T15:04:06Z");
720        let dt = dt.with_offset(-7 * 3600).unwrap().in_zone("America/Denver");
721        s.clear();
722        dt.write_iso(&mut s);
723        assert_eq!(s, "2006-01-02T15:04:06-07:00[America/Denver]");
724        assert_eq!(dt.to_epoch_ms(), Some(1_136_214_246_000 + 7 * 3_600_000));
725        assert!(DateTime::from_epoch_ms(i64::MAX).is_none());
726        assert!(dt.with_offset(86_400).is_none());
727    }
728
729    #[test]
730    fn iso_text() {
731        let mut s = String::new();
732        let dt = DateTime::floating(
733            Date::new(-44, 3, 15).unwrap(),
734            Time::new(9, 5, 0, 7).unwrap(),
735        );
736        dt.write_iso(&mut s);
737        assert_eq!(s, "-000044-03-15T09:05:00.007");
738    }
739
740    #[test]
741    fn zones() {
742        for good in [
743            "UTC",
744            "America/New_York",
745            "Etc/GMT+5",
746            "America/Argentina/ComodRivadavia",
747            "._x",
748        ] {
749            assert!(is_zone_name(good), "{good}");
750            let z = TimeZone::named(good).unwrap();
751            assert_eq!(z.as_option(), ZoneOption::Named(good));
752        }
753        for bad in ["", "/", "a/", "/a", "a//b", ".", "a/..", "1a", "a b", "é"] {
754            assert!(!is_zone_name(bad), "{bad}");
755            assert!(TimeZone::named(bad).is_none());
756        }
757        assert_eq!(TimeZone::default().as_option(), ZoneOption::Utc);
758        assert_eq!(
759            TimeZone::offset(3600).unwrap().as_option(),
760            ZoneOption::Offset(3600)
761        );
762        assert!(TimeZone::offset(-86_400).is_none());
763    }
764
765    /// A POSIX TZ rule is carried as it is written, and the host sees it
766    /// where it sees a zone's name; it is not a name.
767    #[test]
768    fn rules() {
769        let rule = "EST5EDT,M3.2.0,M11.1.0";
770        assert!(!is_zone_name(rule));
771        let z = TimeZone::rules(rule).unwrap();
772        assert_eq!(z.as_option(), ZoneOption::Named(rule));
773        assert_ne!(Some(z), TimeZone::named("EST5EDT"));
774        let mut shown = String::new();
775        core::fmt::write(&mut shown, format_args!("{z:?}")).unwrap();
776        assert_eq!(shown, "TimeZone(Rules(\"EST5EDT,M3.2.0,M11.1.0\"))");
777        let longest = "<+0330>-3:30<+0430>,J79/24,J263/24-and-then-some-to-sixty-three";
778        assert_eq!(longest.len(), 63);
779        assert_eq!(
780            TimeZone::rules(longest).unwrap().as_option(),
781            ZoneOption::Named(longest)
782        );
783        for bad in [
784            "",
785            "EST 5",
786            "CET-1CEST,M3.5.0,M10.5.0/3\n",
787            "é",
788            &"x".repeat(64),
789        ] {
790            assert!(TimeZone::rules(bad).is_none(), "{bad}");
791        }
792        // A name of the longest length is a name, not a rule.
793        let name = "a".repeat(64);
794        let mut shown = String::new();
795        core::fmt::write(
796            &mut shown,
797            format_args!("{:?}", TimeZone::named(&name).unwrap()),
798        )
799        .unwrap();
800        let mut expected = String::new();
801        core::fmt::write(&mut expected, format_args!("TimeZone({name:?})")).unwrap();
802        assert_eq!(shown, expected);
803    }
804}