1use std::fmt;
11use std::str::FromStr;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14mod zone;
15
16const DAY: i64 = 86_400;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
21pub enum Weekday {
22 Monday,
24 Tuesday,
26 Wednesday,
28 Thursday,
30 Friday,
32 Saturday,
34 Sunday,
36}
37
38impl Weekday {
39 pub const ALL: [Self; 7] =
41 [Self::Monday, Self::Tuesday, Self::Wednesday, Self::Thursday, Self::Friday, Self::Saturday, Self::Sunday];
42
43 #[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 #[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 #[must_use]
65 pub fn days_since(self, start: Self) -> u8 {
66 (self.number() + 7 - start.number()) % 7
67 }
68}
69
70#[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 #[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 #[must_use]
88 pub fn today_utc() -> Self {
89 Self::from_days(unix_now().div_euclid(DAY))
90 }
91
92 #[must_use]
95 pub fn today_local() -> Self {
96 DateTime::now_local().date
97 }
98
99 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 #[must_use]
140 pub fn year(self) -> i32 {
141 self.year
142 }
143
144 #[must_use]
146 pub fn month(self) -> u8 {
147 self.month
148 }
149
150 #[must_use]
152 pub fn day(self) -> u8 {
153 self.day
154 }
155
156 #[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 #[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 #[must_use]
189 pub fn weekday(self) -> Weekday {
190 let index = (self.to_days() + 3).rem_euclid(7);
192 Weekday::ALL[usize::try_from(index).unwrap_or(0)]
193 }
194
195 #[must_use]
197 pub fn add_days(self, days: i64) -> Self {
198 Self::from_days(self.to_days().saturating_add(days))
199 }
200
201 #[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 #[must_use]
214 pub fn first_of_month(self) -> Self {
215 Self { day: 1, ..self }
216 }
217
218 #[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 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 fn from_str(text: &str) -> Result<Self, Self::Err> {
239 Self::parse(text)
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
245pub struct TimeOfDay {
246 pub hour: u8,
248 pub minute: u8,
250 pub second: u8,
252}
253
254impl TimeOfDay {
255 pub const LARGEST: Self = Self { hour: 23, minute: 59, second: 59 };
257
258 #[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 #[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 #[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 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
315pub struct DateTime {
316 pub date: Date,
318 pub time: TimeOfDay,
320 pub offset_minutes: i16,
322}
323
324impl DateTime {
325 #[must_use]
327 pub fn now_local() -> Self {
328 Self::from_unix(unix_now(), local_offset_minutes())
329 }
330
331 #[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 #[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#[must_use]
372pub fn local_offset() -> Option<i16> {
373 zone::offset_minutes(unix_now())
374}
375
376#[must_use]
380pub fn local_offset_minutes() -> i16 {
381 local_offset().unwrap_or(0)
382}
383
384fn 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#[must_use]
394pub fn is_leap_year(year: i32) -> bool {
395 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
396}
397
398#[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 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 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 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 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 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}