Skip to main content

rskit_util/time/
civil.rs

1//! UTC civil date and epoch conversion helpers.
2
3const SECONDS_PER_DAY: i64 = 86_400;
4
5/// A proleptic Gregorian calendar date.
6///
7/// Fields are public for lightweight utility use. Conversion functions validate
8/// dates before mapping them into epoch-based values.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub struct CivilDate {
11    /// Calendar year, using astronomical numbering.
12    pub year: i64,
13    /// Calendar month in the inclusive range `1..=12`.
14    pub month: i64,
15    /// Calendar day in the inclusive range `1..=31`, constrained by month/year.
16    pub day: i64,
17}
18
19impl CivilDate {
20    /// Returns a validated civil date.
21    #[must_use]
22    pub fn new(year: i64, month: i64, day: i64) -> Option<Self> {
23        let date = Self { year, month, day };
24        date.is_valid().then_some(date)
25    }
26
27    /// Returns `true` when the date is valid in the proleptic Gregorian calendar.
28    #[must_use]
29    pub fn is_valid(self) -> bool {
30        days_in_month(self.year, self.month)
31            .is_some_and(|max_day| self.day >= 1 && self.day <= max_day)
32    }
33}
34
35/// A UTC civil date and wall-clock time with whole-second precision.
36///
37/// Fields are public for lightweight utility use. Conversion functions validate
38/// date and time ranges before mapping them into epoch-based values.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct CivilDateTime {
41    /// UTC calendar date.
42    pub date: CivilDate,
43    /// Hour of day in the inclusive range `0..=23`.
44    pub hour: i64,
45    /// Minute of hour in the inclusive range `0..=59`.
46    pub minute: i64,
47    /// Second of minute in the inclusive range `0..=59`.
48    pub second: i64,
49}
50
51impl CivilDateTime {
52    /// Returns a validated UTC civil date/time.
53    #[must_use]
54    pub fn new(date: CivilDate, hour: i64, minute: i64, second: i64) -> Option<Self> {
55        let datetime = Self {
56            date,
57            hour,
58            minute,
59            second,
60        };
61        datetime.is_valid().then_some(datetime)
62    }
63
64    /// Returns `true` when the date and time fields are valid.
65    #[must_use]
66    pub fn is_valid(self) -> bool {
67        self.date.is_valid()
68            && (0..24).contains(&self.hour)
69            && (0..60).contains(&self.minute)
70            && (0..60).contains(&self.second)
71    }
72}
73
74/// Returns `true` when `year` is a leap year in the proleptic Gregorian calendar.
75#[must_use]
76pub const fn is_leap_year(year: i64) -> bool {
77    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
78}
79
80/// Returns the number of days in `month` for `year`, or `None` for invalid months.
81#[must_use]
82pub const fn days_in_month(year: i64, month: i64) -> Option<i64> {
83    match month {
84        1 | 3 | 5 | 7 | 8 | 10 | 12 => Some(31),
85        4 | 6 | 9 | 11 => Some(30),
86        2 if is_leap_year(year) => Some(29),
87        2 => Some(28),
88        _ => None,
89    }
90}
91
92/// Converts a count of days since the Unix epoch to a civil date.
93///
94/// Uses Howard Hinnant's `civil_from_days` algorithm.
95#[must_use]
96pub fn civil_from_days(days_since_unix_epoch: i64) -> Option<CivilDate> {
97    civil_from_days_i128(i128::from(days_since_unix_epoch))
98}
99
100/// Converts a valid civil date to its day offset from the Unix epoch.
101#[must_use]
102pub fn days_from_civil(date: CivilDate) -> Option<i64> {
103    if !date.is_valid() {
104        return None;
105    }
106
107    let mut year = i128::from(date.year);
108    let month = i128::from(date.month);
109    let day = i128::from(date.day);
110
111    if month <= 2 {
112        year -= 1;
113    }
114
115    let era = year.div_euclid(400);
116    let yoe = year - era * 400;
117    let mp = month + if month > 2 { -3 } else { 9 };
118    let doy = (153 * mp + 2) / 5 + day - 1;
119    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
120    i64::try_from(era * 146_097 + doe - 719_468).ok()
121}
122
123/// Converts Unix epoch seconds to a UTC civil date/time.
124#[must_use]
125pub const fn datetime_from_epoch_secs(epoch_secs: i64) -> CivilDateTime {
126    let days = epoch_secs.div_euclid(SECONDS_PER_DAY);
127    let secs_of_day = epoch_secs.rem_euclid(SECONDS_PER_DAY);
128    let date = civil_from_epoch_days(days);
129
130    CivilDateTime {
131        date,
132        hour: secs_of_day / 3_600,
133        minute: (secs_of_day % 3_600) / 60,
134        second: secs_of_day % 60,
135    }
136}
137
138/// Converts a valid UTC civil date/time to Unix epoch seconds.
139#[must_use]
140pub fn epoch_secs_from_datetime(datetime: CivilDateTime) -> Option<i64> {
141    if !datetime.is_valid() {
142        return None;
143    }
144
145    let days = days_from_civil(datetime.date)?;
146    let hour_secs = datetime.hour.checked_mul(3_600)?;
147    let minute_secs = datetime.minute.checked_mul(60)?;
148    let secs_of_day = hour_secs
149        .checked_add(minute_secs)?
150        .checked_add(datetime.second)?;
151    days.checked_mul(SECONDS_PER_DAY)?.checked_add(secs_of_day)
152}
153
154const fn civil_from_epoch_days(days_since_unix_epoch: i64) -> CivilDate {
155    let z = days_since_unix_epoch + 719_468;
156    let era = z.div_euclid(146_097);
157    let doe = z - era * 146_097;
158    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
159    let mut year = yoe + era * 400;
160    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
161    let mp = (5 * doy + 2) / 153;
162    let day = doy - (153 * mp + 2) / 5 + 1;
163    let month = if mp < 10 { mp + 3 } else { mp - 9 };
164
165    if month <= 2 {
166        year += 1;
167    }
168
169    CivilDate { year, month, day }
170}
171
172fn civil_from_days_i128(days_since_unix_epoch: i128) -> Option<CivilDate> {
173    let z = days_since_unix_epoch + 719_468;
174    let era = z.div_euclid(146_097);
175    let doe = z - era * 146_097;
176    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
177    let mut year = yoe + era * 400;
178    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
179    let mp = (5 * doy + 2) / 153;
180    let day = doy - (153 * mp + 2) / 5 + 1;
181    let month = if mp < 10 { mp + 3 } else { mp - 9 };
182
183    if month <= 2 {
184        year += 1;
185    }
186
187    Some(CivilDate {
188        year: i64::try_from(year).ok()?,
189        month: i64::try_from(month).ok()?,
190        day: i64::try_from(day).ok()?,
191    })
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn validates_dates_and_times() {
200        assert!(CivilDate::new(2000, 2, 29).is_some());
201        assert!(CivilDate::new(1900, 2, 29).is_none());
202        assert!(CivilDate::new(2024, 13, 1).is_none());
203        assert!(
204            CivilDateTime::new(
205                CivilDate {
206                    year: 2024,
207                    month: 1,
208                    day: 1
209                },
210                23,
211                59,
212                59
213            )
214            .is_some()
215        );
216        assert!(
217            CivilDateTime::new(
218                CivilDate {
219                    year: 2024,
220                    month: 1,
221                    day: 1
222                },
223                24,
224                0,
225                0
226            )
227            .is_none()
228        );
229        assert!(
230            CivilDateTime::new(
231                CivilDate {
232                    year: 2024,
233                    month: 1,
234                    day: 1
235                },
236                -1,
237                0,
238                0
239            )
240            .is_none()
241        );
242        assert!(
243            CivilDateTime::new(
244                CivilDate {
245                    year: 2024,
246                    month: 1,
247                    day: 1
248                },
249                0,
250                -1,
251                0
252            )
253            .is_none()
254        );
255        assert!(
256            CivilDateTime::new(
257                CivilDate {
258                    year: 2024,
259                    month: 1,
260                    day: 1
261                },
262                0,
263                0,
264                -1
265            )
266            .is_none()
267        );
268    }
269
270    #[test]
271    fn converts_days_and_civil_dates() {
272        assert_eq!(civil_from_days(0), CivilDate::new(1970, 1, 1));
273        assert_eq!(civil_from_days(-1), CivilDate::new(1969, 12, 31));
274        assert_eq!(
275            days_from_civil(CivilDate {
276                year: 1970,
277                month: 1,
278                day: 1
279            }),
280            Some(0)
281        );
282        assert_eq!(
283            days_from_civil(CivilDate {
284                year: 1969,
285                month: 12,
286                day: 31
287            }),
288            Some(-1)
289        );
290        assert_eq!(
291            days_from_civil(CivilDate {
292                year: 1900,
293                month: 2,
294                day: 29
295            }),
296            None
297        );
298    }
299
300    #[test]
301    fn converts_epoch_seconds_and_datetimes() {
302        let cases = [
303            (
304                -1,
305                CivilDateTime {
306                    date: CivilDate {
307                        year: 1969,
308                        month: 12,
309                        day: 31,
310                    },
311                    hour: 23,
312                    minute: 59,
313                    second: 59,
314                },
315            ),
316            (
317                0,
318                CivilDateTime {
319                    date: CivilDate {
320                        year: 1970,
321                        month: 1,
322                        day: 1,
323                    },
324                    hour: 0,
325                    minute: 0,
326                    second: 0,
327                },
328            ),
329            (
330                951_782_400,
331                CivilDateTime {
332                    date: CivilDate {
333                        year: 2000,
334                        month: 2,
335                        day: 29,
336                    },
337                    hour: 0,
338                    minute: 0,
339                    second: 0,
340                },
341            ),
342            (
343                1_700_000_000,
344                CivilDateTime {
345                    date: CivilDate {
346                        year: 2023,
347                        month: 11,
348                        day: 14,
349                    },
350                    hour: 22,
351                    minute: 13,
352                    second: 20,
353                },
354            ),
355        ];
356
357        for (epoch_secs, datetime) in cases {
358            assert_eq!(datetime_from_epoch_secs(epoch_secs), datetime);
359            assert_eq!(epoch_secs_from_datetime(datetime), Some(epoch_secs));
360        }
361    }
362
363    #[test]
364    fn rejects_invalid_datetime_before_epoch_conversion() {
365        assert_eq!(
366            epoch_secs_from_datetime(CivilDateTime {
367                date: CivilDate {
368                    year: 2024,
369                    month: 1,
370                    day: 1
371                },
372                hour: -1,
373                minute: 0,
374                second: 0,
375            }),
376            None
377        );
378    }
379}