Skip to main content

merman_core/time/
civil.rs

1use std::cmp::Ordering;
2use std::fmt;
3use std::str::FromStr;
4
5const MILLIS_PER_SECOND: i64 = 1_000;
6const MILLIS_PER_MINUTE: i64 = 60 * MILLIS_PER_SECOND;
7const MILLIS_PER_HOUR: i64 = 60 * MILLIS_PER_MINUTE;
8const MILLIS_PER_DAY: i64 = 24 * MILLIS_PER_HOUR;
9
10/// A proleptic-Gregorian calendar date with a signed 32-bit year.
11///
12/// Unlike Jiff's civil date, this type deliberately admits Mermaid's `10000`
13/// year boundary. Conversion to an instant remains checked separately.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct CivilDate {
16    year: i32,
17    month: u8,
18    day: u8,
19}
20
21impl CivilDate {
22    pub const fn new(year: i32, month: u32, day: u32) -> Option<Self> {
23        if month < 1 || month > 12 || day < 1 || day > days_in_month(year, month) {
24            return None;
25        }
26        Some(Self {
27            year,
28            month: month as u8,
29            day: day as u8,
30        })
31    }
32
33    pub const fn year(self) -> i32 {
34        self.year
35    }
36
37    pub const fn month(self) -> u32 {
38        self.month as u32
39    }
40
41    pub const fn month0(self) -> u32 {
42        self.month() - 1
43    }
44
45    pub const fn day(self) -> u32 {
46        self.day as u32
47    }
48
49    pub const fn day0(self) -> u32 {
50        self.day() - 1
51    }
52
53    pub fn weekday(self) -> Weekday {
54        Weekday::from_monday_index((self.days_since_unix_epoch() + 3).rem_euclid(7) as u8)
55    }
56
57    pub fn ordinal(self) -> u32 {
58        let january_first = Self::new(self.year, 1, 1).expect("January 1 is always valid");
59        u32::try_from(self.days_since(january_first) + 1)
60            .expect("a Gregorian year contains at most 366 days")
61    }
62
63    pub fn iso_week(self) -> IsoWeek {
64        let year = i64::from(self.year);
65        let weekday = i64::from(self.weekday().number_from_monday());
66        let mut week = (i64::from(self.ordinal()) - weekday + 10).div_euclid(7);
67        let mut week_year = year;
68        if week < 1 {
69            week_year -= 1;
70            week = i64::from(iso_weeks_in_year(week_year));
71        } else if week > i64::from(iso_weeks_in_year(year)) {
72            week_year += 1;
73            week = 1;
74        }
75        IsoWeek {
76            year: week_year,
77            week: week as u32,
78        }
79    }
80
81    pub fn at_midnight(self) -> CivilDateTime {
82        CivilDateTime {
83            date: self,
84            hour: 0,
85            minute: 0,
86            second: 0,
87            millisecond: 0,
88        }
89    }
90
91    pub const fn at_hms(self, hour: u32, minute: u32, second: u32) -> Option<CivilDateTime> {
92        CivilDateTime::new(self, hour, minute, second, 0)
93    }
94
95    pub const fn at_hms_milli(
96        self,
97        hour: u32,
98        minute: u32,
99        second: u32,
100        millisecond: u32,
101    ) -> Option<CivilDateTime> {
102        CivilDateTime::new(self, hour, minute, second, millisecond)
103    }
104
105    pub fn checked_add_days(self, days: i64) -> Option<Self> {
106        civil_from_days(self.days_since_unix_epoch().checked_add(days)?)
107    }
108
109    pub fn checked_sub_days(self, days: i64) -> Option<Self> {
110        self.checked_add_days(days.checked_neg()?)
111    }
112
113    pub fn days_since(self, earlier: Self) -> i64 {
114        self.days_since_unix_epoch() - earlier.days_since_unix_epoch()
115    }
116
117    pub(crate) fn days_since_unix_epoch(self) -> i64 {
118        days_from_civil(self.year, self.month(), self.day())
119    }
120}
121
122impl fmt::Display for CivilDate {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self.year {
125            0..=9999 => write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day),
126            10000.. => write!(f, "+{}-{:02}-{:02}", self.year, self.month, self.day),
127            _ => write!(
128                f,
129                "-{:04}-{:02}-{:02}",
130                self.year.unsigned_abs(),
131                self.month,
132                self.day
133            ),
134        }
135    }
136}
137
138impl FromStr for CivilDate {
139    type Err = ParseCivilDateError;
140
141    fn from_str(value: &str) -> Result<Self, Self::Err> {
142        let (year_month, day) = value.rsplit_once('-').ok_or(ParseCivilDateError)?;
143        let (year, month) = year_month.rsplit_once('-').ok_or(ParseCivilDateError)?;
144        if month.len() != 2
145            || day.len() != 2
146            || !month.bytes().all(|byte| byte.is_ascii_digit())
147            || !day.bytes().all(|byte| byte.is_ascii_digit())
148        {
149            return Err(ParseCivilDateError);
150        }
151
152        let year_digits = year.strip_prefix(['+', '-']).unwrap_or(year);
153        let signed = year.starts_with(['+', '-']);
154        if year_digits.len() < 4
155            || (!signed && year_digits.len() != 4)
156            || !year_digits.bytes().all(|byte| byte.is_ascii_digit())
157        {
158            return Err(ParseCivilDateError);
159        }
160
161        let year = year.parse().map_err(|_| ParseCivilDateError)?;
162        let month = month.parse().map_err(|_| ParseCivilDateError)?;
163        let day = day.parse().map_err(|_| ParseCivilDateError)?;
164        let date = Self::new(year, month, day).ok_or(ParseCivilDateError)?;
165        if date.to_string() == value {
166            Ok(date)
167        } else {
168            Err(ParseCivilDateError)
169        }
170    }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub struct ParseCivilDateError;
175
176impl fmt::Display for ParseCivilDateError {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.write_str("expected a canonical civil date")
179    }
180}
181
182impl std::error::Error for ParseCivilDateError {}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
185pub struct CivilDateTime {
186    date: CivilDate,
187    hour: u8,
188    minute: u8,
189    second: u8,
190    millisecond: u16,
191}
192
193impl CivilDateTime {
194    pub const fn new(
195        date: CivilDate,
196        hour: u32,
197        minute: u32,
198        second: u32,
199        millisecond: u32,
200    ) -> Option<Self> {
201        if hour > 23 || minute > 59 || second > 59 || millisecond > 999 {
202            return None;
203        }
204        Some(Self {
205            date,
206            hour: hour as u8,
207            minute: minute as u8,
208            second: second as u8,
209            millisecond: millisecond as u16,
210        })
211    }
212
213    pub const fn date(self) -> CivilDate {
214        self.date
215    }
216
217    pub const fn year(self) -> i32 {
218        self.date.year()
219    }
220
221    pub const fn month(self) -> u32 {
222        self.date.month()
223    }
224
225    pub const fn month0(self) -> u32 {
226        self.date.month0()
227    }
228
229    pub const fn day(self) -> u32 {
230        self.date.day()
231    }
232
233    pub const fn day0(self) -> u32 {
234        self.date.day0()
235    }
236
237    pub fn weekday(self) -> Weekday {
238        self.date.weekday()
239    }
240
241    pub const fn hour(self) -> u32 {
242        self.hour as u32
243    }
244
245    pub const fn minute(self) -> u32 {
246        self.minute as u32
247    }
248
249    pub const fn second(self) -> u32 {
250        self.second as u32
251    }
252
253    pub const fn millisecond(self) -> u32 {
254        self.millisecond as u32
255    }
256
257    pub fn checked_add_millis(self, milliseconds: i64) -> Option<Self> {
258        Self::from_wide_naive_unix_millis(
259            self.wide_naive_unix_millis()
260                .checked_add(i128::from(milliseconds))?,
261        )
262    }
263
264    pub fn checked_add_days(self, days: i64) -> Option<Self> {
265        Some(Self {
266            date: self.date.checked_add_days(days)?,
267            ..self
268        })
269    }
270
271    pub fn checked_add_months(self, months: i64) -> Option<Self> {
272        let month_index = i64::from(self.year())
273            .checked_mul(12)?
274            .checked_add(i64::from(self.month0()))?
275            .checked_add(months)?;
276        let year = i32::try_from(month_index.div_euclid(12)).ok()?;
277        let month = u32::try_from(month_index.rem_euclid(12)).ok()? + 1;
278        let day = self.day().min(days_in_month(year, month));
279        Some(Self {
280            date: CivilDate::new(year, month, day)?,
281            ..self
282        })
283    }
284
285    pub fn checked_add_years(self, years: i64) -> Option<Self> {
286        let years = i32::try_from(years).ok()?;
287        let year = self.year().checked_add(years)?;
288        let day = self.day().min(days_in_month(year, self.month()));
289        Some(Self {
290            date: CivilDate::new(year, self.month(), day)?,
291            ..self
292        })
293    }
294
295    pub fn signed_duration_millis_since(self, earlier: Self) -> Option<i64> {
296        self.wide_naive_unix_millis()
297            .checked_sub(earlier.wide_naive_unix_millis())?
298            .try_into()
299            .ok()
300    }
301
302    #[cfg(feature = "system-timezone")]
303    pub(crate) fn naive_unix_millis(self) -> Option<i64> {
304        self.wide_naive_unix_millis().try_into().ok()
305    }
306
307    fn wide_naive_unix_millis(self) -> i128 {
308        i128::from(self.date.days_since_unix_epoch()) * i128::from(MILLIS_PER_DAY)
309            + i128::from(self.hour) * i128::from(MILLIS_PER_HOUR)
310            + i128::from(self.minute) * i128::from(MILLIS_PER_MINUTE)
311            + i128::from(self.second) * i128::from(MILLIS_PER_SECOND)
312            + i128::from(self.millisecond)
313    }
314
315    pub(crate) fn from_naive_unix_millis(milliseconds: i64) -> Option<Self> {
316        Self::from_wide_naive_unix_millis(i128::from(milliseconds))
317    }
318
319    fn from_wide_naive_unix_millis(milliseconds: i128) -> Option<Self> {
320        let day = milliseconds.div_euclid(i128::from(MILLIS_PER_DAY));
321        let within_day = milliseconds.rem_euclid(i128::from(MILLIS_PER_DAY));
322        let date = civil_from_days(day.try_into().ok()?)?;
323        let hour = within_day / i128::from(MILLIS_PER_HOUR);
324        let within_hour = within_day % i128::from(MILLIS_PER_HOUR);
325        let minute = within_hour / i128::from(MILLIS_PER_MINUTE);
326        let within_minute = within_hour % i128::from(MILLIS_PER_MINUTE);
327        let second = within_minute / i128::from(MILLIS_PER_SECOND);
328        let millisecond = within_minute % i128::from(MILLIS_PER_SECOND);
329        Self::new(
330            date,
331            hour.try_into().ok()?,
332            minute.try_into().ok()?,
333            second.try_into().ok()?,
334            millisecond.try_into().ok()?,
335        )
336    }
337}
338
339impl fmt::Display for CivilDateTime {
340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341        write!(
342            f,
343            "{}T{:02}:{:02}:{:02}.{:03}",
344            self.date, self.hour, self.minute, self.second, self.millisecond
345        )
346    }
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
350pub struct UtcOffset {
351    seconds: i32,
352}
353
354impl UtcOffset {
355    pub const UTC: Self = Self { seconds: 0 };
356
357    pub const fn from_minutes(minutes: i32) -> Option<Self> {
358        if minutes < -1439 || minutes > 1439 {
359            return None;
360        }
361        Some(Self {
362            seconds: minutes * 60,
363        })
364    }
365
366    pub const fn from_seconds(seconds: i32) -> Option<Self> {
367        if seconds <= -86_400 || seconds >= 86_400 {
368            return None;
369        }
370        Some(Self { seconds })
371    }
372
373    pub const fn seconds(self) -> i32 {
374        self.seconds
375    }
376
377    pub const fn minutes(self) -> i32 {
378        self.seconds / 60
379    }
380}
381
382impl fmt::Display for UtcOffset {
383    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
384        let sign = if self.seconds < 0 { '-' } else { '+' };
385        let absolute = self.seconds.unsigned_abs();
386        write!(
387            f,
388            "{sign}{:02}:{:02}",
389            absolute / 3600,
390            (absolute % 3600) / 60
391        )
392    }
393}
394
395#[derive(Debug, Clone, Copy)]
396pub struct OffsetDateTime {
397    unix_millis: i64,
398    offset: UtcOffset,
399}
400
401impl OffsetDateTime {
402    pub const fn from_unix_millis(unix_millis: i64, offset: UtcOffset) -> Self {
403        Self {
404            unix_millis,
405            offset,
406        }
407    }
408
409    pub fn from_local(local: CivilDateTime, offset: UtcOffset) -> Option<Self> {
410        let unix_millis = local
411            .wide_naive_unix_millis()
412            .checked_sub(i128::from(offset.seconds) * i128::from(MILLIS_PER_SECOND))?
413            .try_into()
414            .ok()?;
415        Some(Self {
416            unix_millis,
417            offset,
418        })
419    }
420
421    pub const fn timestamp_millis(self) -> i64 {
422        self.unix_millis
423    }
424
425    pub const fn timestamp_seconds(self) -> i64 {
426        self.unix_millis.div_euclid(MILLIS_PER_SECOND)
427    }
428
429    pub const fn offset(self) -> UtcOffset {
430        self.offset
431    }
432
433    pub fn date(self) -> CivilDate {
434        self.local_datetime().date()
435    }
436
437    pub fn year(self) -> i32 {
438        self.local_datetime().year()
439    }
440
441    pub fn month(self) -> u32 {
442        self.local_datetime().month()
443    }
444
445    pub fn month0(self) -> u32 {
446        self.local_datetime().month0()
447    }
448
449    pub fn day(self) -> u32 {
450        self.local_datetime().day()
451    }
452
453    pub fn day0(self) -> u32 {
454        self.local_datetime().day0()
455    }
456
457    pub fn ordinal(self) -> u32 {
458        self.date().ordinal()
459    }
460
461    pub fn iso_week(self) -> IsoWeek {
462        self.date().iso_week()
463    }
464
465    pub fn weekday(self) -> Weekday {
466        self.date().weekday()
467    }
468
469    pub fn hour(self) -> u32 {
470        self.local_datetime().hour()
471    }
472
473    pub fn minute(self) -> u32 {
474        self.local_datetime().minute()
475    }
476
477    pub fn second(self) -> u32 {
478        self.local_datetime().second()
479    }
480
481    pub fn millisecond(self) -> u32 {
482        self.local_datetime().millisecond()
483    }
484
485    pub fn local_datetime(self) -> CivilDateTime {
486        let local_millis = i128::from(self.unix_millis)
487            + i128::from(self.offset.seconds) * i128::from(MILLIS_PER_SECOND);
488        CivilDateTime::from_wide_naive_unix_millis(local_millis)
489            .expect("an i64 millisecond instant plus a UTC offset fits an i32 civil year")
490    }
491
492    pub fn utc_datetime(self) -> CivilDateTime {
493        CivilDateTime::from_naive_unix_millis(self.unix_millis)
494            .expect("an i64 millisecond instant fits an i32 civil year")
495    }
496
497    pub const fn to_offset(self, offset: UtcOffset) -> Self {
498        Self { offset, ..self }
499    }
500
501    pub fn checked_add_millis(self, milliseconds: i64) -> Option<Self> {
502        Some(Self {
503            unix_millis: self.unix_millis.checked_add(milliseconds)?,
504            ..self
505        })
506    }
507}
508
509impl PartialEq for OffsetDateTime {
510    fn eq(&self, other: &Self) -> bool {
511        self.unix_millis == other.unix_millis
512    }
513}
514
515impl Eq for OffsetDateTime {}
516
517impl PartialOrd for OffsetDateTime {
518    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
519        Some(self.cmp(other))
520    }
521}
522
523impl Ord for OffsetDateTime {
524    fn cmp(&self, other: &Self) -> Ordering {
525        self.unix_millis.cmp(&other.unix_millis)
526    }
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
530pub enum Weekday {
531    Monday,
532    Tuesday,
533    Wednesday,
534    Thursday,
535    Friday,
536    Saturday,
537    Sunday,
538}
539
540impl Weekday {
541    const fn from_monday_index(index: u8) -> Self {
542        match index {
543            0 => Self::Monday,
544            1 => Self::Tuesday,
545            2 => Self::Wednesday,
546            3 => Self::Thursday,
547            4 => Self::Friday,
548            5 => Self::Saturday,
549            _ => Self::Sunday,
550        }
551    }
552
553    pub const fn number_from_monday(self) -> u32 {
554        match self {
555            Self::Monday => 1,
556            Self::Tuesday => 2,
557            Self::Wednesday => 3,
558            Self::Thursday => 4,
559            Self::Friday => 5,
560            Self::Saturday => 6,
561            Self::Sunday => 7,
562        }
563    }
564
565    pub const fn number_from_sunday(self) -> u32 {
566        match self {
567            Self::Sunday => 1,
568            Self::Monday => 2,
569            Self::Tuesday => 3,
570            Self::Wednesday => 4,
571            Self::Thursday => 5,
572            Self::Friday => 6,
573            Self::Saturday => 7,
574        }
575    }
576
577    pub const fn full_name(self) -> &'static str {
578        match self {
579            Self::Monday => "Monday",
580            Self::Tuesday => "Tuesday",
581            Self::Wednesday => "Wednesday",
582            Self::Thursday => "Thursday",
583            Self::Friday => "Friday",
584            Self::Saturday => "Saturday",
585            Self::Sunday => "Sunday",
586        }
587    }
588
589    pub const fn short_name(self) -> &'static str {
590        match self {
591            Self::Monday => "Mon",
592            Self::Tuesday => "Tue",
593            Self::Wednesday => "Wed",
594            Self::Thursday => "Thu",
595            Self::Friday => "Fri",
596            Self::Saturday => "Sat",
597            Self::Sunday => "Sun",
598        }
599    }
600}
601
602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
603pub struct IsoWeek {
604    year: i64,
605    week: u32,
606}
607
608impl IsoWeek {
609    pub const fn year(self) -> i64 {
610        self.year
611    }
612
613    pub const fn week(self) -> u32 {
614        self.week
615    }
616}
617
618const fn is_leap_year_i64(year: i64) -> bool {
619    year.rem_euclid(4) == 0 && (year.rem_euclid(100) != 0 || year.rem_euclid(400) == 0)
620}
621
622pub const fn is_leap_year(year: i32) -> bool {
623    is_leap_year_i64(year as i64)
624}
625
626pub const fn days_in_month(year: i32, month: u32) -> u32 {
627    match month {
628        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
629        4 | 6 | 9 | 11 => 30,
630        2 if is_leap_year(year) => 29,
631        2 => 28,
632        _ => 0,
633    }
634}
635
636fn iso_weeks_in_year(year: i64) -> u32 {
637    let january_first = weekday_for_wide_year(year, 1, 1);
638    if january_first == Weekday::Thursday
639        || (january_first == Weekday::Wednesday && is_leap_year_i64(year))
640    {
641        53
642    } else {
643        52
644    }
645}
646
647fn weekday_for_wide_year(year: i64, month: u32, day: u32) -> Weekday {
648    Weekday::from_monday_index((days_from_civil_wide(year, month, day) + 3).rem_euclid(7) as u8)
649}
650
651fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
652    days_from_civil_wide(i64::from(year), month, day)
653}
654
655fn days_from_civil_wide(mut year: i64, month: u32, day: u32) -> i64 {
656    if month <= 2 {
657        year -= 1;
658    }
659    let era = year.div_euclid(400);
660    let year_of_era = year - era * 400;
661    let month_prime = i64::from(month) + if month > 2 { -3 } else { 9 };
662    let day_of_year = (153 * month_prime + 2) / 5 + i64::from(day) - 1;
663    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
664    era * 146_097 + day_of_era - 719_468
665}
666
667fn civil_from_days(days: i64) -> Option<CivilDate> {
668    let days = days.checked_add(719_468)?;
669    let era = days.div_euclid(146_097);
670    let day_of_era = days - era * 146_097;
671    let year_of_era =
672        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
673    let mut year = year_of_era + era * 400;
674    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
675    let month_prime = (5 * day_of_year + 2) / 153;
676    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
677    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
678    if month <= 2 {
679        year += 1;
680    }
681    CivilDate::new(
682        year.try_into().ok()?,
683        month.try_into().ok()?,
684        day.try_into().ok()?,
685    )
686}