1use std::time::{SystemTime, UNIX_EPOCH};
8
9#[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#[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#[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 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#[must_use]
69pub fn canonicalize(text: &str) -> Option<String> {
70 parse_millis(text).map(format_millis)
71}
72
73#[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#[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
88fn 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
99fn 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 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}