Skip to main content

kernel/
time.rs

1//! Shared time helpers: the epoch-millisecond clock and the hand-rolled
2//! Gregorian calendar conversions used for ISO 8601 wire timestamps. Kept in the
3//! kernel (the dependency floor) so every crate shares one implementation
4//! instead of a date-library dependency or a per-crate copy.
5
6use std::time::{SystemTime, UNIX_EPOCH};
7
8/// The current wall-clock time in milliseconds since the Unix epoch, or `0` if
9/// the clock is before the epoch (unreachable on a sane system).
10pub fn now_millis() -> i64 {
11    SystemTime::now()
12        .duration_since(UNIX_EPOCH)
13        .map(|elapsed| elapsed.as_millis() as i64)
14        .unwrap_or(0)
15}
16
17/// Format `millis` since the Unix epoch as `YYYY-MM-DDTHH:MM:SSZ` (UTC, no
18/// fractional seconds) — RFC 3339 / ISO 8601.
19pub fn iso8601(millis: i64) -> String {
20    let seconds = millis.div_euclid(1000);
21    let days = seconds.div_euclid(86_400);
22    let seconds_of_day = seconds.rem_euclid(86_400);
23    let hour = seconds_of_day / 3_600;
24    let minute = (seconds_of_day % 3_600) / 60;
25    let second = seconds_of_day % 60;
26    let (year, month, day) = civil_from_days(days);
27    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
28}
29
30/// Parse an ISO 8601 UTC timestamp back to epoch milliseconds. Accepts both the
31/// fixed-width `YYYY-MM-DDTHH:MM:SSZ` form [`iso8601`] emits and a fractional
32/// `…:SS.fffZ` form (only the leading three fraction digits are kept). Returns
33/// `None` unless the shape is a `date T time` split with in-range fields; the
34/// fraction is scanned with `chars()` so untrusted multibyte input never panics.
35pub fn millis_from_iso8601(text: &str) -> Option<i64> {
36    let text = text.trim().trim_end_matches('Z');
37    let (date, time) = text.split_once('T')?;
38    let mut date_parts = date.split('-');
39    let year: i64 = date_parts.next()?.parse().ok()?;
40    let month: u32 = date_parts.next()?.parse().ok()?;
41    let day: u32 = date_parts.next()?.parse().ok()?;
42    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
43        return None;
44    }
45    let (hms, fraction) = match time.split_once('.') {
46        Some((hms, fraction)) => (hms, Some(fraction)),
47        None => (time, None),
48    };
49    let mut time_parts = hms.split(':');
50    let hour: i64 = time_parts.next()?.parse().ok()?;
51    let minute: i64 = time_parts.next()?.parse().ok()?;
52    let second: i64 = time_parts.next()?.parse().ok()?;
53    let sub_millis: i64 = fraction
54        .map(|fraction| {
55            let digits: String = fraction
56                .chars()
57                .take_while(char::is_ascii_digit)
58                .take(3)
59                .collect();
60            format!("{digits:0<3}").parse().unwrap_or(0)
61        })
62        .unwrap_or(0);
63    let days = days_from_civil(year, month, day);
64    Some((days * 86_400 + hour * 3_600 + minute * 60 + second) * 1_000 + sub_millis)
65}
66
67/// Convert a `(year, month, day)` civil date to days since the Unix epoch, via
68/// Howard Hinnant's `days_from_civil` algorithm (the inverse of
69/// [`civil_from_days`]).
70pub(crate) fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
71    let year = if month <= 2 { year - 1 } else { year };
72    let era = if year >= 0 { year } else { year - 399 } / 400;
73    let year_of_era = year - era * 400; // [0, 399]
74    let month = i64::from(month);
75    let day_of_year =
76        (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + i64::from(day) - 1; // [0, 365]
77    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; // [0, 146096]
78    era * 146_097 + day_of_era - 719_468
79}
80
81/// Convert a count of days since the Unix epoch to a `(year, month, day)` civil
82/// date, via Howard Hinnant's `civil_from_days` algorithm.
83pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) {
84    // Shift the epoch to 0000-03-01 so leap days fall at the end of the cycle.
85    let z = days + 719_468;
86    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
87    let day_of_era = (z - era * 146_097) as u64; // [0, 146096]
88    let year_of_era =
89        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; // [0, 399]
90    let year = year_of_era as i64 + era * 400;
91    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); // [0, 365]
92    let month_position = (5 * day_of_year + 2) / 153; // [0, 11]
93    let day = (day_of_year - (153 * month_position + 2) / 5 + 1) as u32; // [1, 31]
94    let month = if month_position < 10 {
95        month_position + 3
96    } else {
97        month_position - 9
98    } as u32; // [1, 12]
99    let year = if month <= 2 { year + 1 } else { year };
100    (year, month, day)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn the_epoch_is_formatted() {
109        assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
110    }
111
112    #[test]
113    fn a_known_instant_is_formatted() {
114        assert_eq!(iso8601(1_600_000_000_000), "2020-09-13T12:26:40Z");
115    }
116
117    #[test]
118    fn sub_second_millis_truncate_to_the_second() {
119        assert_eq!(iso8601(1_600_000_000_999), "2020-09-13T12:26:40Z");
120    }
121
122    #[test]
123    fn a_leap_day_is_handled() {
124        // 2020-02-29T00:00:00Z = 1_582_934_400 seconds.
125        assert_eq!(iso8601(1_582_934_400_000), "2020-02-29T00:00:00Z");
126    }
127
128    #[test]
129    fn a_pre_epoch_instant_formats_in_the_past() {
130        assert_eq!(iso8601(-1_000), "1969-12-31T23:59:59Z");
131    }
132
133    #[test]
134    fn iso8601_round_trips_through_millis() {
135        for millis in [0, 1_600_000_000_000, 1_582_934_400_000] {
136            let text = iso8601(millis);
137            assert_eq!(millis_from_iso8601(&text), Some(millis));
138        }
139    }
140
141    #[test]
142    fn civil_dates_anchor_at_the_epoch() {
143        assert_eq!(days_from_civil(1970, 1, 1), 0);
144        assert_eq!(days_from_civil(1970, 1, 2), 1);
145        assert_eq!(days_from_civil(2024, 1, 15), 19737);
146        // Leap day and the day-after-Feb boundary.
147        assert_eq!(days_from_civil(2000, 3, 1), 11017);
148        assert_eq!(days_from_civil(2024, 2, 29), 19782);
149    }
150
151    #[test]
152    fn the_parser_handles_fractions_and_never_panics() {
153        assert_eq!(millis_from_iso8601("1970-01-01T00:00:00Z"), Some(0));
154        assert_eq!(
155            millis_from_iso8601("2024-01-15T10:30:00.000Z"),
156            Some(1_705_314_600_000)
157        );
158        assert_eq!(
159            millis_from_iso8601("2024-01-15T10:30:00Z"),
160            Some(1_705_314_600_000)
161        );
162        // Fractions of varying length pad/truncate to milliseconds.
163        assert_eq!(
164            millis_from_iso8601("2024-01-15T10:30:00.5Z"),
165            Some(1_705_314_600_500)
166        );
167        assert_eq!(
168            millis_from_iso8601("2024-01-15T10:30:00.12Z"),
169            Some(1_705_314_600_120)
170        );
171        assert_eq!(
172            millis_from_iso8601("2024-01-15T10:30:00.123456Z"),
173            Some(1_705_314_600_123)
174        );
175        // A multibyte char in the fraction must NOT panic (untrusted input).
176        assert_eq!(
177            millis_from_iso8601("2024-01-15T10:30:00.12éZ"),
178            Some(1_705_314_600_120)
179        );
180    }
181
182    #[test]
183    fn a_malformed_timestamp_does_not_parse() {
184        assert_eq!(millis_from_iso8601("not-a-time"), None);
185        assert_eq!(millis_from_iso8601("2020/09/13 12:26:40"), None);
186        assert_eq!(millis_from_iso8601("2024-01-15T10:30:00+02:00"), None);
187        assert_eq!(millis_from_iso8601("2024-13-01T00:00:00Z"), None);
188        assert_eq!(millis_from_iso8601(""), None);
189    }
190}