use std::borrow::Cow;
pub fn normalize_timestamp(ts: &str) -> Cow<'_, str> {
if is_canonical(ts) {
return Cow::Borrowed(ts);
}
let t = ts.trim();
if t.len() >= 19 {
let b = t.as_bytes();
if b[4] == b'-'
&& b[7] == b'-'
&& matches!(b[10], b'T' | b'-' | b' ')
&& b[0..4].iter().all(u8::is_ascii_digit)
&& b[5..7].iter().all(u8::is_ascii_digit)
&& b[8..10].iter().all(u8::is_ascii_digit)
&& let Some(time) = normalize_time(&t[11..])
{
return Cow::Owned(format!("{}T{}Z", &t[0..10], time));
}
}
if let Some(time) = normalize_time(t) {
return Cow::Owned(time);
}
Cow::Borrowed(ts)
}
pub fn to_unix_ms(ts: &str) -> Option<i64> {
let b = ts.as_bytes();
if b.len() < 19 {
return None;
}
let take = |r: std::ops::Range<usize>| std::str::from_utf8(&b[r]).ok();
let y: i64 = take(0..4)?.parse().ok()?;
let mo: i64 = take(5..7)?.parse().ok()?;
let d: i64 = take(8..10)?.parse().ok()?;
let h: i64 = take(11..13)?.parse().ok()?;
let mi: i64 = take(14..16)?.parse().ok()?;
let s: i64 = take(17..19)?.parse().ok()?;
if b[4] != b'-'
|| b[7] != b'-'
|| !matches!(b[10], b'T' | b'-' | b' ')
|| b[13] != b':'
|| b[16] != b':'
{
return None;
}
let mut ms: i64 = 0;
if b.len() >= 23
&& (b[19] == b'.' || b[19] == b',')
&& let Some(v) = take(20..23).and_then(|s| s.parse::<i64>().ok())
{
ms = v;
}
let days = days_since_epoch(y, mo, d)?;
Some((days * 86_400 + h * 3600 + mi * 60 + s) * 1000 + ms)
}
pub fn days_since_epoch(y: i64, m: i64, d: i64) -> Option<i64> {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
let y = if m <= 2 { y - 1 } else { y };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some(era * 146_097 + doe - 719_468)
}
pub fn days_to_ymd(days: i64) -> (i32, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = (yoe + era * 400) as i32;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
pub fn time_of_day(ts: Option<&str>) -> String {
let Some(raw) = ts else {
return "00:00:00.000".to_string();
};
let norm = normalize_timestamp(raw);
let s = norm.as_ref();
let b = s.as_bytes();
if b.len() == 24 && b[10] == b'T' {
return s[11..23].to_string();
}
if b.len() == 12 && b[2] == b':' {
return s.to_string();
}
"00:00:00.000".to_string()
}
fn is_canonical(ts: &str) -> bool {
let b = ts.as_bytes();
b.len() == 24
&& b[4] == b'-'
&& b[7] == b'-'
&& b[10] == b'T'
&& b[13] == b':'
&& b[16] == b':'
&& b[19] == b'.'
&& b[23] == b'Z'
&& b[0..4].iter().all(u8::is_ascii_digit)
&& b[5..7].iter().all(u8::is_ascii_digit)
&& b[8..10].iter().all(u8::is_ascii_digit)
&& b[11..13].iter().all(u8::is_ascii_digit)
&& b[14..16].iter().all(u8::is_ascii_digit)
&& b[17..19].iter().all(u8::is_ascii_digit)
&& b[20..23].iter().all(u8::is_ascii_digit)
}
fn normalize_time(s: &str) -> Option<String> {
let b = s.as_bytes();
if b.len() < 8 || b[2] != b':' || b[5] != b':' {
return None;
}
if !b[0..2].iter().all(u8::is_ascii_digit)
|| !b[3..5].iter().all(u8::is_ascii_digit)
|| !b[6..8].iter().all(u8::is_ascii_digit)
{
return None;
}
let h: u32 = s[0..2].parse().ok()?;
let m: u32 = s[3..5].parse().ok()?;
let sec: u32 = s[6..8].parse().ok()?;
if h >= 24 || m >= 60 || sec >= 60 {
return None;
}
let mut ms = String::new();
if b.len() > 8 {
if b[8] != b'.' && b[8] != b',' {
return None;
}
for &c in &b[9..] {
if c.is_ascii_digit() {
ms.push(c as char);
} else {
break;
}
}
}
while ms.len() < 3 {
ms.push('0');
}
Some(format!(
"{}:{}:{}.{}",
&s[0..2],
&s[3..5],
&s[6..8],
&ms[..3]
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn already_canonical_is_borrowed() {
let ts = "2026-07-06T23:24:29.065Z";
assert!(matches!(normalize_timestamp(ts), Cow::Borrowed(_)));
assert_eq!(normalize_timestamp(ts), ts);
}
#[test]
fn classic_canboat_log_form() {
assert_eq!(
normalize_timestamp("2011-04-25-06:25:03.603"),
"2011-04-25T06:25:03.603Z"
);
}
#[test]
fn iso_with_comma_millis_gets_dot_and_z() {
assert_eq!(
normalize_timestamp("2018-10-16T22:25:25,166"),
"2018-10-16T22:25:25.166Z"
);
}
#[test]
fn iso_missing_z_gets_z() {
assert_eq!(
normalize_timestamp("2018-10-16T22:25:25.166"),
"2018-10-16T22:25:25.166Z"
);
}
#[test]
fn date_time_without_fraction_pads_millis() {
assert_eq!(
normalize_timestamp("2011-04-25-06:25:03"),
"2011-04-25T06:25:03.000Z"
);
}
#[test]
fn actisense_time_only_comma() {
assert_eq!(normalize_timestamp("17:33:21,107"), "17:33:21.107");
}
#[test]
fn airmar_time_only_no_fraction() {
assert_eq!(normalize_timestamp("20:11:00"), "20:11:00.000");
}
#[test]
fn short_fraction_pads_right() {
assert_eq!(normalize_timestamp("00:00:57,1"), "00:00:57.100");
}
#[test]
fn overlong_fraction_truncates_to_millis() {
assert_eq!(normalize_timestamp("00:00:57.123456"), "00:00:57.123");
}
#[test]
fn unrecognised_passes_through() {
assert_eq!(normalize_timestamp("not a timestamp"), "not a timestamp");
assert_eq!(normalize_timestamp(""), "");
}
#[test]
fn out_of_range_time_is_left_verbatim() {
assert_eq!(normalize_timestamp("25:00:00"), "25:00:00");
}
#[test]
fn time_of_day_from_date_bearing() {
assert_eq!(time_of_day(Some("2011-04-25-06:25:03.603")), "06:25:03.603");
assert_eq!(
time_of_day(Some("2026-07-06T23:24:29.065Z")),
"23:24:29.065"
);
}
#[test]
fn time_of_day_from_time_only_and_absent() {
assert_eq!(time_of_day(Some("17:33:21,107")), "17:33:21.107");
assert_eq!(time_of_day(Some("20:11:00")), "20:11:00.000");
assert_eq!(time_of_day(None), "00:00:00.000");
assert_eq!(time_of_day(Some("garbage")), "00:00:00.000");
}
#[test]
fn to_unix_ms_known_values() {
assert_eq!(to_unix_ms("1970-01-01T00:00:00.000"), Some(0));
assert_eq!(to_unix_ms("1970-01-01-00:00:01.000"), Some(1000));
assert_eq!(
to_unix_ms("2025-04-25T17:05:21.993Z"),
Some(1_745_600_721_993)
);
assert_eq!(to_unix_ms("17:33:21.107"), None);
assert_eq!(to_unix_ms("garbage"), None);
}
#[test]
fn days_to_ymd_known_values() {
assert_eq!(days_to_ymd(0), (1970, 1, 1));
assert_eq!(days_to_ymd(1), (1970, 1, 2));
assert_eq!(days_to_ymd(19245), (2022, 9, 10));
assert_eq!(days_to_ymd(19782), (2024, 2, 29)); }
#[test]
fn days_round_trip_through_epoch() {
for &(y, m, d) in &[(1970, 1, 1), (2024, 2, 29), (2026, 7, 9), (1601, 1, 1)] {
let days = days_since_epoch(y, m, d).unwrap();
assert_eq!(days_to_ymd(days), (y as i32, m as u32, d as u32));
}
}
}