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 timestamp back to epoch milliseconds. Accepts the
31/// fixed-width `YYYY-MM-DDTHH:MM:SSZ` form [`iso8601`] emits, a fractional
32/// `…:SS.fffZ` form (only the leading three fraction digits are kept), and a
33/// `±HH:MM` zone offset in place of the `Z`. Returns `None` unless the shape is
34/// a `date T time` split with in-range fields; the fraction is scanned with
35/// `chars()` so untrusted multibyte input never panics.
36pub fn millis_from_iso8601(text: &str) -> Option<i64> {
37    let text = text.trim().trim_end_matches('Z');
38    let (date, time) = text.split_once('T')?;
39    let (time, offset_seconds) = match time.rfind(['+', '-']) {
40        Some(at) => {
41            let (time, offset) = time.split_at(at);
42            let (hours, minutes) = offset[1..].split_once(':')?;
43            let seconds = hours.parse::<i64>().ok()? * 3_600 + minutes.parse::<i64>().ok()? * 60;
44            (
45                time,
46                if offset.starts_with('-') {
47                    -seconds
48                } else {
49                    seconds
50                },
51            )
52        }
53        None => (time, 0),
54    };
55    let mut date_parts = date.split('-');
56    let year: i64 = date_parts.next()?.parse().ok()?;
57    let month: u32 = date_parts.next()?.parse().ok()?;
58    let day: u32 = date_parts.next()?.parse().ok()?;
59    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
60        return None;
61    }
62    let (hms, fraction) = match time.split_once('.') {
63        Some((hms, fraction)) => (hms, Some(fraction)),
64        None => (time, None),
65    };
66    let mut time_parts = hms.split(':');
67    let hour: i64 = time_parts.next()?.parse().ok()?;
68    let minute: i64 = time_parts.next()?.parse().ok()?;
69    let second: i64 = time_parts.next()?.parse().ok()?;
70    let sub_millis: i64 = fraction
71        .map(|fraction| {
72            let digits: String = fraction
73                .chars()
74                .take_while(char::is_ascii_digit)
75                .take(3)
76                .collect();
77            format!("{digits:0<3}").parse().unwrap_or(0)
78        })
79        .unwrap_or(0);
80    let days = days_from_civil(year, month, day);
81    Some(
82        (days * 86_400 + hour * 3_600 + minute * 60 + second - offset_seconds) * 1_000 + sub_millis,
83    )
84}
85
86/// Convert a `(year, month, day)` civil date to days since the Unix epoch, via
87/// Howard Hinnant's `days_from_civil` algorithm (the inverse of
88/// [`civil_from_days`]).
89pub(crate) fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
90    let year = if month <= 2 { year - 1 } else { year };
91    let era = if year >= 0 { year } else { year - 399 } / 400;
92    let year_of_era = year - era * 400; // [0, 399]
93    let month = i64::from(month);
94    let day_of_year =
95        (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + i64::from(day) - 1; // [0, 365]
96    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; // [0, 146096]
97    era * 146_097 + day_of_era - 719_468
98}
99
100/// Convert a count of days since the Unix epoch to a `(year, month, day)` civil
101/// date, via Howard Hinnant's `civil_from_days` algorithm.
102pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) {
103    // Shift the epoch to 0000-03-01 so leap days fall at the end of the cycle.
104    let z = days + 719_468;
105    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
106    let day_of_era = (z - era * 146_097) as u64; // [0, 146096]
107    let year_of_era =
108        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; // [0, 399]
109    let year = year_of_era as i64 + era * 400;
110    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); // [0, 365]
111    let month_position = (5 * day_of_year + 2) / 153; // [0, 11]
112    let day = (day_of_year - (153 * month_position + 2) / 5 + 1) as u32; // [1, 31]
113    let month = if month_position < 10 {
114        month_position + 3
115    } else {
116        month_position - 9
117    } as u32; // [1, 12]
118    let year = if month <= 2 { year + 1 } else { year };
119    (year, month, day)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn the_epoch_is_formatted() {
128        assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
129    }
130
131    #[test]
132    fn a_known_instant_is_formatted() {
133        assert_eq!(iso8601(1_600_000_000_000), "2020-09-13T12:26:40Z");
134    }
135
136    #[test]
137    fn sub_second_millis_truncate_to_the_second() {
138        assert_eq!(iso8601(1_600_000_000_999), "2020-09-13T12:26:40Z");
139    }
140
141    #[test]
142    fn a_leap_day_is_handled() {
143        // 2020-02-29T00:00:00Z = 1_582_934_400 seconds.
144        assert_eq!(iso8601(1_582_934_400_000), "2020-02-29T00:00:00Z");
145    }
146
147    #[test]
148    fn a_pre_epoch_instant_formats_in_the_past() {
149        assert_eq!(iso8601(-1_000), "1969-12-31T23:59:59Z");
150    }
151
152    #[test]
153    fn iso8601_round_trips_through_millis() {
154        for millis in [0, 1_600_000_000_000, 1_582_934_400_000] {
155            let text = iso8601(millis);
156            assert_eq!(millis_from_iso8601(&text), Some(millis));
157        }
158    }
159
160    #[test]
161    fn civil_dates_anchor_at_the_epoch() {
162        assert_eq!(days_from_civil(1970, 1, 1), 0);
163        assert_eq!(days_from_civil(1970, 1, 2), 1);
164        assert_eq!(days_from_civil(2024, 1, 15), 19737);
165        // Leap day and the day-after-Feb boundary.
166        assert_eq!(days_from_civil(2000, 3, 1), 11017);
167        assert_eq!(days_from_civil(2024, 2, 29), 19782);
168    }
169
170    #[test]
171    fn the_parser_handles_fractions_and_never_panics() {
172        assert_eq!(millis_from_iso8601("1970-01-01T00:00:00Z"), Some(0));
173        assert_eq!(
174            millis_from_iso8601("2024-01-15T10:30:00.000Z"),
175            Some(1_705_314_600_000)
176        );
177        assert_eq!(
178            millis_from_iso8601("2024-01-15T10:30:00Z"),
179            Some(1_705_314_600_000)
180        );
181        // Fractions of varying length pad/truncate to milliseconds.
182        assert_eq!(
183            millis_from_iso8601("2024-01-15T10:30:00.5Z"),
184            Some(1_705_314_600_500)
185        );
186        assert_eq!(
187            millis_from_iso8601("2024-01-15T10:30:00.12Z"),
188            Some(1_705_314_600_120)
189        );
190        assert_eq!(
191            millis_from_iso8601("2024-01-15T10:30:00.123456Z"),
192            Some(1_705_314_600_123)
193        );
194        // A multibyte char in the fraction must NOT panic (untrusted input).
195        assert_eq!(
196            millis_from_iso8601("2024-01-15T10:30:00.12éZ"),
197            Some(1_705_314_600_120)
198        );
199    }
200
201    #[test]
202    fn a_malformed_timestamp_does_not_parse() {
203        assert_eq!(millis_from_iso8601("not-a-time"), None);
204        assert_eq!(millis_from_iso8601("2020/09/13 12:26:40"), None);
205        assert_eq!(millis_from_iso8601("2024-01-15T10:30:00+02"), None);
206        assert_eq!(millis_from_iso8601("2024-13-01T00:00:00Z"), None);
207        assert_eq!(millis_from_iso8601(""), None);
208    }
209
210    #[test]
211    fn offsets_shift_to_utc() {
212        assert_eq!(
213            millis_from_iso8601("2023-11-14T22:13:20Z"),
214            Some(1_700_000_000_000)
215        );
216        assert_eq!(
217            millis_from_iso8601("2023-11-15T01:13:20.123456+03:00"),
218            Some(1_700_000_000_123)
219        );
220        assert_eq!(
221            millis_from_iso8601("2023-11-14T17:13:20.5-05:00"),
222            Some(1_700_000_000_500)
223        );
224        assert_eq!(millis_from_iso8601("2023-11-14T22:13:20+0300"), None);
225    }
226}