Skip to main content

rich_interact/
datetime.rs

1//! Dates and times of day (#719), without a time zone: what the date and
2//! time pickers ([`DatePicker`](crate::DatePicker),
3//! [`TimePicker`](crate::TimePicker),
4//! [`DateTimePicker`](crate::DateTimePicker)) and intuiTUIve's calendar
5//! choose. Hand-rolled, with no dependency: a [`Date`] is a day of the
6//! proleptic Gregorian calendar, a [`Time`] a time of day to the second,
7//! and a [`DateTime`] the two together. Each parses the ISO 8601 form it
8//! prints (`2026-10-17`, `14:30`, `2026-10-17 14:30`), and a time also
9//! parses a 12-hour clock (`2:30 pm`).
10//!
11//! ```
12//! use rich_interact::datetime::{Date, DateTime, Time};
13//!
14//! let at: DateTime = "2026-10-17 14:30".parse().unwrap();
15//! assert_eq!(at.date, Date::new(2026, 10, 17));
16//! assert_eq!(at.time, Time::new(14, 30, 0));
17//! assert_eq!(at.to_string(), "2026-10-17 14:30");
18//! assert_eq!("2:30 pm".parse::<Time>().unwrap(), Time::new(14, 30, 0));
19//! ```
20//!
21//! A [`Zone`] turns a moment (seconds since 1970 UTC) into a local
22//! [`DateTime`] and back, following summer time: [`Zone::Local`] is the
23//! platform's zone, and [`DateTime::now_local`] and [`Date::today_local`]
24//! read the clock in it. Named dates (`tomorrow`, `eow`, `3d`) are
25//! [`dates`](crate::dates)'.
26
27use std::fmt;
28use std::str::FromStr;
29use std::time::{SystemTime, UNIX_EPOCH};
30
31pub use crate::zone::{Rules, Zone};
32
33/// A day of the proleptic Gregorian calendar. Months and days count from 1.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub struct Date {
36    pub year: i32,
37    pub month: u32,
38    pub day: u32,
39}
40
41const MONTHS: [&str; 12] = [
42    "January",
43    "February",
44    "March",
45    "April",
46    "May",
47    "June",
48    "July",
49    "August",
50    "September",
51    "October",
52    "November",
53    "December",
54];
55
56/// Seconds since 1970-01-01 00:00 UTC, now.
57fn unix_now() -> i64 {
58    match SystemTime::now().duration_since(UNIX_EPOCH) {
59        Ok(since) => since.as_secs() as i64,
60        Err(before) => -(before.duration().as_secs() as i64),
61    }
62}
63
64impl Date {
65    pub const fn new(year: i32, month: u32, day: u32) -> Date {
66        Date { year, month, day }
67    }
68
69    /// Whether `year` has a 29th of February.
70    pub const fn is_leap_year(year: i32) -> bool {
71        (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
72    }
73
74    /// The days in `month` of `year`.
75    pub const fn days_in_month(year: i32, month: u32) -> u32 {
76        match month {
77            2 if Date::is_leap_year(year) => 29,
78            2 => 28,
79            4 | 6 | 9 | 11 => 30,
80            _ => 31,
81        }
82    }
83
84    /// Whether the month is 1 to 12 and the day is in it.
85    pub const fn is_valid(self) -> bool {
86        self.month >= 1
87            && self.month <= 12
88            && self.day >= 1
89            && self.day <= Date::days_in_month(self.year, self.month)
90    }
91
92    /// Today in UTC. There is no time zone here: pass the local day to a
93    /// picker when the difference matters.
94    pub fn today_utc() -> Date {
95        Date::from_days(unix_now().div_euclid(86_400))
96    }
97
98    /// Today in the local zone, summer time included.
99    pub fn today_local() -> Date {
100        DateTime::now_local().date
101    }
102
103    /// Today in `zone`.
104    pub fn today_in(zone: &Zone) -> Date {
105        DateTime::now_in(zone).date
106    }
107
108    /// Days since 1970-01-01 (negative before it).
109    pub fn days(self) -> i64 {
110        // Howard Hinnant's days_from_civil.
111        let year = self.year as i64 - (self.month <= 2) as i64;
112        let era = year.div_euclid(400);
113        let yoe = year - era * 400;
114        let month = self.month as i64;
115        let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + self.day as i64 - 1;
116        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
117        era * 146_097 + doe - 719_468
118    }
119
120    /// The date `days` after 1970-01-01.
121    pub fn from_days(days: i64) -> Date {
122        // Howard Hinnant's civil_from_days.
123        let z = days + 719_468;
124        let era = z.div_euclid(146_097);
125        let doe = z - era * 146_097;
126        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
127        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
128        let mp = (5 * doy + 2) / 153;
129        let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
130        let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
131        let year = (yoe + era * 400 + (month <= 2) as i64) as i32;
132        Date { year, month, day }
133    }
134
135    /// The day of the week: 0 for Monday to 6 for Sunday.
136    pub fn weekday(self) -> u32 {
137        // 1970-01-01 was a Thursday.
138        (self.days() + 3).rem_euclid(7) as u32
139    }
140
141    /// The date `days` later (earlier if negative).
142    #[must_use]
143    pub fn add_days(self, days: i64) -> Date {
144        Date::from_days(self.days() + days)
145    }
146
147    /// The same day `months` later (earlier if negative), or the last day
148    /// of that month if it is shorter.
149    #[must_use]
150    pub fn add_months(self, months: i32) -> Date {
151        let index = self.year as i64 * 12 + self.month as i64 - 1 + months as i64;
152        let year = index.div_euclid(12) as i32;
153        let month = index.rem_euclid(12) as u32 + 1;
154        let day = self.day.min(Date::days_in_month(year, month));
155        Date { year, month, day }
156    }
157
158    /// The first day of its month.
159    #[must_use]
160    pub fn first_of_month(self) -> Date {
161        Date { day: 1, ..self }
162    }
163
164    /// The last day of its month.
165    #[must_use]
166    pub fn last_of_month(self) -> Date {
167        Date {
168            day: Date::days_in_month(self.year, self.month),
169            ..self
170        }
171    }
172
173    /// The month's name, in English.
174    pub fn month_name(self) -> &'static str {
175        MONTHS[(self.month.clamp(1, 12) - 1) as usize]
176    }
177
178    /// The day at `time`.
179    pub const fn at(self, time: Time) -> DateTime {
180        DateTime { date: self, time }
181    }
182}
183
184impl fmt::Display for Date {
185    /// `2026-10-07`.
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
188    }
189}
190
191impl FromStr for Date {
192    type Err = String;
193
194    /// `YYYY-MM-DD` (the year four digits, 0001 to 9999; the month and day
195    /// may have one), or `today` (in UTC).
196    fn from_str(text: &str) -> Result<Date, String> {
197        let text = text.trim();
198        if text.eq_ignore_ascii_case("today") {
199            return Ok(Date::today_utc());
200        }
201        let bad = || format!("{text:?} is not a date (YYYY-MM-DD)");
202        let mut parts = text.split('-');
203        let (Some(year), Some(month), Some(day), None) =
204            (parts.next(), parts.next(), parts.next(), parts.next())
205        else {
206            return Err(bad());
207        };
208        let number = |part: &str, most: usize| {
209            (!part.is_empty() && part.len() <= most && part.bytes().all(|b| b.is_ascii_digit()))
210                .then(|| part.parse::<u32>().ok())
211                .flatten()
212        };
213        // Four digits make the year, as the date is printed: `26-10-17` is
214        // not the year 26.
215        let year = (year.len() == 4).then(|| number(year, 4)).flatten();
216        let (Some(year @ 1..), Some(month), Some(day)) = (year, number(month, 2), number(day, 2))
217        else {
218            return Err(bad());
219        };
220        let date = Date::new(year as i32, month, day);
221        if !date.is_valid() {
222            return Err(format!("{text:?} is not a day of the calendar"));
223        }
224        Ok(date)
225    }
226}
227
228/// A time of day, to the second: 00:00:00 to 23:59:59.
229#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
230pub struct Time {
231    pub hour: u32,
232    pub minute: u32,
233    pub second: u32,
234}
235
236impl Time {
237    /// Midnight, the first time of a day.
238    pub const MIDNIGHT: Time = Time::new(0, 0, 0);
239    /// 23:59:59, the last time of a day.
240    pub const LAST: Time = Time::new(23, 59, 59);
241
242    pub const fn new(hour: u32, minute: u32, second: u32) -> Time {
243        Time {
244            hour,
245            minute,
246            second,
247        }
248    }
249
250    /// Whether the hour is below 24 and the minute and second below 60.
251    pub const fn is_valid(self) -> bool {
252        self.hour < 24 && self.minute < 60 && self.second < 60
253    }
254
255    /// Seconds since midnight.
256    pub const fn seconds(self) -> u32 {
257        self.hour * 3600 + self.minute * 60 + self.second
258    }
259
260    /// The time `seconds` after midnight, wrapping round the day.
261    pub const fn from_seconds(seconds: i64) -> Time {
262        let seconds = seconds.rem_euclid(86_400) as u32;
263        Time::new(seconds / 3600, seconds / 60 % 60, seconds % 60)
264    }
265
266    /// Now, in UTC.
267    pub fn now_utc() -> Time {
268        Time::from_seconds(unix_now())
269    }
270
271    /// Now, in the local zone.
272    pub fn now_local() -> Time {
273        DateTime::now_local().time
274    }
275
276    /// The hour on a 12-hour clock (12, then 1 to 11), and whether it is
277    /// after noon.
278    pub const fn twelve_hour(self) -> (u32, bool) {
279        let pm = self.hour >= 12;
280        let hour = match self.hour % 12 {
281            0 => 12,
282            hour => hour,
283        };
284        (hour, pm)
285    }
286
287    /// The time written with or without its seconds, on a 24-hour clock
288    /// (`14:30`, `14:30:05`) or a 12-hour one (`2:30 PM`, `2:30:05 PM`).
289    pub fn format(self, seconds: bool, twelve_hour: bool) -> String {
290        let tail = if seconds {
291            format!(":{:02}", self.second)
292        } else {
293            String::new()
294        };
295        if twelve_hour {
296            let (hour, pm) = self.twelve_hour();
297            let half = if pm { "PM" } else { "AM" };
298            format!("{hour}:{:02}{tail} {half}", self.minute)
299        } else {
300            format!("{:02}:{:02}{tail}", self.hour, self.minute)
301        }
302    }
303}
304
305impl fmt::Display for Time {
306    /// `14:30`, or `14:30:05` when the seconds are not 0.
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        f.write_str(&self.format(self.second != 0, false))
309    }
310}
311
312impl FromStr for Time {
313    type Err = String;
314
315    /// `HH:MM` or `HH:MM:SS` on a 24-hour clock, or with `am` or `pm` after
316    /// it on a 12-hour one (`2:30 pm`, `2pm`, `12:05AM`), or `now` (in UTC).
317    fn from_str(text: &str) -> Result<Time, String> {
318        let text = text.trim();
319        if text.eq_ignore_ascii_case("now") {
320            return Ok(Time::now_utc());
321        }
322        let bad = || format!("{text:?} is not a time (HH:MM)");
323        let lower = text.to_ascii_lowercase();
324        let (clock, half) = if let Some(rest) = lower.strip_suffix("am") {
325            (rest.trim_end().trim_end_matches('.'), Some(false))
326        } else if let Some(rest) = lower.strip_suffix("pm") {
327            (rest.trim_end().trim_end_matches('.'), Some(true))
328        } else {
329            (lower.as_str(), None)
330        };
331        let parts: Vec<&str> = clock.split(':').collect();
332        // `2pm` has no minutes; a 24-hour time needs them.
333        if parts.len() > 3 || (parts.len() == 1 && half.is_none()) {
334            return Err(bad());
335        }
336        let mut numbers = [0u32; 3];
337        for (slot, part) in numbers.iter_mut().zip(&parts) {
338            if part.is_empty() || part.len() > 2 || !part.bytes().all(|b| b.is_ascii_digit()) {
339                return Err(bad());
340            }
341            *slot = part.parse().map_err(|_| bad())?;
342        }
343        let [mut hour, minute, second] = numbers;
344        if let Some(pm) = half {
345            if !(1..=12).contains(&hour) {
346                return Err(format!("{text:?}: a 12-hour clock goes from 1 to 12"));
347            }
348            hour = hour % 12 + if pm { 12 } else { 0 };
349        }
350        let time = Time::new(hour, minute, second);
351        if !time.is_valid() {
352            return Err(format!("{text:?} is not a time of day"));
353        }
354        Ok(time)
355    }
356}
357
358/// A day and a time of day, without a time zone.
359#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
360pub struct DateTime {
361    pub date: Date,
362    pub time: Time,
363}
364
365impl DateTime {
366    pub const fn new(date: Date, time: Time) -> DateTime {
367        DateTime { date, time }
368    }
369
370    /// Now, in UTC.
371    pub fn now_utc() -> DateTime {
372        let now = unix_now();
373        DateTime::new(
374            Date::from_days(now.div_euclid(86_400)),
375            Time::from_seconds(now),
376        )
377    }
378
379    /// Now, in the local zone, summer time included.
380    ///
381    /// ```
382    /// use rich_interact::datetime::DateTime;
383    ///
384    /// assert!(DateTime::now_local().is_valid());
385    /// ```
386    pub fn now_local() -> DateTime {
387        DateTime::now_in(&Zone::Local)
388    }
389
390    /// Now, on `zone`'s clocks.
391    pub fn now_in(zone: &Zone) -> DateTime {
392        DateTime::from_unix(unix_now(), zone)
393    }
394
395    /// The moment `unix` (seconds since 1970-01-01 00:00 UTC) on `zone`'s
396    /// clocks.
397    ///
398    /// ```
399    /// use rich_interact::datetime::{Date, DateTime, Time, Zone};
400    ///
401    /// let at = DateTime::from_unix(1_792_404_000, &Zone::Fixed(7200));
402    /// assert_eq!(at, DateTime::new(Date::new(2026, 10, 19), Time::new(12, 0, 0)));
403    /// assert_eq!(at.to_unix(&Zone::Fixed(7200)), 1_792_404_000);
404    /// ```
405    pub fn from_unix(unix: i64, zone: &Zone) -> DateTime {
406        let local = unix + zone.offset_at(unix) as i64;
407        DateTime::new(
408            Date::from_days(local.div_euclid(86_400)),
409            Time::from_seconds(local),
410        )
411    }
412
413    /// The moment this is on `zone`'s clocks, in seconds since 1970 UTC.
414    /// A time the clocks pass twice (when summer time ends) is the first
415    /// of the two; one they skip (when it starts) is read on the clock of
416    /// before the change, so 02:30 in a gap from 02:00 to 03:00 is 03:30.
417    pub fn to_unix(self, zone: &Zone) -> i64 {
418        let wall = self.date.days() * 86_400 + self.time.seconds() as i64;
419        // The offsets around: transitions are days apart.
420        let around = [
421            zone.offset_at(wall - 172_800),
422            zone.offset_at(wall),
423            zone.offset_at(wall + 172_800),
424        ];
425        around
426            .iter()
427            .map(|&offset| wall - offset as i64)
428            .filter(|&unix| (unix + zone.offset_at(unix) as i64) == wall)
429            .min()
430            .unwrap_or(wall - around[0] as i64)
431    }
432
433    /// The moment `seconds` later (earlier if negative), on a clock that
434    /// does not change (as UTC's): add to [`to_unix`](Self::to_unix) for
435    /// a zone's.
436    #[must_use]
437    pub fn add_seconds(self, seconds: i64) -> DateTime {
438        DateTime::from_unix(self.to_unix(&Zone::Utc) + seconds, &Zone::Utc)
439    }
440
441    /// Whether both halves are valid.
442    pub const fn is_valid(self) -> bool {
443        self.date.is_valid() && self.time.is_valid()
444    }
445
446    /// Written as `2026-10-17 14:30`, with the time as
447    /// [`Time::format`] writes it.
448    pub fn format(self, seconds: bool, twelve_hour: bool) -> String {
449        format!("{} {}", self.date, self.time.format(seconds, twelve_hour))
450    }
451}
452
453impl fmt::Display for DateTime {
454    /// `2026-10-17 14:30`, or `2026-10-17 14:30:05` when the seconds are
455    /// not 0.
456    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
457        write!(f, "{} {}", self.date, self.time)
458    }
459}
460
461impl FromStr for DateTime {
462    type Err = String;
463
464    /// A [`Date`], then a space or `T`, then a [`Time`]
465    /// (`2026-10-17 14:30`, `2026-10-17T14:30:05`, `2026-10-17 2:30 pm`);
466    /// a date alone is at midnight; `now` is now (in UTC).
467    fn from_str(text: &str) -> Result<DateTime, String> {
468        let text = text.trim();
469        if text.eq_ignore_ascii_case("now") {
470            return Ok(DateTime::now_utc());
471        }
472        // A space, or ISO 8601's `T` after the day's last digit, whichever
473        // comes first: the time may hold a space (`2026-10-17T2:30 pm`).
474        let split = text
475            .char_indices()
476            .skip(1)
477            .find(|&(at, c)| {
478                c == ' ' || (matches!(c, 'T' | 't') && text.as_bytes()[at - 1].is_ascii_digit())
479            })
480            .map(|(at, _)| (&text[..at], &text[at + 1..]));
481        match split {
482            Some((date, time)) => Ok(DateTime::new(date.parse()?, time.parse()?)),
483            None => Ok(DateTime::new(text.parse()?, Time::MIDNIGHT)),
484        }
485    }
486}
487
488impl From<Date> for DateTime {
489    /// The day at midnight.
490    fn from(date: Date) -> DateTime {
491        DateTime::new(date, Time::MIDNIGHT)
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn leap_years_and_month_lengths() {
501        assert!(Date::is_leap_year(2000));
502        assert!(Date::is_leap_year(2024));
503        assert!(!Date::is_leap_year(1900));
504        assert!(!Date::is_leap_year(2026));
505        assert_eq!(Date::days_in_month(2024, 2), 29);
506        assert_eq!(Date::days_in_month(2026, 2), 28);
507        assert_eq!(Date::days_in_month(2026, 4), 30);
508        assert_eq!(Date::days_in_month(2026, 12), 31);
509    }
510
511    #[test]
512    fn weekdays_of_known_dates() {
513        // Monday is 0.
514        assert_eq!(Date::new(2026, 10, 7).weekday(), 2);
515        assert_eq!(Date::new(2000, 2, 29).weekday(), 1);
516        assert_eq!(Date::new(1970, 1, 1).weekday(), 3);
517        assert_eq!(Date::new(1969, 12, 31).weekday(), 2);
518        assert_eq!(Date::new(2026, 10, 1).weekday(), 3);
519    }
520
521    #[test]
522    fn days_round_trip() {
523        assert_eq!(Date::new(1970, 1, 1).days(), 0);
524        assert_eq!(Date::new(2000, 3, 1).days(), 11_017);
525        assert_eq!(Date::new(1969, 12, 31).days(), -1);
526        for days in -800_000..800_000 {
527            if days % 997 == 0 {
528                assert_eq!(Date::from_days(days).days(), days);
529            }
530        }
531        assert_eq!(Date::new(2000, 2, 28).add_days(1), Date::new(2000, 2, 29));
532        assert_eq!(Date::new(2026, 12, 31).add_days(1), Date::new(2027, 1, 1));
533        assert_eq!(Date::new(2026, 3, 1).add_days(-1), Date::new(2026, 2, 28));
534    }
535
536    #[test]
537    fn months_keep_the_day_where_they_can() {
538        assert_eq!(Date::new(2026, 1, 31).add_months(1), Date::new(2026, 2, 28));
539        assert_eq!(Date::new(2024, 1, 31).add_months(1), Date::new(2024, 2, 29));
540        assert_eq!(
541            Date::new(2026, 1, 15).add_months(-1),
542            Date::new(2025, 12, 15)
543        );
544        assert_eq!(
545            Date::new(2026, 10, 7).add_months(14),
546            Date::new(2027, 12, 7)
547        );
548        assert_eq!(Date::new(2026, 10, 7).to_string(), "2026-10-07");
549    }
550
551    #[test]
552    fn dates_parse_and_refuse() {
553        assert_eq!("2026-10-17".parse(), Ok(Date::new(2026, 10, 17)));
554        assert_eq!(" 2026-1-5 ".parse(), Ok(Date::new(2026, 1, 5)));
555        assert_eq!("2024-02-29".parse(), Ok(Date::new(2024, 2, 29)));
556        for bad in [
557            "",
558            "2026",
559            "2026-10",
560            "2026-13-01",
561            "2026-02-29",
562            "2026-10-32",
563            "2026-10-0",
564            "26-10-17x",
565            "2026/10/17",
566            "2026-10-17-1",
567            "+2026-10-17",
568        ] {
569            assert!(bad.parse::<Date>().is_err(), "{bad:?}");
570        }
571        assert!("today".parse::<Date>().unwrap().is_valid());
572    }
573
574    #[test]
575    fn times_parse_on_either_clock() {
576        assert_eq!("14:30".parse(), Ok(Time::new(14, 30, 0)));
577        assert_eq!("9:05:07".parse(), Ok(Time::new(9, 5, 7)));
578        assert_eq!("2:30 pm".parse(), Ok(Time::new(14, 30, 0)));
579        assert_eq!("2PM".parse(), Ok(Time::new(14, 0, 0)));
580        assert_eq!("12:05am".parse(), Ok(Time::new(0, 5, 0)));
581        assert!("12 p.m.".parse::<Time>().is_err());
582        assert_eq!("12pm".parse(), Ok(Time::new(12, 0, 0)));
583        for bad in [
584            "", "14", "24:00", "12:60", "1:2:3:4", "13pm", "0am", "a:b", "-1:00",
585        ] {
586            assert!(bad.parse::<Time>().is_err(), "{bad:?}");
587        }
588        assert_eq!(Time::new(14, 30, 0).to_string(), "14:30");
589        assert_eq!(Time::new(14, 30, 5).to_string(), "14:30:05");
590        assert_eq!(Time::new(0, 5, 0).format(false, true), "12:05 AM");
591        assert_eq!(Time::new(12, 5, 9).format(true, true), "12:05:09 PM");
592        assert_eq!(Time::from_seconds(-1), Time::LAST);
593    }
594
595    #[test]
596    fn date_times_parse_and_order() {
597        let at = DateTime::new(Date::new(2026, 10, 17), Time::new(14, 30, 0));
598        assert_eq!("2026-10-17 14:30".parse(), Ok(at));
599        assert_eq!("2026-10-17T14:30".parse(), Ok(at));
600        assert_eq!("2026-10-17 2:30 pm".parse(), Ok(at));
601        assert_eq!(
602            "2026-10-17".parse(),
603            Ok(DateTime::from(Date::new(2026, 10, 17)))
604        );
605        assert!("2026-10-17 25:00".parse::<DateTime>().is_err());
606        assert!("2026-10-17 x".parse::<DateTime>().is_err());
607        assert!("today 14:30".parse::<DateTime>().is_ok());
608        assert!(at < DateTime::new(Date::new(2026, 10, 17), Time::new(14, 30, 1)));
609        assert!(at > DateTime::new(Date::new(2026, 10, 16), Time::LAST));
610        assert_eq!(at.to_string(), "2026-10-17 14:30");
611        assert!(DateTime::now_utc().is_valid());
612    }
613
614    #[test]
615    fn moments_round_trip_across_a_change_of_summer_time() {
616        let cet = Zone::posix("CET-1CEST,M3.5.0,M10.5.0/3").unwrap();
617        let utc = |y, m, d, h, min| DateTime::new(Date::new(y, m, d), Time::new(h, min, 0));
618        // Summer time starts at 01:00 UTC on 29 March 2026: 02:00 is 03:00.
619        let before = utc(2026, 3, 29, 0, 30).to_unix(&Zone::Utc);
620        let after = utc(2026, 3, 29, 1, 30).to_unix(&Zone::Utc);
621        assert_eq!(DateTime::from_unix(before, &cet), utc(2026, 3, 29, 1, 30));
622        assert_eq!(DateTime::from_unix(after, &cet), utc(2026, 3, 29, 3, 30));
623        assert_eq!(utc(2026, 3, 29, 3, 30).to_unix(&cet), after);
624        // In the gap: on the clock of before, an hour on.
625        assert_eq!(utc(2026, 3, 29, 2, 30).to_unix(&cet), after);
626        // It ends at 01:00 UTC on 25 October: 02:30 comes twice, the
627        // first (summer) one is taken.
628        let first = utc(2026, 10, 25, 0, 30).to_unix(&Zone::Utc);
629        let second = utc(2026, 10, 25, 1, 30).to_unix(&Zone::Utc);
630        assert_eq!(DateTime::from_unix(first, &cet), utc(2026, 10, 25, 2, 30));
631        assert_eq!(DateTime::from_unix(second, &cet), utc(2026, 10, 25, 2, 30));
632        assert_eq!(utc(2026, 10, 25, 2, 30).to_unix(&cet), first);
633        // A day across it is 25 hours.
634        let day = utc(2026, 10, 26, 0, 0).to_unix(&cet) - utc(2026, 10, 25, 0, 0).to_unix(&cet);
635        assert_eq!(day, 25 * 3600);
636    }
637
638    /// The local zone follows summer time: run in a child with `TZ` set,
639    /// as the C library reads it once.
640    #[test]
641    fn the_local_zone_follows_summer_time() {
642        if std::env::var_os("RICH_INTERACT_TZ_CHILD").is_none() {
643            let child = std::process::Command::new(std::env::current_exe().unwrap())
644                .args([
645                    "--exact",
646                    "datetime::tests::the_local_zone_follows_summer_time",
647                    "--quiet",
648                ])
649                .env("RICH_INTERACT_TZ_CHILD", "1")
650                .env("TZ", "CET-1CEST,M3.5.0,M10.5.0/3")
651                .output()
652                .unwrap();
653            let said = String::from_utf8_lossy(&child.stdout);
654            assert!(child.status.success(), "{said}");
655            return;
656        }
657        let utc = |m, d, h| DateTime::new(Date::new(2026, m, d), Time::new(h, 0, 0));
658        let winter = utc(1, 15, 12).to_unix(&Zone::Utc);
659        let summer = utc(7, 15, 12).to_unix(&Zone::Utc);
660        assert_eq!(Zone::Local.offset_at(winter), 3600);
661        assert_eq!(Zone::Local.offset_at(summer), 7200);
662        assert_eq!(DateTime::from_unix(summer, &Zone::Local), utc(7, 15, 14));
663        assert_eq!(
664            utc(10, 25, 2).to_unix(&Zone::Local),
665            utc(10, 25, 0).to_unix(&Zone::Utc)
666        );
667    }
668}