#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WallClock(pub f64);
#[derive(Debug)]
pub enum WallClockError {
Unrecognized(String),
OutOfRange(&'static str),
}
impl std::error::Error for WallClockError {}
impl std::fmt::Display for WallClockError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WallClockError::Unrecognized(s) => write!(f, "unrecognized datetime {:?}", s),
WallClockError::OutOfRange(what) => write!(f, "datetime out of range: {}", what),
}
}
}
impl WallClock {
pub fn parse(raw: &str) -> Result<WallClock, WallClockError> {
let s = raw.trim();
if s.is_empty() {
return Err(WallClockError::Unrecognized(raw.to_string()));
}
let lower = s.to_ascii_lowercase();
if let Some(digits) = lower.strip_suffix("ms") {
let v: f64 = digits
.parse()
.map_err(|_| WallClockError::Unrecognized(raw.to_string()))?;
return Ok(WallClock(v / 1_000.0));
}
if let Some(digits) = lower.strip_suffix('s') {
if digits
.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c == '-')
{
let v: f64 = digits
.parse()
.map_err(|_| WallClockError::Unrecognized(raw.to_string()))?;
return Ok(WallClock(v));
}
}
let (date_part, rest) =
split_date(s).ok_or_else(|| WallClockError::Unrecognized(raw.to_string()))?;
let (y, m, d) = parse_ymd(&date_part)?;
let mut sec: f64 = 0.0;
let mut offset_sec: f64 = 0.0;
let rest = rest.trim_start();
if !rest.is_empty() {
let rest = rest.strip_prefix(['T', 't', ' ']).unwrap_or(rest);
if rest.is_empty() {
} else {
let (hms, tail) = split_time(rest)
.ok_or_else(|| WallClockError::Unrecognized(raw.to_string()))?;
sec = parse_hms(&hms)?;
let tail = tail.trim();
if !tail.is_empty() {
offset_sec = parse_offset(tail)?;
}
}
}
let days = days_from_civil(y, m, d).ok_or(WallClockError::OutOfRange("date"))?;
let epoch = days as f64 * 86_400.0 + sec - offset_sec;
Ok(WallClock(epoch))
}
pub fn epoch_secs(&self) -> f64 {
self.0
}
pub fn as_marker(&self) -> u64 {
WALL_CLOCK_FLAG | ((self.0 * 1000.0).round() as u64)
}
pub fn from_marker(marker: u64) -> Option<WallClock> {
if marker & WALL_CLOCK_FLAG == 0 {
return None; }
Some(WallClock((marker & !WALL_CLOCK_FLAG) as f64 / 1000.0))
}
}
pub const WALL_CLOCK_FLAG: u64 = 1u64 << 63;
fn split_date(s: &str) -> Option<(&str, &str)> {
let b = s.as_bytes();
if b.len() < 10 {
return None;
}
if !(b[0].is_ascii_digit()
&& b[1].is_ascii_digit()
&& b[2].is_ascii_digit()
&& b[3].is_ascii_digit()
&& b[4] == b'-'
&& b[5].is_ascii_digit()
&& b[6].is_ascii_digit()
&& b[7] == b'-'
&& b[8].is_ascii_digit()
&& b[9].is_ascii_digit())
{
return None;
}
Some((&s[..10], &s[10..]))
}
fn split_time(s: &str) -> Option<(&str, &str)> {
let b = s.as_bytes();
let mut end = 0usize;
let seen_colon = b.first() != Some(&b':');
let _ = seen_colon;
while end < b.len() && (b[end].is_ascii_digit() || b[end] == b':') {
end += 1;
}
if end < 5 {
return None;
}
let mut hms_end = end;
if end < b.len() && b[end] == b'.' {
hms_end += 1;
while hms_end < b.len() && b[hms_end].is_ascii_digit() {
hms_end += 1;
}
}
Some((&s[..hms_end], &s[hms_end..]))
}
fn parse_ymd(s: &str) -> Result<(i64, u32, u32), WallClockError> {
let y: i64 = s[0..4]
.parse()
.map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
let m: u32 = s[5..7]
.parse()
.map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
let d: u32 = s[8..10]
.parse()
.map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
if !(1..=12).contains(&m) {
return Err(WallClockError::OutOfRange("month must be 01–12"));
}
if !(1..=31).contains(&d) {
return Err(WallClockError::OutOfRange("day must be 01–31"));
}
Ok((y, m, d))
}
fn parse_hms(s: &str) -> Result<f64, WallClockError> {
let parts: Vec<&str> = s.split(':').collect();
if parts.is_empty() || parts.len() > 3 {
return Err(WallClockError::Unrecognized(s.to_string()));
}
let h: f64 = parts[0]
.parse()
.map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
if !(0.0..24.0).contains(&h) {
return Err(WallClockError::OutOfRange("hour must be 00–23"));
}
let (m, sec_part) = match parts.len() {
1 => (0.0, None),
2 => (
parts[1]
.parse::<f64>()
.map_err(|_| WallClockError::Unrecognized(s.to_string()))?,
None,
),
_ => (
parts[1]
.parse::<f64>()
.map_err(|_| WallClockError::Unrecognized(s.to_string()))?,
Some(parts[2]),
),
};
if !(0.0..60.0).contains(&m) {
return Err(WallClockError::OutOfRange("minute must be 00–59"));
}
let mut total = h * 3600.0 + m * 60.0;
if let Some(sp) = sec_part {
let mut seg = sp.split('.');
let ss: f64 = seg
.next()
.unwrap_or("0")
.parse()
.map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
if !(0.0..60.0).contains(&ss) {
return Err(WallClockError::OutOfRange("second must be 00–59"));
}
total += ss;
if let Some(frac) = seg.next() {
let frac_val = format!("0.{}", frac)
.parse::<f64>()
.map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
total += frac_val;
}
}
Ok(total)
}
fn parse_offset(s: &str) -> Result<f64, WallClockError> {
let up = s.to_ascii_uppercase();
if up == "Z" {
return Ok(0.0);
}
let (sign, body) = match up.strip_prefix('+') {
Some(b) => (1.0, b),
None => match up.strip_prefix('-') {
Some(b) => (-1.0, b),
None => return Err(WallClockError::Unrecognized(s.to_string())),
},
};
let digits: String = body.chars().filter(|c| c.is_ascii_digit()).collect();
let (h, m) = match digits.len() {
2 => (digits.parse::<f64>().unwrap_or(0.0), 0.0),
4 => {
let h: f64 = digits[..2]
.parse()
.map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
let m: f64 = digits[2..]
.parse()
.map_err(|_| WallClockError::Unrecognized(s.to_string()))?;
(h, m)
}
_ => return Err(WallClockError::Unrecognized(s.to_string())),
};
if !(0.0..24.0).contains(&h) || !(0.0..60.0).contains(&m) {
return Err(WallClockError::OutOfRange("offset out of range"));
}
Ok(sign * (h * 3600.0 + m * 60.0))
}
fn days_from_civil(y: i64, m: u32, d: u32) -> Option<i64> {
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let month_lens = [
31u32,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let ml = *month_lens.get((m as usize).saturating_sub(1))?;
if d > ml {
return None;
}
let y2 = if m <= 2 { y - 1 } else { y };
let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;
let yoe: i64 = y2 - era * 400;
let mp: i64 = m as i64 + if m as i64 > 2 { -3 } else { 9 };
let doy = (153 * mp + 2) / 5 + (d as i64) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some(era * 146_097 + doe - 719_468)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_date_is_zero() {
assert_eq!(WallClock::parse("1970-01-01").unwrap().epoch_secs(), 0.0);
assert_eq!(
WallClock::parse("1970-01-01T00:00:00Z")
.unwrap()
.epoch_secs(),
0.0
);
}
#[test]
fn a_known_moment_parses() {
assert_eq!(
WallClock::parse("2026-09-15").unwrap().epoch_secs(),
1_789_430_400.0
);
assert_eq!(
WallClock::parse("2026-09-15T00:00:00Z")
.unwrap()
.epoch_secs(),
1_789_430_400.0
);
assert_eq!(
WallClock::parse("2026-09-15 00:00:00")
.unwrap()
.epoch_secs(),
1_789_430_400.0
);
}
#[test]
fn time_of_day_and_fractions_count() {
let w = WallClock::parse("2026-09-15T17:00:00Z").unwrap();
assert_eq!(w.epoch_secs(), 1_789_430_400.0 + 17.0 * 3600.0);
let w2 = WallClock::parse("2026-09-15T17:00:00.5Z").unwrap();
assert_eq!(w2.epoch_secs(), 1_789_430_400.0 + 17.0 * 3600.0 + 0.5);
}
#[test]
fn offsets_shift_to_utc() {
let w = WallClock::parse("2026-09-15T17:00:00+02:00").unwrap();
assert_eq!(w.epoch_secs(), 1_789_430_400.0 + 15.0 * 3600.0);
let w2 = WallClock::parse("2026-09-15T12:00:00-0500").unwrap();
assert_eq!(w2.epoch_secs(), 1_789_430_400.0 + 17.0 * 3600.0);
assert_eq!(
WallClock::parse("2026-09-15T00:00:00z")
.unwrap()
.epoch_secs(),
1_789_430_400.0
);
}
#[test]
fn explicit_units_are_accepted_and_scaled() {
assert_eq!(
WallClock::parse("1757955600s").unwrap().epoch_secs(),
1_757_955_600.0
);
assert_eq!(
WallClock::parse("1757955600000ms").unwrap().epoch_secs(),
1_757_955_600.0
);
assert_eq!(
WallClock::parse("1757955600.5s").unwrap().epoch_secs(),
1_757_955_600.5
);
}
#[test]
fn impossible_dates_are_range_errors_not_silence() {
assert!(matches!(
WallClock::parse("2026-02-30"),
Err(WallClockError::OutOfRange("date"))
));
assert!(matches!(
WallClock::parse("2026-13-01"),
Err(WallClockError::OutOfRange(_))
));
assert!(matches!(
WallClock::parse("2026-09-15T25:00:00Z"),
Err(WallClockError::OutOfRange(_))
));
assert!(WallClock::parse("2024-02-29").is_ok());
assert!(matches!(
WallClock::parse("2026-02-29"),
Err(WallClockError::OutOfRange("date"))
));
}
#[test]
fn garbage_is_unrecognized() {
for bad in ["not a time", "15/09/2026", "sep 15", "2026-9-15", "", " "] {
assert!(
matches!(WallClock::parse(bad), Err(WallClockError::Unrecognized(_))),
"expected Unrecognized for {:?}",
bad
);
}
assert!(matches!(
WallClock::parse("1757955600"),
Err(WallClockError::Unrecognized(_))
));
}
}