1use std::fmt;
28use std::str::FromStr;
29use std::time::{SystemTime, UNIX_EPOCH};
30
31pub use crate::zone::{Rules, Zone};
32
33#[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
56fn 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 pub const fn is_leap_year(year: i32) -> bool {
71 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
72 }
73
74 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 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 pub fn today_utc() -> Date {
95 Date::from_days(unix_now().div_euclid(86_400))
96 }
97
98 pub fn today_local() -> Date {
100 DateTime::now_local().date
101 }
102
103 pub fn today_in(zone: &Zone) -> Date {
105 DateTime::now_in(zone).date
106 }
107
108 pub fn days(self) -> i64 {
110 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 pub fn from_days(days: i64) -> Date {
122 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 pub fn weekday(self) -> u32 {
137 (self.days() + 3).rem_euclid(7) as u32
139 }
140
141 #[must_use]
143 pub fn add_days(self, days: i64) -> Date {
144 Date::from_days(self.days() + days)
145 }
146
147 #[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 #[must_use]
160 pub fn first_of_month(self) -> Date {
161 Date { day: 1, ..self }
162 }
163
164 #[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 pub fn month_name(self) -> &'static str {
175 MONTHS[(self.month.clamp(1, 12) - 1) as usize]
176 }
177
178 pub const fn at(self, time: Time) -> DateTime {
180 DateTime { date: self, time }
181 }
182}
183
184impl fmt::Display for Date {
185 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 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 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#[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 pub const MIDNIGHT: Time = Time::new(0, 0, 0);
239 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 pub const fn is_valid(self) -> bool {
252 self.hour < 24 && self.minute < 60 && self.second < 60
253 }
254
255 pub const fn seconds(self) -> u32 {
257 self.hour * 3600 + self.minute * 60 + self.second
258 }
259
260 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 pub fn now_utc() -> Time {
268 Time::from_seconds(unix_now())
269 }
270
271 pub fn now_local() -> Time {
273 DateTime::now_local().time
274 }
275
276 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 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 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 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 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#[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 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 pub fn now_local() -> DateTime {
387 DateTime::now_in(&Zone::Local)
388 }
389
390 pub fn now_in(zone: &Zone) -> DateTime {
392 DateTime::from_unix(unix_now(), zone)
393 }
394
395 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 pub fn to_unix(self, zone: &Zone) -> i64 {
418 let wall = self.date.days() * 86_400 + self.time.seconds() as i64;
419 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 #[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 pub const fn is_valid(self) -> bool {
443 self.date.is_valid() && self.time.is_valid()
444 }
445
446 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 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 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 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 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 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 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 assert_eq!(utc(2026, 3, 29, 2, 30).to_unix(&cet), after);
626 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 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 #[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}