Skip to main content

packset_core/
clock.rs

1//! UTC timestamps in the one format the store round-trips.
2//!
3//! Atoms compare timestamps as strings, so the format has to sort the way the
4//! instants do: fixed width, milliseconds, `Z`. A shorter or longer fraction
5//! sorts wrong against what is already on disk.
6
7use std::time::{SystemTime, UNIX_EPOCH};
8
9/// Milliseconds since the epoch, formatted as `YYYY-MM-DDTHH:MM:SS.mmmZ`.
10#[must_use]
11pub fn format_millis(millis: i64) -> String {
12    let (days, ms_of_day) = {
13        let d = millis.div_euclid(86_400_000);
14        let r = millis.rem_euclid(86_400_000);
15        (d, r)
16    };
17    let (year, month, day) = civil_from_days(days);
18    let ms = ms_of_day % 1000;
19    let secs = ms_of_day / 1000;
20    let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
21    format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}.{ms:03}Z")
22}
23
24/// Now, in the store's format.
25#[must_use]
26pub fn utcnow() -> String {
27    let millis = SystemTime::now()
28        .duration_since(UNIX_EPOCH)
29        .map_or(0, |d| d.as_millis() as i64);
30    format_millis(millis)
31}
32
33/// Parse a stored timestamp back to milliseconds since the epoch.
34///
35/// Accepts the `Z` form this module writes and the `+00:00` offset form,
36/// since both are already on disk.
37#[must_use]
38pub fn parse_millis(text: &str) -> Option<i64> {
39    let raw = text.trim();
40    let raw = raw.strip_suffix('Z').unwrap_or(raw);
41    let raw = raw.strip_suffix("+00:00").unwrap_or(raw);
42    let (date, time) = raw.split_once('T').or_else(|| raw.split_once(' '))?;
43    let mut date_parts = date.split('-');
44    let year: i64 = date_parts.next()?.parse().ok()?;
45    let month: u32 = date_parts.next()?.parse().ok()?;
46    let day: u32 = date_parts.next()?.parse().ok()?;
47    let (clock, frac) = time.split_once('.').unwrap_or((time, "0"));
48    let mut clock_parts = clock.split(':');
49    let hour: i64 = clock_parts.next()?.parse().ok()?;
50    let minute: i64 = clock_parts.next()?.parse().ok()?;
51    let second: i64 = clock_parts.next().unwrap_or("0").parse().ok()?;
52    // A fraction is any width on disk; take milliseconds and ignore the rest.
53    let mut millis = 0i64;
54    for (i, ch) in frac.chars().take(3).enumerate() {
55        let digit = ch.to_digit(10)? as i64;
56        millis += digit * 10i64.pow(2 - i as u32);
57    }
58    let days = days_from_civil(year, month, day);
59    Some(days * 86_400_000 + hour * 3_600_000 + minute * 60_000 + second * 1000 + millis)
60}
61
62/// Rewrite any stamp [`parse_millis`] accepts into the store form.
63///
64/// Atoms compare timestamps as strings, so a caller-supplied `+00:00`,
65/// space instead of `T`, or missing-millis form has to become
66/// `YYYY-MM-DDTHH:MM:SS.mmmZ` before it is compared to `valid_from` /
67/// `valid_to`.
68#[must_use]
69pub fn canonicalize(text: &str) -> Option<String> {
70    parse_millis(text).map(format_millis)
71}
72
73/// A stored timestamp shifted by whole seconds, in the same format.
74#[must_use]
75pub fn shift(text: &str, seconds: i64) -> Option<String> {
76    parse_millis(text).map(|ms| format_millis(ms + seconds * 1000))
77}
78
79/// Days between two stored timestamps, floored at zero.
80#[must_use]
81pub fn elapsed_days(from: &str, to: &str) -> f64 {
82    match (parse_millis(from), parse_millis(to)) {
83        (Some(a), Some(b)) => ((b - a) as f64 / 86_400_000.0).max(0.0),
84        _ => 0.0,
85    }
86}
87
88/// Days since the epoch for a civil date. Howard Hinnant's `days_from_civil`.
89fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
90    let y = if month <= 2 { year - 1 } else { year };
91    let era = if y >= 0 { y } else { y - 399 } / 400;
92    let yoe = y - era * 400;
93    let m = i64::from(month);
94    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(day) - 1;
95    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
96    era * 146_097 + doe - 719_468
97}
98
99/// The inverse of [`days_from_civil`].
100fn civil_from_days(days: i64) -> (i64, u32, u32) {
101    let z = days + 719_468;
102    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
103    let doe = z - era * 146_097;
104    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
105    let y = yoe + era * 400;
106    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
107    let mp = (5 * doy + 2) / 153;
108    let d = doy - (153 * mp + 2) / 5 + 1;
109    let m = if mp < 10 { mp + 3 } else { mp - 9 };
110    ((if m <= 2 { y + 1 } else { y }), m as u32, d as u32)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn the_format_sorts_the_way_the_instants_do() {
119        let early = format_millis(1_700_000_000_000);
120        let late = format_millis(1_700_000_001_000);
121        assert!(early < late, "{early} {late}");
122        assert_eq!(early.len(), 24, "{early}");
123        assert!(early.ends_with('Z'));
124    }
125
126    #[test]
127    fn a_stamp_round_trips() {
128        for ms in [0, 1, 1_700_000_000_123, 253_370_764_800_000] {
129            let text = format_millis(ms);
130            assert_eq!(parse_millis(&text), Some(ms), "{text}");
131        }
132    }
133
134    #[test]
135    fn a_known_instant_reads_back_as_written() {
136        assert_eq!(format_millis(1_767_225_600_000), "2026-01-01T00:00:00.000Z");
137        assert_eq!(
138            parse_millis("2026-01-01T00:00:00.000Z"),
139            Some(1_767_225_600_000)
140        );
141    }
142
143    #[test]
144    fn the_offset_form_already_on_disk_still_parses() {
145        // Records on disk carry both spellings of the same instant, so a
146        // reader that took only one would silently drop half of them.
147        assert_eq!(
148            parse_millis("2026-01-01T00:00:00+00:00"),
149            parse_millis("2026-01-01T00:00:00.000Z")
150        );
151    }
152
153    #[test]
154    fn a_caller_stamp_rewrites_to_the_store_form() {
155        assert_eq!(
156            canonicalize("2024-06-01T00:00:00+00:00").as_deref(),
157            Some("2024-06-01T00:00:00.000Z")
158        );
159        assert_eq!(
160            canonicalize("2024-06-01T00:00:00.000Z").as_deref(),
161            Some("2024-06-01T00:00:00.000Z")
162        );
163        assert_eq!(
164            canonicalize("2024-06-01 00:00:00").as_deref(),
165            Some("2024-06-01T00:00:00.000Z")
166        );
167        assert_eq!(
168            canonicalize("2024-06-01 00:00:00.000Z").as_deref(),
169            Some("2024-06-01T00:00:00.000Z")
170        );
171        assert_eq!(canonicalize("not-a-date"), None);
172    }
173
174    #[test]
175    fn a_fraction_of_any_width_takes_milliseconds() {
176        assert_eq!(
177            parse_millis("2026-01-01T00:00:00.123456Z"),
178            parse_millis("2026-01-01T00:00:00.123Z")
179        );
180        assert_eq!(
181            parse_millis("2026-01-01T00:00:00.1Z"),
182            parse_millis("2026-01-01T00:00:00.100Z")
183        );
184    }
185
186    #[test]
187    fn shifting_a_day_lands_on_the_next_one() {
188        assert_eq!(
189            shift("2026-01-01T00:00:00.000Z", 86_400).as_deref(),
190            Some("2026-01-02T00:00:00.000Z")
191        );
192        assert_eq!(
193            shift("2026-02-28T00:00:00.000Z", 86_400).as_deref(),
194            Some("2026-03-01T00:00:00.000Z")
195        );
196    }
197
198    #[test]
199    fn elapsed_is_floored_at_zero() {
200        let a = "2026-01-01T00:00:00.000Z";
201        let b = "2026-01-03T00:00:00.000Z";
202        assert!((elapsed_days(a, b) - 2.0).abs() < 1e-9);
203        assert_eq!(elapsed_days(b, a), 0.0);
204        assert_eq!(elapsed_days("not a stamp", b), 0.0);
205    }
206
207    #[test]
208    fn a_leap_day_survives_the_round_trip() {
209        let text = "2028-02-29T12:34:56.789Z";
210        assert_eq!(parse_millis(text).map(format_millis).as_deref(), Some(text));
211    }
212}