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 #[must_use]
234 pub fn written(self) -> String {
235 crate::t!("quvyta.date.format", day = u32::from(self.day), month = self.month_in_date(), year = self.year)
236 }
237
238 #[must_use]
241 pub fn written_short(self) -> String {
242 crate::t!("quvyta.date.format-short", day = u32::from(self.day), month = self.month_short(), year = self.year)
243 }
244
245 #[must_use]
248 pub fn day_and_month(self) -> String {
249 crate::t!("quvyta.date.format-day-month-long", day = u32::from(self.day), month = self.month_in_date())
250 }
251
252 #[must_use]
255 pub fn day_and_month_short(self) -> String {
256 crate::t!("quvyta.date.format-day-month", day = u32::from(self.day), month = self.month_short())
257 }
258
259 fn month_in_date(self) -> String {
261 crate::i18n::translate_active_if_known(&format!("quvyta.date.month-in-date-{}", self.month))
262 .unwrap_or_else(|| crate::t!(&format!("quvyta.date.month-{}", self.month)))
263 }
264
265 fn month_short(self) -> String {
267 crate::t!(&format!("quvyta.date.month-short-{}", self.month))
268 }
269}
270
271impl fmt::Display for Date {
272 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275 let (sign, year) = if self.year < 0 { ("-", self.year.unsigned_abs()) } else { ("", self.year.unsigned_abs()) };
276 write!(f, "{sign}{year:04}-{:02}-{:02}", self.month, self.day)
277 }
278}
279
280impl FromStr for Date {
281 type Err = String;
282
283 fn from_str(text: &str) -> Result<Self, Self::Err> {
285 Self::parse(text)
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
291pub struct TimeOfDay {
292 pub hour: u8,
294 pub minute: u8,
296 pub second: u8,
298}
299
300impl TimeOfDay {
301 pub const LARGEST: Self = Self { hour: 23, minute: 59, second: 59 };
303
304 #[must_use]
306 pub fn new(hour: u8, minute: u8, second: u8) -> Self {
307 Self {
308 hour: hour.min(Self::LARGEST.hour),
309 minute: minute.min(Self::LARGEST.minute),
310 second: second.min(Self::LARGEST.second),
311 }
312 }
313
314 #[must_use]
317 pub fn parse(text: &str) -> Option<Self> {
318 let parts: Vec<&str> = text.trim().split(':').collect();
319 if !(2..=3).contains(&parts.len()) {
320 return None;
321 }
322 let largest = [Self::LARGEST.hour, Self::LARGEST.minute, Self::LARGEST.second];
323 let mut values = [0u8; 3];
324 for (index, part) in parts.iter().enumerate() {
325 let valid = (1..=2).contains(&part.len()) && part.chars().all(|c| c.is_ascii_digit());
326 values[index] = part.parse().ok().filter(|value| valid && *value <= largest[index])?;
327 }
328 Some(Self { hour: values[0], minute: values[1], second: values[2] })
329 }
330
331 #[must_use]
333 pub fn seconds_since_midnight(self) -> u32 {
334 u32::from(self.hour) * 3_600 + u32::from(self.minute) * 60 + u32::from(self.second)
335 }
336
337 #[must_use]
339 pub fn from_seconds_since_midnight(seconds: u32) -> Self {
340 let seconds = seconds % 86_400;
341 Self {
342 hour: u8::try_from(seconds / 3_600).unwrap_or(0),
343 minute: u8::try_from(seconds / 60 % 60).unwrap_or(0),
344 second: u8::try_from(seconds % 60).unwrap_or(0),
345 }
346 }
347}
348
349impl fmt::Display for TimeOfDay {
350 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
352 write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
353 }
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
361pub struct DateTime {
362 pub date: Date,
364 pub time: TimeOfDay,
366 pub offset_minutes: i16,
368}
369
370impl DateTime {
371 #[must_use]
373 pub fn now_local() -> Self {
374 Self::from_unix(unix_now(), local_offset_minutes())
375 }
376
377 #[must_use]
380 pub fn from_unix(seconds: i64, offset_minutes: i16) -> Self {
381 let local = seconds.saturating_add(i64::from(offset_minutes) * 60);
382 let day_seconds = u32::try_from(local.rem_euclid(DAY)).unwrap_or(0);
383 Self {
384 date: Date::from_days(local.div_euclid(DAY)),
385 time: TimeOfDay::from_seconds_since_midnight(day_seconds),
386 offset_minutes,
387 }
388 }
389
390 #[must_use]
401 pub fn to_unix(&self) -> i64 {
402 self.date
403 .to_days()
404 .saturating_mul(DAY)
405 .saturating_add(i64::from(self.time.seconds_since_midnight()))
406 .saturating_sub(i64::from(self.offset_minutes) * 60)
407 }
408}
409
410#[must_use]
418pub fn local_offset() -> Option<i16> {
419 zone::offset_minutes(unix_now())
420}
421
422#[must_use]
426pub fn local_offset_minutes() -> i16 {
427 local_offset().unwrap_or(0)
428}
429
430fn unix_now() -> i64 {
432 match SystemTime::now().duration_since(UNIX_EPOCH) {
433 Ok(since) => i64::try_from(since.as_secs()).unwrap_or(i64::MAX),
434 Err(before) => i64::try_from(before.duration().as_secs()).unwrap_or(i64::MAX).saturating_neg(),
435 }
436}
437
438#[must_use]
440pub fn is_leap_year(year: i32) -> bool {
441 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
442}
443
444#[must_use]
446pub fn days_in_month(year: i32, month: u8) -> u8 {
447 match month {
448 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
449 4 | 6 | 9 | 11 => 30,
450 2 if is_leap_year(year) => 29,
451 2 => 28,
452 _ => 0,
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn date(year: i32, month: u8, day: u8) -> Date {
461 Date::new(year, month, day).expect("valid date")
462 }
463
464 #[test]
465 fn validates_days_and_leap_years() {
466 assert!(Date::new(2024, 2, 29).is_some());
467 assert!(Date::new(2026, 2, 29).is_none());
468 assert!(Date::new(1900, 2, 29).is_none());
469 assert!(Date::new(2000, 2, 29).is_some());
470 assert!(Date::new(2026, 13, 1).is_none());
471 assert!(Date::new(2026, 4, 31).is_none());
472 assert!(Date::new(2026, 4, 0).is_none());
473 }
474
475 #[test]
476 fn counts_days_from_the_epoch_both_ways() {
477 assert_eq!(date(1970, 1, 1).to_days(), 0);
478 assert_eq!(date(2000, 3, 1).to_days(), 11_017);
479 assert_eq!(date(1969, 12, 31).to_days(), -1);
480 assert_eq!(date(2026, 9, 16).to_days(), 20_712);
481 for days in (-800_000..800_000).step_by(997) {
482 assert_eq!(Date::from_days(days).to_days(), days);
483 }
484 assert_eq!(Date::from_days(-719_468), date(0, 3, 1));
485 }
486
487 #[test]
488 fn knows_weekdays() {
489 assert_eq!(date(1970, 1, 1).weekday(), Weekday::Thursday);
490 assert_eq!(date(2026, 9, 16).weekday(), Weekday::Wednesday);
491 assert_eq!(date(2000, 1, 1).weekday(), Weekday::Saturday);
492 assert_eq!(date(1900, 1, 1).weekday(), Weekday::Monday);
493 assert_eq!(Weekday::Sunday.days_since(Weekday::Monday), 6);
494 assert_eq!(Weekday::Monday.days_since(Weekday::Sunday), 1);
495 assert_eq!(Weekday::from_number(7), Some(Weekday::Sunday));
496 assert_eq!(Weekday::from_number(0), None);
497 }
498
499 #[test]
500 fn adds_days_and_months() {
501 assert_eq!(date(2026, 12, 31).add_days(1), date(2027, 1, 1));
502 assert_eq!(date(2024, 3, 1).add_days(-1), date(2024, 2, 29));
503 assert_eq!(date(2026, 1, 31).add_months(1), date(2026, 2, 28));
504 assert_eq!(date(2024, 1, 31).add_months(1), date(2024, 2, 29));
505 assert_eq!(date(2026, 1, 15).add_months(-1), date(2025, 12, 15));
506 assert_eq!(date(2026, 3, 31).add_months(-13), date(2025, 2, 28));
507 assert_eq!(date(2026, 9, 16).add_months(24), date(2028, 9, 16));
508 }
509
510 #[test]
511 fn extreme_day_counts_do_not_overflow() {
512 assert_eq!(Date::from_days(i64::MAX).year(), i32::MAX);
513 assert_eq!(Date::from_days(i64::MIN).year(), i32::MIN);
514 assert_eq!(date(2026, 9, 16).add_days(i64::MAX).year(), i32::MAX);
515 }
516
517 #[test]
518 fn finds_week_starts() {
519 let wednesday = date(2026, 9, 16);
520 assert_eq!(wednesday.start_of_week(Weekday::Monday), date(2026, 9, 14));
521 assert_eq!(wednesday.start_of_week(Weekday::Sunday), date(2026, 9, 13));
522 assert_eq!(date(2026, 9, 13).start_of_week(Weekday::Sunday), date(2026, 9, 13));
523 assert_eq!(wednesday.first_of_month(), date(2026, 9, 1));
524 }
525
526 #[test]
527 fn today_is_a_real_date() {
528 let today = Date::today_utc();
529 assert!(today.year() >= 2024 && Date::new(today.year(), today.month(), today.day()).is_some());
530 }
531
532 #[test]
533 fn reads_and_writes_iso_dates() {
534 assert_eq!(Date::parse("2026-09-17"), Ok(date(2026, 9, 17)));
535 assert_eq!("2026-09-17".parse::<Date>(), Ok(date(2026, 9, 17)));
536 assert_eq!(Date::parse(" 2026-09-17\n"), Ok(date(2026, 9, 17)), "spaces around it are ignored");
537 assert_eq!(date(2026, 9, 17).to_string(), "2026-09-17");
538 assert_eq!(date(999, 1, 2).to_string(), "0999-01-02");
539 assert_eq!(Date::parse("-0044-03-15"), Ok(date(-44, 3, 15)));
540 assert_eq!(date(-44, 3, 15).to_string(), "-0044-03-15");
541 for days in (-400_000..400_000).step_by(499) {
542 let value = Date::from_days(days);
543 assert_eq!(Date::parse(&value.to_string()), Ok(value), "{value}");
544 }
545 }
546
547 #[test]
548 fn iso_dates_that_are_no_date_are_refused() {
549 for text in [
550 "",
551 "2026",
552 "2026-09",
553 "2026-09-17-01",
554 "2026/09/17",
555 "20260917",
556 "2026-9-17",
557 "2026-09-7",
558 "2026-09-017",
559 "26-09-17",
560 "2026-aa-17",
561 "2026-09-1x",
562 "2026-13-40",
563 "2026-02-30",
564 "2026-04-31",
565 "2026-00-10",
566 "2026-09-00",
567 "1900-02-29",
568 "+2026-09-17",
569 ] {
570 assert!(Date::parse(text).is_err(), "`{text}` was read as a date");
571 }
572 assert_eq!(Date::parse("2024-02-29"), Ok(date(2024, 2, 29)));
574 assert_eq!(Date::parse("2000-02-29"), Ok(date(2000, 2, 29)));
575 assert!(Date::parse("2100-02-29").is_err());
576 assert!(Date::parse("2026-02-30").is_err_and(|error| error.contains("not a day this calendar has")));
578 assert!(Date::parse("2026-9-17").is_err_and(|error| error.contains("2 digits")));
579 }
580
581 #[test]
582 fn times_of_day_are_capped_read_and_written() {
583 assert_eq!(TimeOfDay::new(25, 70, 90), TimeOfDay::LARGEST);
584 assert_eq!(TimeOfDay::new(9, 30, 0).to_string(), "09:30:00");
585 assert_eq!(TimeOfDay::parse("9:30"), Some(TimeOfDay::new(9, 30, 0)));
586 assert_eq!(TimeOfDay::parse(" 09:30:15 "), Some(TimeOfDay::new(9, 30, 15)));
587 assert_eq!(TimeOfDay::parse("24:00"), None, "out of range is refused, not capped");
588 assert_eq!(TimeOfDay::parse("9"), None);
589 assert_eq!(TimeOfDay::parse("9:30:15:20"), None);
590 assert_eq!(TimeOfDay::parse("nine:thirty"), None);
591 assert_eq!(TimeOfDay::default(), TimeOfDay::new(0, 0, 0));
592 assert!(TimeOfDay::new(9, 30, 0) < TimeOfDay::new(9, 30, 1), "times compare in clock order");
593 }
594
595 #[test]
596 fn seconds_since_midnight_go_both_ways() {
597 assert_eq!(TimeOfDay::new(0, 0, 0).seconds_since_midnight(), 0);
598 assert_eq!(TimeOfDay::LARGEST.seconds_since_midnight(), 86_399);
599 assert_eq!(TimeOfDay::new(9, 30, 15).seconds_since_midnight(), 34_215);
600 for seconds in (0..86_400).step_by(37) {
601 assert_eq!(TimeOfDay::from_seconds_since_midnight(seconds).seconds_since_midnight(), seconds);
602 }
603 assert_eq!(TimeOfDay::from_seconds_since_midnight(86_400), TimeOfDay::new(0, 0, 0), "a day wraps");
604 }
605
606 #[test]
607 fn a_moment_and_its_unix_second_agree() {
608 let epoch = DateTime::from_unix(0, 0);
609 assert_eq!(epoch.date, date(1970, 1, 1));
610 assert_eq!(epoch.time, TimeOfDay::new(0, 0, 0));
611 assert_eq!(epoch.to_unix(), 0);
612
613 let istanbul = DateTime::from_unix(1_773_997_200, 180);
615 assert_eq!((istanbul.date.to_string(), istanbul.time.to_string()), ("2026-03-20".into(), "12:00:00".into()));
616 let angeles = DateTime::from_unix(1_773_997_200, -420);
617 assert_eq!((angeles.date.to_string(), angeles.time.to_string()), ("2026-03-20".into(), "02:00:00".into()));
618 assert_eq!(istanbul.to_unix(), angeles.to_unix());
619
620 let before = DateTime::from_unix(0, -180);
622 assert_eq!((before.date.to_string(), before.time.to_string()), ("1969-12-31".into(), "21:00:00".into()));
623 assert_eq!(before.to_unix(), 0);
624 let after = DateTime::from_unix(-1, 120);
625 assert_eq!((after.date.to_string(), after.time.to_string()), ("1970-01-01".into(), "01:59:59".into()));
626 assert_eq!(after.to_unix(), -1);
627
628 for seconds in (-2_000_000_000..2_000_000_000).step_by(1_000_003) {
629 for offset in [-720, -270, 0, 180, 345, 840] {
630 assert_eq!(DateTime::from_unix(seconds, offset).to_unix(), seconds, "{seconds} at {offset}");
631 }
632 }
633 }
634
635 #[test]
636 fn extreme_unix_seconds_do_not_overflow() {
637 for seconds in [i64::MIN, i64::MIN + 1, i64::MAX - 1, i64::MAX] {
638 for offset in [i16::MIN, -1, 0, 1, i16::MAX] {
639 let moment = DateTime::from_unix(seconds, offset);
640 assert!(Date::new(moment.date.year(), moment.date.month(), moment.date.day()).is_some());
641 let _ = moment.to_unix();
642 }
643 }
644 }
645
646 #[test]
647 fn the_local_offset_is_either_known_or_said_to_be_unknown() {
648 match local_offset() {
649 Some(minutes) => {
650 assert_eq!(local_offset_minutes(), minutes);
651 assert!((-720..=840).contains(&minutes), "{minutes} minutes is no time zone offset");
652 }
653 None => assert_eq!(local_offset_minutes(), 0, "an unknown offset counts as UTC"),
654 }
655 }
656
657 #[test]
658 fn local_today_and_now_agree_with_utc_within_the_offset() {
659 let before = Date::today_local();
663 let now = DateTime::now_local();
664 let after = Date::today_local();
665 assert_eq!(now.offset_minutes, local_offset_minutes());
666 assert!(now.date == before || now.date == after, "{} is not the local day {before}", now.date);
667 let difference = now.date.to_days() - DateTime::from_unix(now.to_unix(), 0).date.to_days();
668 assert!((-1..=1).contains(&difference), "the local day is at most a day from the UTC one");
669 assert!(now.date.year() >= 2024, "{}", now.date);
670 let utc = DateTime::from_unix(now.to_unix(), 0);
672 let minutes = (now.date.to_days() - utc.date.to_days()) * 1_440
673 + i64::from(now.time.seconds_since_midnight()) / 60
674 - i64::from(utc.time.seconds_since_midnight()) / 60;
675 assert_eq!(minutes, i64::from(now.offset_minutes));
676 }
677}