use thiserror::Error;
const NANOS_PER_SECOND: i128 = 1_000_000_000;
const SECONDS_PER_DAY: i64 = 86_400;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum Rfc3339Error {
#[error("malformed RFC3339 date-time skeleton")]
Malformed,
#[error("RFC3339 field out of range: {0}")]
FieldRange(&'static str),
#[error("RFC3339 fractional seconds must be 1..=9 digits")]
Fraction,
#[error("RFC3339 offset must be Z or \u{b1}HH:MM")]
Offset,
#[error("trailing bytes after RFC3339 date-time")]
Trailing,
}
#[must_use]
pub fn now_unix_nanos() -> i128 {
match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(after) => i128::try_from(after.as_nanos()).unwrap_or(i128::MAX),
Err(before) => -i128::try_from(before.duration().as_nanos()).unwrap_or(i128::MAX),
}
}
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
let adjusted_year = if month <= 2 { year - 1 } else { year };
let era = adjusted_year.div_euclid(400);
let year_of_era = adjusted_year - era * 400;
let shifted_month = (month + 9) % 12;
let day_of_year = (153 * shifted_month + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let shifted = days + 719_468;
let era = shifted.div_euclid(146_097);
let day_of_era = shifted - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let shifted_month = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
let month = if shifted_month < 10 {
shifted_month + 3
} else {
shifted_month - 9
};
let civil_year = if month <= 2 { year + 1 } else { year };
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
(civil_year, month as u32, day as u32)
}
const fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
29
} else {
28
}
}
_ => 0,
}
}
#[must_use]
pub fn format_unix_nanos(unix_nanos: i128) -> String {
let seconds = unix_nanos.div_euclid(NANOS_PER_SECOND);
#[allow(clippy::cast_possible_truncation)]
let subsecond_nanos = unix_nanos.rem_euclid(NANOS_PER_SECOND) as u32;
let saturated = if unix_nanos >= 0 {
i64::MAX / 2
} else {
i64::MIN / 2
};
let seconds = i64::try_from(seconds).unwrap_or(saturated);
let days = seconds.div_euclid(SECONDS_PER_DAY);
let second_of_day = seconds.rem_euclid(SECONDS_PER_DAY);
let (year, month, day) = civil_from_days(days);
let hour = second_of_day / 3_600;
let minute = (second_of_day % 3_600) / 60;
let second = second_of_day % 60;
let mut formatted = format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}");
if subsecond_nanos > 0 {
let digits = format!("{subsecond_nanos:09}");
formatted.push('.');
formatted.push_str(digits.trim_end_matches('0'));
}
formatted.push('Z');
formatted
}
pub fn parse_rfc3339_to_unix_nanos(input: &str) -> Result<i128, Rfc3339Error> {
let bytes = input.as_bytes();
if bytes.len() < 19 {
return Err(Rfc3339Error::Malformed);
}
let digit = |index: usize| -> Result<i64, Rfc3339Error> {
let byte = bytes[index];
if byte.is_ascii_digit() {
Ok(i64::from(byte - b'0'))
} else {
Err(Rfc3339Error::Malformed)
}
};
let pair =
|index: usize| -> Result<i64, Rfc3339Error> { Ok(digit(index)? * 10 + digit(index + 1)?) };
if bytes[4] != b'-'
|| bytes[7] != b'-'
|| !matches!(bytes[10], b'T' | b't')
|| bytes[13] != b':'
|| bytes[16] != b':'
{
return Err(Rfc3339Error::Malformed);
}
let year = digit(0)? * 1_000 + digit(1)? * 100 + digit(2)? * 10 + digit(3)?;
let month = pair(5)?;
let day = pair(8)?;
let hour = pair(11)?;
let minute = pair(14)?;
let second = pair(17)?;
if !(1..=12).contains(&month) {
return Err(Rfc3339Error::FieldRange("month"));
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let month_days = i64::from(days_in_month(year, month as u32));
if !(1..=month_days).contains(&day) {
return Err(Rfc3339Error::FieldRange("day"));
}
if hour > 23 {
return Err(Rfc3339Error::FieldRange("hour"));
}
if minute > 59 {
return Err(Rfc3339Error::FieldRange("minute"));
}
if second > 59 {
return Err(Rfc3339Error::FieldRange("second"));
}
let mut cursor = 19;
let mut fraction_nanos: i128 = 0;
if bytes.get(cursor) == Some(&b'.') {
cursor += 1;
let fraction_start = cursor;
let mut scale = NANOS_PER_SECOND / 10;
while let Some(byte) = bytes.get(cursor) {
if !byte.is_ascii_digit() {
break;
}
if cursor - fraction_start >= 9 {
return Err(Rfc3339Error::Fraction);
}
fraction_nanos += i128::from(byte - b'0') * scale;
scale /= 10;
cursor += 1;
}
if cursor == fraction_start {
return Err(Rfc3339Error::Fraction);
}
}
let offset_seconds: i64 = match bytes.get(cursor) {
Some(b'Z' | b'z') => {
cursor += 1;
0
}
Some(sign @ (b'+' | b'-')) => {
if bytes.len() < cursor + 6 || bytes[cursor + 3] != b':' {
return Err(Rfc3339Error::Offset);
}
let offset_digit = |index: usize| -> Result<i64, Rfc3339Error> {
let byte = bytes[index];
if byte.is_ascii_digit() {
Ok(i64::from(byte - b'0'))
} else {
Err(Rfc3339Error::Offset)
}
};
let offset_hour = offset_digit(cursor + 1)? * 10 + offset_digit(cursor + 2)?;
let offset_minute = offset_digit(cursor + 4)? * 10 + offset_digit(cursor + 5)?;
if offset_hour > 23 || offset_minute > 59 {
return Err(Rfc3339Error::Offset);
}
let magnitude = offset_hour * 3_600 + offset_minute * 60;
cursor += 6;
if *sign == b'+' { magnitude } else { -magnitude }
}
_ => return Err(Rfc3339Error::Offset),
};
if cursor != bytes.len() {
return Err(Rfc3339Error::Trailing);
}
let civil_seconds =
days_from_civil(year, month, day) * SECONDS_PER_DAY + hour * 3_600 + minute * 60 + second;
let utc_seconds = civil_seconds - offset_seconds;
Ok(i128::from(utc_seconds) * NANOS_PER_SECOND + fraction_nanos)
}
#[cfg(test)]
mod tests {
use super::{Rfc3339Error, format_unix_nanos, now_unix_nanos, parse_rfc3339_to_unix_nanos};
const NANOS: i128 = 1_000_000_000;
#[test]
fn known_epoch_vectors_parse_exactly() {
let vectors: &[(&str, i128)] = &[
("1970-01-01T00:00:00Z", 0),
("1969-12-31T23:59:59Z", -NANOS),
("2000-03-01T00:00:00Z", 951_868_800 * NANOS),
("2024-02-29T12:00:00Z", 1_709_208_000 * NANOS),
("2026-01-01T00:00:00Z", 1_767_225_600 * NANOS),
("2026-01-01T00:00:00+05:30", 1_767_205_800 * NANOS),
(
"2026-01-01T00:00:00-07:00",
(1_767_225_600 + 25_200) * NANOS,
),
("1970-01-01T00:00:00-00:00", 0),
("1970-01-01T00:00:00.5Z", 500_000_000),
("1970-01-01T00:00:00.000000001Z", 1),
];
for (text, expected) in vectors {
assert_eq!(
parse_rfc3339_to_unix_nanos(text).as_ref(),
Ok(expected),
"vector {text}"
);
}
assert_eq!(parse_rfc3339_to_unix_nanos("1970-01-01t00:00:00z"), Ok(0));
}
#[test]
fn defective_inputs_are_rejected_loudly() {
let rejects: &[(&str, Rfc3339Error)] = &[
("2023-02-29T00:00:00Z", Rfc3339Error::FieldRange("day")),
("2026-13-01T00:00:00Z", Rfc3339Error::FieldRange("month")),
("2026-00-01T00:00:00Z", Rfc3339Error::FieldRange("month")),
("2026-01-00T00:00:00Z", Rfc3339Error::FieldRange("day")),
("2026-01-01T24:00:00Z", Rfc3339Error::FieldRange("hour")),
("2026-01-01T00:60:00Z", Rfc3339Error::FieldRange("minute")),
("2016-12-31T23:59:60Z", Rfc3339Error::FieldRange("second")),
("2026-01-01 00:00:00Z", Rfc3339Error::Malformed),
("2026-01-01T00:00:00", Rfc3339Error::Offset),
("2026-01-01T00:00:00+0530", Rfc3339Error::Offset),
("2026-01-01T00:00:00+24:00", Rfc3339Error::Offset),
("2026-01-01T00:00:00.Z", Rfc3339Error::Fraction),
("2026-01-01T00:00:00.0123456789Z", Rfc3339Error::Fraction),
("2026-01-01T00:00:00Zjunk", Rfc3339Error::Trailing),
("not a timestamp at all!", Rfc3339Error::Malformed),
];
for (text, expected) in rejects {
assert_eq!(
parse_rfc3339_to_unix_nanos(text).as_ref(),
Err(expected),
"reject {text}"
);
}
}
#[test]
fn format_matches_time_crate_conventions() {
assert_eq!(format_unix_nanos(0), "1970-01-01T00:00:00Z");
assert_eq!(format_unix_nanos(-NANOS), "1969-12-31T23:59:59Z");
assert_eq!(
format_unix_nanos(1_709_208_000 * NANOS),
"2024-02-29T12:00:00Z"
);
assert_eq!(format_unix_nanos(500_000_000), "1970-01-01T00:00:00.5Z");
assert_eq!(format_unix_nanos(1), "1970-01-01T00:00:00.000000001Z");
}
#[test]
fn parse_format_round_trips_across_regimes() {
let mut sample = -2_000_000_000_i64; while sample < 4_000_000_000 {
let nanos = i128::from(sample) * NANOS + 123_456_789;
let text = format_unix_nanos(nanos);
assert_eq!(
parse_rfc3339_to_unix_nanos(&text),
Ok(nanos),
"round-trip {text}"
);
sample += 86_399; }
}
#[test]
fn now_is_formattable_and_reparsable() {
let now = now_unix_nanos();
let text = format_unix_nanos(now);
assert_eq!(parse_rfc3339_to_unix_nanos(&text), Ok(now));
}
}