Skip to main content

qframe/
date.rs

1//! Calendar dates in the proleptic Gregorian calendar, times of day, and the two together.
2//!
3//! The arithmetic counts days since 1970-01-01 with Howard Hinnant's civil-calendar
4//! algorithms, which are exact for every date an `i32` year can hold.
5//!
6//! [`Date`] and [`TimeOfDay`] carry no time zone. [`DateTime`] carries the offset from UTC of
7//! the moment it holds, and [`local_offset_minutes`] reads that offset from the system; where the
8//! system does not say, [`local_offset`] returns `None` instead of a made-up zero.
9
10use std::fmt;
11use std::str::FromStr;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14mod zone;
15
16/// Seconds in a day.
17const DAY: i64 = 86_400;
18
19/// A day of the week.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
21pub enum Weekday {
22    /// Monday.
23    Monday,
24    /// Tuesday.
25    Tuesday,
26    /// Wednesday.
27    Wednesday,
28    /// Thursday.
29    Thursday,
30    /// Friday.
31    Friday,
32    /// Saturday.
33    Saturday,
34    /// Sunday.
35    Sunday,
36}
37
38impl Weekday {
39    /// Monday to Sunday.
40    pub const ALL: [Self; 7] =
41        [Self::Monday, Self::Tuesday, Self::Wednesday, Self::Thursday, Self::Friday, Self::Saturday, Self::Sunday];
42
43    /// 1 for Monday up to 7 for Sunday (ISO 8601).
44    #[must_use]
45    pub fn number(self) -> u8 {
46        match self {
47            Self::Monday => 1,
48            Self::Tuesday => 2,
49            Self::Wednesday => 3,
50            Self::Thursday => 4,
51            Self::Friday => 5,
52            Self::Saturday => 6,
53            Self::Sunday => 7,
54        }
55    }
56
57    /// The weekday numbered `number` (1 Monday to 7 Sunday).
58    #[must_use]
59    pub fn from_number(number: u8) -> Option<Self> {
60        Self::ALL.get(usize::from(number).checked_sub(1)?).copied()
61    }
62
63    /// Days from `start` to this weekday going forward, `0..7`.
64    #[must_use]
65    pub fn days_since(self, start: Self) -> u8 {
66        (self.number() + 7 - start.number()) % 7
67    }
68}
69
70/// A calendar date.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
72pub struct Date {
73    year: i32,
74    month: u8,
75    day: u8,
76}
77
78impl Date {
79    /// The date `year`-`month`-`day`, or `None` when no such day exists.
80    #[must_use]
81    pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
82        (1..=12).contains(&month).then_some(())?;
83        (day >= 1 && day <= days_in_month(year, month)).then_some(Self { year, month, day })
84    }
85
86    /// Today in UTC, from the system clock.
87    #[must_use]
88    pub fn today_utc() -> Self {
89        Self::from_days(unix_now().div_euclid(DAY))
90    }
91
92    /// Today where the machine stands, from the system clock and the system time zone. Falls back
93    /// to [`Date::today_utc`] where the zone is unknown; [`local_offset`] tells you which it is.
94    #[must_use]
95    pub fn today_local() -> Self {
96        DateTime::now_local().date
97    }
98
99    /// Reads `YYYY-MM-DD`: four or more digits for the year, then two for the month and the day,
100    /// with a leading `-` for a year before the era. The calendar is checked, so 2026-02-30 is an
101    /// error, and surrounding spaces are ignored.
102    ///
103    /// ```
104    /// use qframe::date::Date;
105    ///
106    /// assert_eq!(Date::parse("2026-09-17"), Ok(Date::new(2026, 9, 17).expect("a real day")));
107    /// assert!(Date::parse("2026-02-30").is_err());
108    /// ```
109    pub fn parse(text: &str) -> Result<Self, String> {
110        let trimmed = text.trim();
111        let (negative, digits) = match trimmed.strip_prefix('-') {
112            Some(rest) => (true, rest),
113            None => (false, trimmed),
114        };
115        let mut parts = digits.split('-');
116        let (Some(year), Some(month), Some(day), None) = (parts.next(), parts.next(), parts.next(), parts.next())
117        else {
118            return Err(format!("`{trimmed}` is no date; a date is written YYYY-MM-DD"));
119        };
120        let number = |part: &str, width: usize, exact: bool, name: &str| -> Result<i32, String> {
121            let length = if exact { part.len() == width } else { part.len() >= width };
122            let digits = length && part.chars().all(|c| c.is_ascii_digit());
123            part.parse::<i32>()
124                .ok()
125                .filter(|_| digits)
126                .ok_or_else(|| format!("`{part}` is no {name}; it takes {width} digits"))
127        };
128        let year = number(year, 4, false, "year")?;
129        let month = number(month, 2, true, "month")?;
130        let day = number(day, 2, true, "day")?;
131        let year = if negative { -year } else { year };
132        let parts = u8::try_from(month).ok().zip(u8::try_from(day).ok());
133        parts
134            .and_then(|(month, day)| Self::new(year, month, day))
135            .ok_or_else(|| format!("{trimmed} is not a day this calendar has"))
136    }
137
138    /// The year.
139    #[must_use]
140    pub fn year(self) -> i32 {
141        self.year
142    }
143
144    /// The month, 1 to 12.
145    #[must_use]
146    pub fn month(self) -> u8 {
147        self.month
148    }
149
150    /// The day of the month, from 1.
151    #[must_use]
152    pub fn day(self) -> u8 {
153        self.day
154    }
155
156    /// Days since 1970-01-01 (negative before).
157    #[must_use]
158    pub fn to_days(self) -> i64 {
159        let year = i64::from(self.year) - i64::from(self.month <= 2);
160        let era = year.div_euclid(400);
161        let year_of_era = year.rem_euclid(400);
162        let month = i64::from(self.month);
163        let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + i64::from(self.day) - 1;
164        let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
165        era * 146_097 + day_of_era - 719_468
166    }
167
168    /// The date `days` after 1970-01-01. Years beyond the range of `i32` are clamped to it.
169    #[must_use]
170    pub fn from_days(days: i64) -> Self {
171        let z = days.saturating_add(719_468);
172        let era = z.div_euclid(146_097);
173        let day_of_era = z.rem_euclid(146_097);
174        let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
175        let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
176        let mp = (5 * day_of_year + 2) / 153;
177        let day = day_of_year - (153 * mp + 2) / 5 + 1;
178        let month = if mp < 10 { mp + 3 } else { mp - 9 };
179        let year = year_of_era + era * 400 + i64::from(month <= 2);
180        Self {
181            year: i32::try_from(year).unwrap_or(if year < 0 { i32::MIN } else { i32::MAX }),
182            month: u8::try_from(month).unwrap_or(1),
183            day: u8::try_from(day).unwrap_or(1),
184        }
185    }
186
187    /// The day of the week.
188    #[must_use]
189    pub fn weekday(self) -> Weekday {
190        // 1970-01-01 was a Thursday.
191        let index = (self.to_days() + 3).rem_euclid(7);
192        Weekday::ALL[usize::try_from(index).unwrap_or(0)]
193    }
194
195    /// The date `days` later (earlier when negative).
196    #[must_use]
197    pub fn add_days(self, days: i64) -> Self {
198        Self::from_days(self.to_days().saturating_add(days))
199    }
200
201    /// The same day `months` later (earlier when negative), moved back to the last day of a
202    /// shorter month: January 31 plus one month is February 28 or 29.
203    #[must_use]
204    pub fn add_months(self, months: i32) -> Self {
205        let index = i64::from(self.year) * 12 + i64::from(self.month) - 1 + i64::from(months);
206        let year = i32::try_from(index.div_euclid(12)).unwrap_or(self.year);
207        let month = u8::try_from(index.rem_euclid(12) + 1).unwrap_or(1);
208        let day = self.day.min(days_in_month(year, month));
209        Self { year, month, day }
210    }
211
212    /// The first day of this date's month.
213    #[must_use]
214    pub fn first_of_month(self) -> Self {
215        Self { day: 1, ..self }
216    }
217
218    /// The date the week containing this date starts on, for weeks starting on `start`.
219    #[must_use]
220    pub fn start_of_week(self, start: Weekday) -> Self {
221        self.add_days(-i64::from(self.weekday().days_since(start)))
222    }
223}
224
225impl fmt::Display for Date {
226    /// Writes `YYYY-MM-DD`, the form [`Date::parse`] reads. A year before the era keeps its sign
227    /// in front of four digits, and a year of five digits or more is written in full.
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        let (sign, year) = if self.year < 0 { ("-", self.year.unsigned_abs()) } else { ("", self.year.unsigned_abs()) };
230        write!(f, "{sign}{year:04}-{:02}-{:02}", self.month, self.day)
231    }
232}
233
234impl FromStr for Date {
235    type Err = String;
236
237    /// The same as [`Date::parse`].
238    fn from_str(text: &str) -> Result<Self, Self::Err> {
239        Self::parse(text)
240    }
241}
242
243/// A time of day on a 24-hour clock, with no date and no time zone.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
245pub struct TimeOfDay {
246    /// Hour, 0 to 23.
247    pub hour: u8,
248    /// Minute, 0 to 59.
249    pub minute: u8,
250    /// Second, 0 to 59.
251    pub second: u8,
252}
253
254impl TimeOfDay {
255    /// The last moment of a day, and so the largest value of each part.
256    pub const LARGEST: Self = Self { hour: 23, minute: 59, second: 59 };
257
258    /// The time `hour:minute:second`; each part is capped at its largest value.
259    #[must_use]
260    pub fn new(hour: u8, minute: u8, second: u8) -> Self {
261        Self {
262            hour: hour.min(Self::LARGEST.hour),
263            minute: minute.min(Self::LARGEST.minute),
264            second: second.min(Self::LARGEST.second),
265        }
266    }
267
268    /// Reads `h:mm` or `h:mm:ss` with every part in range; surrounding spaces are ignored and a
269    /// missing second is zero. Out-of-range parts such as `24:00` are `None`, not capped.
270    #[must_use]
271    pub fn parse(text: &str) -> Option<Self> {
272        let parts: Vec<&str> = text.trim().split(':').collect();
273        if !(2..=3).contains(&parts.len()) {
274            return None;
275        }
276        let largest = [Self::LARGEST.hour, Self::LARGEST.minute, Self::LARGEST.second];
277        let mut values = [0u8; 3];
278        for (index, part) in parts.iter().enumerate() {
279            let valid = (1..=2).contains(&part.len()) && part.chars().all(|c| c.is_ascii_digit());
280            values[index] = part.parse().ok().filter(|value| valid && *value <= largest[index])?;
281        }
282        Some(Self { hour: values[0], minute: values[1], second: values[2] })
283    }
284
285    /// Seconds from midnight, 0 to 86 399.
286    #[must_use]
287    pub fn seconds_since_midnight(self) -> u32 {
288        u32::from(self.hour) * 3_600 + u32::from(self.minute) * 60 + u32::from(self.second)
289    }
290
291    /// The time `seconds` after midnight; a value past the end of a day wraps into the next one.
292    #[must_use]
293    pub fn from_seconds_since_midnight(seconds: u32) -> Self {
294        let seconds = seconds % 86_400;
295        Self {
296            hour: u8::try_from(seconds / 3_600).unwrap_or(0),
297            minute: u8::try_from(seconds / 60 % 60).unwrap_or(0),
298            second: u8::try_from(seconds % 60).unwrap_or(0),
299        }
300    }
301}
302
303impl fmt::Display for TimeOfDay {
304    /// Writes `hh:mm:ss`, the same in every language.
305    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306        write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
307    }
308}
309
310/// A date and a time of day together, with the offset from UTC the moment was read at.
311///
312/// The date and the time are local: they are what a clock on the wall shows. `offset_minutes`
313/// is what turns them back into an instant, which is what [`DateTime::to_unix`] does.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
315pub struct DateTime {
316    /// The local date.
317    pub date: Date,
318    /// The local time of day.
319    pub time: TimeOfDay,
320    /// Minutes local time is ahead of UTC; negative west of it.
321    pub offset_minutes: i16,
322}
323
324impl DateTime {
325    /// Now where the machine stands, from the system clock and [`local_offset_minutes`].
326    #[must_use]
327    pub fn now_local() -> Self {
328        Self::from_unix(unix_now(), local_offset_minutes())
329    }
330
331    /// The moment `seconds` after 1970-01-01 00:00 UTC, written in an offset of
332    /// `offset_minutes`.
333    #[must_use]
334    pub fn from_unix(seconds: i64, offset_minutes: i16) -> Self {
335        let local = seconds.saturating_add(i64::from(offset_minutes) * 60);
336        let day_seconds = u32::try_from(local.rem_euclid(DAY)).unwrap_or(0);
337        Self {
338            date: Date::from_days(local.div_euclid(DAY)),
339            time: TimeOfDay::from_seconds_since_midnight(day_seconds),
340            offset_minutes,
341        }
342    }
343
344    /// Seconds since 1970-01-01 00:00 UTC.
345    ///
346    /// ```
347    /// use qframe::date::DateTime;
348    ///
349    /// // Noon in Istanbul is 09:00 UTC.
350    /// let noon = DateTime::from_unix(1_773_997_200, 180);
351    /// assert_eq!(noon.time.to_string(), "12:00:00");
352    /// assert_eq!(noon.to_unix(), 1_773_997_200);
353    /// ```
354    #[must_use]
355    pub fn to_unix(&self) -> i64 {
356        self.date
357            .to_days()
358            .saturating_mul(DAY)
359            .saturating_add(i64::from(self.time.seconds_since_midnight()))
360            .saturating_sub(i64::from(self.offset_minutes) * 60)
361    }
362}
363
364/// Minutes local time is ahead of UTC right now, or `None` when the system does not say.
365///
366/// The offset is read from the system time zone file — what `TZ` names, or `/etc/localtime` —
367/// once per process. `None` means there was no such file or it could not be understood: Windows,
368/// a system with no zone database, or a `TZ` holding a POSIX rule rather than a zone name. An
369/// application that wants to be exact can tell its user the local time is unknown instead of
370/// showing UTC as if it were local.
371#[must_use]
372pub fn local_offset() -> Option<i16> {
373    zone::offset_minutes(unix_now())
374}
375
376/// Minutes local time is ahead of UTC right now, and 0 when the system does not say.
377///
378/// Use [`local_offset`] where the difference between "UTC" and "unknown" matters.
379#[must_use]
380pub fn local_offset_minutes() -> i16 {
381    local_offset().unwrap_or(0)
382}
383
384/// Seconds since 1970-01-01 00:00 UTC, from the system clock; negative before it.
385fn unix_now() -> i64 {
386    match SystemTime::now().duration_since(UNIX_EPOCH) {
387        Ok(since) => i64::try_from(since.as_secs()).unwrap_or(i64::MAX),
388        Err(before) => i64::try_from(before.duration().as_secs()).unwrap_or(i64::MAX).saturating_neg(),
389    }
390}
391
392/// Whether `year` has a February 29.
393#[must_use]
394pub fn is_leap_year(year: i32) -> bool {
395    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
396}
397
398/// How many days `month` of `year` has; 0 for a month outside 1 to 12.
399#[must_use]
400pub fn days_in_month(year: i32, month: u8) -> u8 {
401    match month {
402        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
403        4 | 6 | 9 | 11 => 30,
404        2 if is_leap_year(year) => 29,
405        2 => 28,
406        _ => 0,
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    fn date(year: i32, month: u8, day: u8) -> Date {
415        Date::new(year, month, day).expect("valid date")
416    }
417
418    #[test]
419    fn validates_days_and_leap_years() {
420        assert!(Date::new(2024, 2, 29).is_some());
421        assert!(Date::new(2026, 2, 29).is_none());
422        assert!(Date::new(1900, 2, 29).is_none());
423        assert!(Date::new(2000, 2, 29).is_some());
424        assert!(Date::new(2026, 13, 1).is_none());
425        assert!(Date::new(2026, 4, 31).is_none());
426        assert!(Date::new(2026, 4, 0).is_none());
427    }
428
429    #[test]
430    fn counts_days_from_the_epoch_both_ways() {
431        assert_eq!(date(1970, 1, 1).to_days(), 0);
432        assert_eq!(date(2000, 3, 1).to_days(), 11_017);
433        assert_eq!(date(1969, 12, 31).to_days(), -1);
434        assert_eq!(date(2026, 9, 16).to_days(), 20_712);
435        for days in (-800_000..800_000).step_by(997) {
436            assert_eq!(Date::from_days(days).to_days(), days);
437        }
438        assert_eq!(Date::from_days(-719_468), date(0, 3, 1));
439    }
440
441    #[test]
442    fn knows_weekdays() {
443        assert_eq!(date(1970, 1, 1).weekday(), Weekday::Thursday);
444        assert_eq!(date(2026, 9, 16).weekday(), Weekday::Wednesday);
445        assert_eq!(date(2000, 1, 1).weekday(), Weekday::Saturday);
446        assert_eq!(date(1900, 1, 1).weekday(), Weekday::Monday);
447        assert_eq!(Weekday::Sunday.days_since(Weekday::Monday), 6);
448        assert_eq!(Weekday::Monday.days_since(Weekday::Sunday), 1);
449        assert_eq!(Weekday::from_number(7), Some(Weekday::Sunday));
450        assert_eq!(Weekday::from_number(0), None);
451    }
452
453    #[test]
454    fn adds_days_and_months() {
455        assert_eq!(date(2026, 12, 31).add_days(1), date(2027, 1, 1));
456        assert_eq!(date(2024, 3, 1).add_days(-1), date(2024, 2, 29));
457        assert_eq!(date(2026, 1, 31).add_months(1), date(2026, 2, 28));
458        assert_eq!(date(2024, 1, 31).add_months(1), date(2024, 2, 29));
459        assert_eq!(date(2026, 1, 15).add_months(-1), date(2025, 12, 15));
460        assert_eq!(date(2026, 3, 31).add_months(-13), date(2025, 2, 28));
461        assert_eq!(date(2026, 9, 16).add_months(24), date(2028, 9, 16));
462    }
463
464    #[test]
465    fn extreme_day_counts_do_not_overflow() {
466        assert_eq!(Date::from_days(i64::MAX).year(), i32::MAX);
467        assert_eq!(Date::from_days(i64::MIN).year(), i32::MIN);
468        assert_eq!(date(2026, 9, 16).add_days(i64::MAX).year(), i32::MAX);
469    }
470
471    #[test]
472    fn finds_week_starts() {
473        let wednesday = date(2026, 9, 16);
474        assert_eq!(wednesday.start_of_week(Weekday::Monday), date(2026, 9, 14));
475        assert_eq!(wednesday.start_of_week(Weekday::Sunday), date(2026, 9, 13));
476        assert_eq!(date(2026, 9, 13).start_of_week(Weekday::Sunday), date(2026, 9, 13));
477        assert_eq!(wednesday.first_of_month(), date(2026, 9, 1));
478    }
479
480    #[test]
481    fn today_is_a_real_date() {
482        let today = Date::today_utc();
483        assert!(today.year() >= 2024 && Date::new(today.year(), today.month(), today.day()).is_some());
484    }
485
486    #[test]
487    fn reads_and_writes_iso_dates() {
488        assert_eq!(Date::parse("2026-09-17"), Ok(date(2026, 9, 17)));
489        assert_eq!("2026-09-17".parse::<Date>(), Ok(date(2026, 9, 17)));
490        assert_eq!(Date::parse("  2026-09-17\n"), Ok(date(2026, 9, 17)), "spaces around it are ignored");
491        assert_eq!(date(2026, 9, 17).to_string(), "2026-09-17");
492        assert_eq!(date(999, 1, 2).to_string(), "0999-01-02");
493        assert_eq!(Date::parse("-0044-03-15"), Ok(date(-44, 3, 15)));
494        assert_eq!(date(-44, 3, 15).to_string(), "-0044-03-15");
495        for days in (-400_000..400_000).step_by(499) {
496            let value = Date::from_days(days);
497            assert_eq!(Date::parse(&value.to_string()), Ok(value), "{value}");
498        }
499    }
500
501    #[test]
502    fn iso_dates_that_are_no_date_are_refused() {
503        for text in [
504            "",
505            "2026",
506            "2026-09",
507            "2026-09-17-01",
508            "2026/09/17",
509            "20260917",
510            "2026-9-17",
511            "2026-09-7",
512            "2026-09-017",
513            "26-09-17",
514            "2026-aa-17",
515            "2026-09-1x",
516            "2026-13-40",
517            "2026-02-30",
518            "2026-04-31",
519            "2026-00-10",
520            "2026-09-00",
521            "1900-02-29",
522            "+2026-09-17",
523        ] {
524            assert!(Date::parse(text).is_err(), "`{text}` was read as a date");
525        }
526        // Leap years are the calendar's, not a guess.
527        assert_eq!(Date::parse("2024-02-29"), Ok(date(2024, 2, 29)));
528        assert_eq!(Date::parse("2000-02-29"), Ok(date(2000, 2, 29)));
529        assert!(Date::parse("2100-02-29").is_err());
530        // The message names what was wrong.
531        assert!(Date::parse("2026-02-30").is_err_and(|error| error.contains("not a day this calendar has")));
532        assert!(Date::parse("2026-9-17").is_err_and(|error| error.contains("2 digits")));
533    }
534
535    #[test]
536    fn times_of_day_are_capped_read_and_written() {
537        assert_eq!(TimeOfDay::new(25, 70, 90), TimeOfDay::LARGEST);
538        assert_eq!(TimeOfDay::new(9, 30, 0).to_string(), "09:30:00");
539        assert_eq!(TimeOfDay::parse("9:30"), Some(TimeOfDay::new(9, 30, 0)));
540        assert_eq!(TimeOfDay::parse(" 09:30:15 "), Some(TimeOfDay::new(9, 30, 15)));
541        assert_eq!(TimeOfDay::parse("24:00"), None, "out of range is refused, not capped");
542        assert_eq!(TimeOfDay::parse("9"), None);
543        assert_eq!(TimeOfDay::parse("9:30:15:20"), None);
544        assert_eq!(TimeOfDay::parse("nine:thirty"), None);
545        assert_eq!(TimeOfDay::default(), TimeOfDay::new(0, 0, 0));
546        assert!(TimeOfDay::new(9, 30, 0) < TimeOfDay::new(9, 30, 1), "times compare in clock order");
547    }
548
549    #[test]
550    fn seconds_since_midnight_go_both_ways() {
551        assert_eq!(TimeOfDay::new(0, 0, 0).seconds_since_midnight(), 0);
552        assert_eq!(TimeOfDay::LARGEST.seconds_since_midnight(), 86_399);
553        assert_eq!(TimeOfDay::new(9, 30, 15).seconds_since_midnight(), 34_215);
554        for seconds in (0..86_400).step_by(37) {
555            assert_eq!(TimeOfDay::from_seconds_since_midnight(seconds).seconds_since_midnight(), seconds);
556        }
557        assert_eq!(TimeOfDay::from_seconds_since_midnight(86_400), TimeOfDay::new(0, 0, 0), "a day wraps");
558    }
559
560    #[test]
561    fn a_moment_and_its_unix_second_agree() {
562        let epoch = DateTime::from_unix(0, 0);
563        assert_eq!(epoch.date, date(1970, 1, 1));
564        assert_eq!(epoch.time, TimeOfDay::new(0, 0, 0));
565        assert_eq!(epoch.to_unix(), 0);
566
567        // The same instant in Istanbul and in Los Angeles.
568        let istanbul = DateTime::from_unix(1_773_997_200, 180);
569        assert_eq!((istanbul.date.to_string(), istanbul.time.to_string()), ("2026-03-20".into(), "12:00:00".into()));
570        let angeles = DateTime::from_unix(1_773_997_200, -420);
571        assert_eq!((angeles.date.to_string(), angeles.time.to_string()), ("2026-03-20".into(), "02:00:00".into()));
572        assert_eq!(istanbul.to_unix(), angeles.to_unix());
573
574        // An offset can push the local day over either end of the UTC day.
575        let before = DateTime::from_unix(0, -180);
576        assert_eq!((before.date.to_string(), before.time.to_string()), ("1969-12-31".into(), "21:00:00".into()));
577        assert_eq!(before.to_unix(), 0);
578        let after = DateTime::from_unix(-1, 120);
579        assert_eq!((after.date.to_string(), after.time.to_string()), ("1970-01-01".into(), "01:59:59".into()));
580        assert_eq!(after.to_unix(), -1);
581
582        for seconds in (-2_000_000_000..2_000_000_000).step_by(1_000_003) {
583            for offset in [-720, -270, 0, 180, 345, 840] {
584                assert_eq!(DateTime::from_unix(seconds, offset).to_unix(), seconds, "{seconds} at {offset}");
585            }
586        }
587    }
588
589    #[test]
590    fn extreme_unix_seconds_do_not_overflow() {
591        for seconds in [i64::MIN, i64::MIN + 1, i64::MAX - 1, i64::MAX] {
592            for offset in [i16::MIN, -1, 0, 1, i16::MAX] {
593                let moment = DateTime::from_unix(seconds, offset);
594                assert!(Date::new(moment.date.year(), moment.date.month(), moment.date.day()).is_some());
595                let _ = moment.to_unix();
596            }
597        }
598    }
599
600    #[test]
601    fn the_local_offset_is_either_known_or_said_to_be_unknown() {
602        match local_offset() {
603            Some(minutes) => {
604                assert_eq!(local_offset_minutes(), minutes);
605                assert!((-720..=840).contains(&minutes), "{minutes} minutes is no time zone offset");
606            }
607            None => assert_eq!(local_offset_minutes(), 0, "an unknown offset counts as UTC"),
608        }
609    }
610
611    #[test]
612    fn local_today_and_now_agree_with_utc_within_the_offset() {
613        let now = DateTime::now_local();
614        assert_eq!(now.offset_minutes, local_offset_minutes());
615        assert_eq!(Date::today_local(), now.date);
616        let difference = Date::today_local().to_days() - Date::today_utc().to_days();
617        assert!((-1..=1).contains(&difference), "the local day is at most a day from the UTC one");
618        assert!(now.date.year() >= 2024, "{}", now.date);
619        // The offset is exactly what turns the local reading back into the UTC one.
620        let utc = DateTime::from_unix(now.to_unix(), 0);
621        let minutes = (now.date.to_days() - utc.date.to_days()) * 1_440
622            + i64::from(now.time.seconds_since_midnight()) / 60
623            - i64::from(utc.time.seconds_since_midnight()) / 60;
624        assert_eq!(minutes, i64::from(now.offset_minutes));
625    }
626}