#[must_use]
pub(crate) fn nanos_to_iso8601(nanos: u64) -> String {
const NANOS_PER_SEC: u64 = 1_000_000_000;
const SECS_PER_MIN: u64 = 60;
const SECS_PER_HOUR: u64 = 3600;
const SECS_PER_DAY: u64 = 86400;
let total_secs = nanos / NANOS_PER_SEC;
let millis = (nanos % NANOS_PER_SEC) / 1_000_000;
let mut days = total_secs / SECS_PER_DAY;
let day_secs = total_secs % SECS_PER_DAY;
let hours = day_secs / SECS_PER_HOUR;
let minutes = (day_secs % SECS_PER_HOUR) / SECS_PER_MIN;
let seconds = day_secs % SECS_PER_MIN;
days += 719_468; let era = days / 146_097;
let doe = days - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z")
}
#[must_use]
pub(crate) fn micros_to_iso8601(micros: u64) -> String {
nanos_to_iso8601(micros.saturating_mul(1000))
}
pub(crate) fn millis_to_iso8601(ms: u64) -> String {
nanos_to_iso8601(ms.saturating_mul(1_000_000))
}
#[must_use]
pub(crate) fn parse_utc_hour(ts: &str) -> Option<u8> {
if !ts.is_ascii() {
return None;
}
let bytes = ts.as_bytes();
if bytes.len() < 13 {
return None;
}
if bytes[10] != b'T' && bytes[10] != b' ' {
return None;
}
let h1 = bytes[11].checked_sub(b'0').filter(|&d| d <= 9)?;
let h2 = bytes[12].checked_sub(b'0').filter(|&d| d <= 9)?;
let hour = h1 * 10 + h2;
if hour >= 24 {
return None;
}
if !ts.ends_with('Z') {
return None;
}
Some(hour)
}
#[must_use]
pub(crate) fn parse_utc_month(ts: &str) -> Option<u8> {
if !ts.is_ascii() {
return None;
}
let bytes = ts.as_bytes();
if bytes.len() < 7 {
return None;
}
if bytes[4] != b'-' {
return None;
}
let m1 = bytes[5].checked_sub(b'0').filter(|&d| d <= 9)?;
let m2 = bytes[6].checked_sub(b'0').filter(|&d| d <= 9)?;
let month = m1 * 10 + m2;
if !(1..=12).contains(&month) {
return None;
}
if !ts.ends_with('Z') {
return None;
}
Some(month - 1) }
pub(crate) fn parse_iso8601_utc_to_ms(s: &str) -> Result<u64, String> {
let s = s.trim();
if !s.ends_with('Z') {
return Err("only UTC timestamps (ending with 'Z') are supported".to_string());
}
let without_z = &s[..s.len() - 1];
let (date_part, time_part) = split_date_time(without_z)?;
let (year, month, day) = parse_date_ymd(date_part)?;
let (hours, minutes, seconds, millis) = parse_time_hms(time_part)?;
let days = civil_date_to_days(year, month, day);
Ok(days * 86_400_000 + hours * 3_600_000 + minutes * 60_000 + seconds * 1_000 + millis)
}
fn split_date_time(without_z: &str) -> Result<(&str, &str), String> {
if let Some(pos) = without_z.find('T') {
Ok((&without_z[..pos], &without_z[pos + 1..]))
} else if let Some(pos) = without_z.find(' ') {
Ok((&without_z[..pos], &without_z[pos + 1..]))
} else {
Err("expected 'T' or space between date and time".to_string())
}
}
fn parse_date_ymd(date_part: &str) -> Result<(u64, u64, u64), String> {
let date_parts: Vec<&str> = date_part.split('-').collect();
if date_parts.len() != 3 {
return Err("expected date format YYYY-MM-DD".to_string());
}
let year: u64 = date_parts[0]
.parse()
.map_err(|_| "invalid year".to_string())?;
let month: u64 = date_parts[1]
.parse()
.map_err(|_| "invalid month".to_string())?;
let day: u64 = date_parts[2]
.parse()
.map_err(|_| "invalid day".to_string())?;
if year < 1970 {
return Err("year must be >= 1970".to_string());
}
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return Err("month or day out of range".to_string());
}
Ok((year, month, day))
}
fn parse_time_hms(time_part: &str) -> Result<(u64, u64, u64, u64), String> {
let (time_no_frac, millis) = parse_fractional_seconds(time_part)?;
let time_parts: Vec<&str> = time_no_frac.split(':').collect();
if time_parts.len() != 3 {
return Err("expected time format HH:MM:SS".to_string());
}
let hours: u64 = time_parts[0]
.parse()
.map_err(|_| "invalid hours".to_string())?;
let minutes: u64 = time_parts[1]
.parse()
.map_err(|_| "invalid minutes".to_string())?;
let seconds: u64 = time_parts[2]
.parse()
.map_err(|_| "invalid seconds".to_string())?;
if hours >= 24 || minutes >= 60 || seconds >= 60 {
return Err("time values out of range".to_string());
}
Ok((hours, minutes, seconds, millis))
}
fn parse_fractional_seconds(time_part: &str) -> Result<(&str, u64), String> {
let Some(dot_pos) = time_part.find('.') else {
return Ok((time_part, 0u64));
};
let frac = &time_part[dot_pos + 1..];
let digits = frac.len().min(3);
let ms: u64 = frac[..digits]
.parse::<u64>()
.map_err(|_| "invalid fractional seconds".to_string())?;
let normalized = ms * 10u64.pow(3 - digits as u32);
Ok((&time_part[..dot_pos], normalized))
}
fn civil_date_to_days(year: u64, month: u64, day: u64) -> u64 {
let (y, m) = if month <= 2 {
(year - 1, month + 9)
} else {
(year, month - 3)
};
let era = y / 400;
let yoe = y - era * 400;
let doy = (153 * m + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nanos_basic() {
let ts = nanos_to_iso8601(1_720_621_921_123_000_000);
assert_eq!(ts, "2024-07-10T14:32:01.123Z");
}
#[test]
fn micros_basic() {
let ts = micros_to_iso8601(1_720_621_921_123_000);
assert_eq!(ts, "2024-07-10T14:32:01.123Z");
}
#[test]
fn zero_epoch() {
assert_eq!(nanos_to_iso8601(0), "1970-01-01T00:00:00.000Z");
}
#[test]
fn parse_utc_hour_canonical() {
assert_eq!(parse_utc_hour("2025-07-10T14:32:01.123Z"), Some(14));
assert_eq!(parse_utc_hour("2025-07-10T00:00:00.000Z"), Some(0));
assert_eq!(parse_utc_hour("2025-07-10T23:59:59.999Z"), Some(23));
}
#[test]
fn parse_utc_hour_no_fraction() {
assert_eq!(parse_utc_hour("2025-07-10T09:30:00Z"), Some(9));
}
#[test]
fn parse_utc_hour_space_separator() {
assert_eq!(parse_utc_hour("2025-07-10 14:32:01.123Z"), Some(14));
}
#[test]
fn parse_utc_hour_rejects_nonutc_offset() {
assert_eq!(parse_utc_hour("2025-07-10T14:32:01.123+02:00"), None);
assert_eq!(parse_utc_hour("2025-07-10T14:32:01-05:00"), None);
}
#[test]
fn parse_utc_hour_rejects_truncated_string() {
assert_eq!(parse_utc_hour(""), None);
assert_eq!(parse_utc_hour("2025-07-10"), None);
assert_eq!(parse_utc_hour("2025-07-10T14"), None); }
#[test]
fn parse_utc_hour_rejects_invalid_separator() {
assert_eq!(parse_utc_hour("2025-07-10_14:32:01Z"), None);
assert_eq!(parse_utc_hour("2025-07-10-14:32:01Z"), None);
}
#[test]
fn parse_utc_hour_rejects_non_numeric_hour() {
assert_eq!(parse_utc_hour("2025-07-10Tab:32:01Z"), None);
assert_eq!(parse_utc_hour("2025-07-10T :32:01Z"), None);
}
#[test]
fn parse_utc_hour_rejects_hour_24_or_above() {
assert_eq!(parse_utc_hour("2025-07-10T24:00:00Z"), None);
assert_eq!(parse_utc_hour("2025-07-10T99:00:00Z"), None);
}
#[test]
fn parse_utc_hour_rejects_missing_trailing_z() {
assert_eq!(parse_utc_hour("2025-07-10T14:32:01.123"), None);
}
#[test]
fn parse_utc_hour_rejects_non_ascii() {
assert_eq!(parse_utc_hour("2025-07-10T14\u{00E9}:32:01Z"), None);
}
#[test]
fn parse_utc_month_canonical() {
assert_eq!(parse_utc_month("2025-01-10T14:32:01.123Z"), Some(0)); assert_eq!(parse_utc_month("2025-06-15T00:00:00.000Z"), Some(5)); assert_eq!(parse_utc_month("2025-07-10T14:32:01.123Z"), Some(6)); assert_eq!(parse_utc_month("2025-12-31T23:59:59.999Z"), Some(11)); }
#[test]
fn parse_utc_month_all_months() {
for m in 1..=12_u8 {
let ts = format!("2025-{m:02}-10T12:00:00Z");
assert_eq!(parse_utc_month(&ts), Some(m - 1), "month {m:02}");
}
}
#[test]
fn parse_utc_month_rejects_month_00() {
assert_eq!(parse_utc_month("2025-00-10T14:32:01Z"), None);
}
#[test]
fn parse_utc_month_rejects_month_13() {
assert_eq!(parse_utc_month("2025-13-10T14:32:01Z"), None);
}
#[test]
fn parse_utc_month_rejects_truncated() {
assert_eq!(parse_utc_month(""), None);
assert_eq!(parse_utc_month("2025-0"), None);
assert_eq!(parse_utc_month("2025"), None);
}
#[test]
fn parse_utc_month_rejects_non_utc() {
assert_eq!(parse_utc_month("2025-07-10T14:32:01+02:00"), None);
assert_eq!(parse_utc_month("2025-07-10T14:32:01-05:00"), None);
}
#[test]
fn parse_utc_month_rejects_non_ascii() {
assert_eq!(parse_utc_month("2025\u{00E9}07-10T14:32:01Z"), None);
}
#[test]
fn parse_utc_month_rejects_non_numeric() {
assert_eq!(parse_utc_month("2025-ab-10T14:32:01Z"), None);
}
#[test]
fn parse_utc_month_rejects_missing_dash() {
assert_eq!(parse_utc_month("2025007-10T14:32:01Z"), None);
}
#[test]
fn parse_iso8601_round_trips_with_nanos_to_iso8601() {
let nanos = 1_720_621_921_123_000_000u64; let iso = nanos_to_iso8601(nanos);
let ms = parse_iso8601_utc_to_ms(&iso).unwrap();
assert_eq!(ms, nanos / 1_000_000);
}
#[test]
fn parse_iso8601_epoch_zero() {
let ms = parse_iso8601_utc_to_ms("1970-01-01T00:00:00.000Z").unwrap();
assert_eq!(ms, 0);
}
#[test]
fn parse_iso8601_without_fractional_seconds() {
let ms = parse_iso8601_utc_to_ms("2025-07-10T14:32:01Z").unwrap();
assert_eq!(ms % 1000, 0);
}
#[test]
fn parse_iso8601_space_separator() {
let ms_t = parse_iso8601_utc_to_ms("2025-07-10T14:32:01.123Z").unwrap();
let ms_sp = parse_iso8601_utc_to_ms("2025-07-10 14:32:01.123Z").unwrap();
assert_eq!(ms_t, ms_sp);
}
#[test]
fn parse_iso8601_rejects_non_utc() {
assert!(parse_iso8601_utc_to_ms("2025-07-10T14:32:01.123+02:00").is_err());
assert!(parse_iso8601_utc_to_ms("2025-07-10T14:32:01.123").is_err());
}
#[test]
fn parse_iso8601_rejects_pre_epoch() {
assert!(parse_iso8601_utc_to_ms("1969-12-31T23:59:59Z").is_err());
}
#[test]
fn parse_iso8601_rejects_invalid_month_day() {
assert!(parse_iso8601_utc_to_ms("2025-13-01T00:00:00Z").is_err());
assert!(parse_iso8601_utc_to_ms("2025-00-01T00:00:00Z").is_err());
assert!(parse_iso8601_utc_to_ms("2025-06-32T00:00:00Z").is_err());
}
#[test]
fn parse_iso8601_rejects_invalid_time() {
assert!(parse_iso8601_utc_to_ms("2025-06-01T24:00:00Z").is_err());
assert!(parse_iso8601_utc_to_ms("2025-06-01T12:60:00Z").is_err());
assert!(parse_iso8601_utc_to_ms("2025-06-01T12:00:60Z").is_err());
}
#[test]
fn parse_iso8601_rejects_malformed_date_field_count() {
assert!(parse_iso8601_utc_to_ms("2025/07/10T14:32:01Z").is_err());
}
}