1#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct WallClock(pub f64);
35
36#[derive(Debug)]
37pub enum WallClockError {
38 Unrecognized(String),
40 OutOfRange(&'static str),
42}
43
44impl std::error::Error for WallClockError {}
45
46impl std::fmt::Display for WallClockError {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 WallClockError::Unrecognized(s) => write!(f, "unrecognized datetime {:?}", s),
50 WallClockError::OutOfRange(what) => write!(f, "datetime out of range: {}", what),
51 }
52 }
53}
54
55impl WallClock {
56 pub fn parse(raw: &str) -> Result<WallClock, WallClockError> {
58 let s = raw.trim();
59 if s.is_empty() {
60 return Err(WallClockError::Unrecognized(raw.to_string()));
61 }
62
63 let lower = s.to_ascii_lowercase();
65 if let Some(digits) = lower.strip_suffix("ms") {
66 let v: f64 = digits
67 .parse()
68 .map_err(|_| WallClockError::Unrecognized(raw.to_string()))?;
69 return Ok(WallClock(v / 1_000.0));
70 }
71 if let Some(digits) = lower.strip_suffix('s') {
72 if digits
73 .chars()
74 .all(|c| c.is_ascii_digit() || c == '.' || c == '-')
75 {
76 let v: f64 = digits
77 .parse()
78 .map_err(|_| WallClockError::Unrecognized(raw.to_string()))?;
79 return Ok(WallClock(v));
80 }
81 }
82
83 let (date_part, rest) =
85 split_date(s).ok_or_else(|| WallClockError::Unrecognized(raw.to_string()))?;
86 let (y, m, d) = parse_ymd(&date_part)?;
87
88 let mut sec: f64 = 0.0;
90 let mut offset_sec: f64 = 0.0;
91
92 let rest = rest.trim_start();
93 if !rest.is_empty() {
94 let rest = rest.strip_prefix(['T', 't', ' ']).unwrap_or(rest);
96 if rest.is_empty() {
97 } else {
99 let (hms, tail) = split_time(rest)
100 .ok_or_else(|| WallClockError::Unrecognized(raw.to_string()))?;
101 sec = parse_hms(&hms)?;
102 let tail = tail.trim();
103 if !tail.is_empty() {
104 offset_sec = parse_offset(tail)?;
105 }
106 }
107 }
108
109 let days = days_from_civil(y, m, d).ok_or(WallClockError::OutOfRange("date"))?;
110 let epoch = days as f64 * 86_400.0 + sec - offset_sec;
112 Ok(WallClock(epoch))
113 }
114
115 pub fn epoch_secs(&self) -> f64 {
117 self.0
118 }
119
120 pub fn as_marker(&self) -> u64 {
131 WALL_CLOCK_FLAG | ((self.0 * 1000.0).round() as u64)
132 }
133
134 pub fn from_marker(marker: u64) -> Option<WallClock> {
136 if marker & WALL_CLOCK_FLAG == 0 {
137 return None; }
139 Some(WallClock((marker & !WALL_CLOCK_FLAG) as f64 / 1000.0))
140 }
141}
142
143pub const WALL_CLOCK_FLAG: u64 = 1u64 << 63;
145
146fn split_date(s: &str) -> Option<(&str, &str)> {
148 let b = s.as_bytes();
149 if b.len() < 10 {
150 return None;
151 }
152 if !(b[0].is_ascii_digit()
154 && b[1].is_ascii_digit()
155 && b[2].is_ascii_digit()
156 && b[3].is_ascii_digit()
157 && b[4] == b'-'
158 && b[5].is_ascii_digit()
159 && b[6].is_ascii_digit()
160 && b[7] == b'-'
161 && b[8].is_ascii_digit()
162 && b[9].is_ascii_digit())
163 {
164 return None;
165 }
166 Some((&s[..10], &s[10..]))
167}
168
169fn split_time(s: &str) -> Option<(&str, &str)> {
171 let b = s.as_bytes();
172 let mut end = 0usize;
174 let seen_colon = b.first() != Some(&b':');
175 let _ = seen_colon;
176 while end < b.len() && (b[end].is_ascii_digit() || b[end] == b':') {
177 end += 1;
178 }
179 if end < 5 {
180 return None;
181 }
182 let mut hms_end = end;
183 if end < b.len() && b[end] == b'.' {
187 hms_end += 1;
188 while hms_end < b.len() && b[hms_end].is_ascii_digit() {
189 hms_end += 1;
190 }
191 }
192 Some((&s[..hms_end], &s[hms_end..]))
193}
194
195fn parse_ymd(s: &str) -> Result<(i64, u32, u32), WallClockError> {
196 let y: i64 = s[0..4]
197 .parse()
198 .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
199 let m: u32 = s[5..7]
200 .parse()
201 .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
202 let d: u32 = s[8..10]
203 .parse()
204 .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
205 if !(1..=12).contains(&m) {
206 return Err(WallClockError::OutOfRange("month must be 01–12"));
207 }
208 if !(1..=31).contains(&d) {
209 return Err(WallClockError::OutOfRange("day must be 01–31"));
210 }
211 Ok((y, m, d))
212}
213
214fn parse_hms(s: &str) -> Result<f64, WallClockError> {
215 let parts: Vec<&str> = s.split(':').collect();
216 if parts.is_empty() || parts.len() > 3 {
217 return Err(WallClockError::Unrecognized(s.to_string()));
218 }
219 let h: f64 = parts[0]
220 .parse()
221 .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
222 if !(0.0..24.0).contains(&h) {
223 return Err(WallClockError::OutOfRange("hour must be 00–23"));
224 }
225 let (m, sec_part) = match parts.len() {
226 1 => (0.0, None),
227 2 => (
228 parts[1]
229 .parse::<f64>()
230 .map_err(|_| WallClockError::Unrecognized(s.to_string()))?,
231 None,
232 ),
233 _ => (
234 parts[1]
235 .parse::<f64>()
236 .map_err(|_| WallClockError::Unrecognized(s.to_string()))?,
237 Some(parts[2]),
238 ),
239 };
240 if !(0.0..60.0).contains(&m) {
241 return Err(WallClockError::OutOfRange("minute must be 00–59"));
242 }
243 let mut total = h * 3600.0 + m * 60.0;
244 if let Some(sp) = sec_part {
245 let mut seg = sp.split('.');
247 let ss: f64 = seg
248 .next()
249 .unwrap_or("0")
250 .parse()
251 .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
252 if !(0.0..60.0).contains(&ss) {
253 return Err(WallClockError::OutOfRange("second must be 00–59"));
254 }
255 total += ss;
256 if let Some(frac) = seg.next() {
257 let frac_val = format!("0.{}", frac)
258 .parse::<f64>()
259 .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
260 total += frac_val;
261 }
262 }
263 Ok(total)
264}
265
266fn parse_offset(s: &str) -> Result<f64, WallClockError> {
268 let up = s.to_ascii_uppercase();
269 if up == "Z" {
270 return Ok(0.0);
271 }
272 let (sign, body) = match up.strip_prefix('+') {
273 Some(b) => (1.0, b),
274 None => match up.strip_prefix('-') {
275 Some(b) => (-1.0, b),
276 None => return Err(WallClockError::Unrecognized(s.to_string())),
277 },
278 };
279 let digits: String = body.chars().filter(|c| c.is_ascii_digit()).collect();
280 let (h, m) = match digits.len() {
281 2 => (digits.parse::<f64>().unwrap_or(0.0), 0.0),
282 4 => {
283 let h: f64 = digits[..2]
284 .parse()
285 .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
286 let m: f64 = digits[2..]
287 .parse()
288 .map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
289 (h, m)
290 }
291 _ => return Err(WallClockError::Unrecognized(s.to_string())),
292 };
293 if !(0.0..24.0).contains(&h) || !(0.0..60.0).contains(&m) {
294 return Err(WallClockError::OutOfRange("offset out of range"));
295 }
296 Ok(sign * (h * 3600.0 + m * 60.0))
297}
298
299fn days_from_civil(y: i64, m: u32, d: u32) -> Option<i64> {
302 let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
304 let month_lens = [
305 31u32,
306 if leap { 29 } else { 28 },
307 31,
308 30,
309 31,
310 30,
311 31,
312 31,
313 30,
314 31,
315 30,
316 31,
317 ];
318 let ml = *month_lens.get((m as usize).saturating_sub(1))?;
319 if d > ml {
320 return None;
321 }
322 let y2 = if m <= 2 { y - 1 } else { y };
324 let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;
325 let yoe: i64 = y2 - era * 400;
326 let mp: i64 = m as i64 + if m as i64 > 2 { -3 } else { 9 };
327 let doy = (153 * mp + 2) / 5 + (d as i64) - 1;
328 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
329 Some(era * 146_097 + doe - 719_468)
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn epoch_date_is_zero() {
338 assert_eq!(WallClock::parse("1970-01-01").unwrap().epoch_secs(), 0.0);
339 assert_eq!(
340 WallClock::parse("1970-01-01T00:00:00Z")
341 .unwrap()
342 .epoch_secs(),
343 0.0
344 );
345 }
346
347 #[test]
348 fn a_known_moment_parses() {
349 assert_eq!(
351 WallClock::parse("2026-09-15").unwrap().epoch_secs(),
352 1_789_430_400.0
353 );
354 assert_eq!(
355 WallClock::parse("2026-09-15T00:00:00Z")
356 .unwrap()
357 .epoch_secs(),
358 1_789_430_400.0
359 );
360 assert_eq!(
361 WallClock::parse("2026-09-15 00:00:00")
362 .unwrap()
363 .epoch_secs(),
364 1_789_430_400.0
365 );
366 }
367
368 #[test]
369 fn time_of_day_and_fractions_count() {
370 let w = WallClock::parse("2026-09-15T17:00:00Z").unwrap();
371 assert_eq!(w.epoch_secs(), 1_789_430_400.0 + 17.0 * 3600.0);
372 let w2 = WallClock::parse("2026-09-15T17:00:00.5Z").unwrap();
373 assert_eq!(w2.epoch_secs(), 1_789_430_400.0 + 17.0 * 3600.0 + 0.5);
374 }
375
376 #[test]
377 fn offsets_shift_to_utc() {
378 let w = WallClock::parse("2026-09-15T17:00:00+02:00").unwrap();
380 assert_eq!(w.epoch_secs(), 1_789_430_400.0 + 15.0 * 3600.0);
381 let w2 = WallClock::parse("2026-09-15T12:00:00-0500").unwrap();
383 assert_eq!(w2.epoch_secs(), 1_789_430_400.0 + 17.0 * 3600.0);
384 assert_eq!(
386 WallClock::parse("2026-09-15T00:00:00z")
387 .unwrap()
388 .epoch_secs(),
389 1_789_430_400.0
390 );
391 }
392
393 #[test]
394 fn explicit_units_are_accepted_and_scaled() {
395 assert_eq!(
396 WallClock::parse("1757955600s").unwrap().epoch_secs(),
397 1_757_955_600.0
398 );
399 assert_eq!(
400 WallClock::parse("1757955600000ms").unwrap().epoch_secs(),
401 1_757_955_600.0
402 );
403 assert_eq!(
404 WallClock::parse("1757955600.5s").unwrap().epoch_secs(),
405 1_757_955_600.5
406 );
407 }
408
409 #[test]
410 fn impossible_dates_are_range_errors_not_silence() {
411 assert!(matches!(
412 WallClock::parse("2026-02-30"),
413 Err(WallClockError::OutOfRange("date"))
414 ));
415 assert!(matches!(
416 WallClock::parse("2026-13-01"),
417 Err(WallClockError::OutOfRange(_))
418 ));
419 assert!(matches!(
420 WallClock::parse("2026-09-15T25:00:00Z"),
421 Err(WallClockError::OutOfRange(_))
422 ));
423 assert!(WallClock::parse("2024-02-29").is_ok());
425 assert!(matches!(
426 WallClock::parse("2026-02-29"),
427 Err(WallClockError::OutOfRange("date"))
428 ));
429 }
430
431 #[test]
432 fn garbage_is_unrecognized() {
433 for bad in ["not a time", "15/09/2026", "sep 15", "2026-9-15", "", " "] {
434 assert!(
435 matches!(WallClock::parse(bad), Err(WallClockError::Unrecognized(_))),
436 "expected Unrecognized for {:?}",
437 bad
438 );
439 }
440 assert!(matches!(
444 WallClock::parse("1757955600"),
445 Err(WallClockError::Unrecognized(_))
446 ));
447 }
448}