use std::cmp::Ordering;
use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
use crate::error::{NormativeReason, ValidationError};
#[derive(Clone)]
pub struct Timestamp {
raw: String,
instant: OffsetDateTime,
}
impl Timestamp {
pub fn parse(text: &str) -> Result<Self, ValidationError> {
let instant = parse_instant(text).map_err(|_| {
ValidationError::normative(
"/timestamp",
"rfc3339_profile",
NormativeReason::UnparseableTimestamp,
)
})?;
Ok(Self {
raw: text.to_string(),
instant,
})
}
pub fn as_str(&self) -> &str {
&self.raw
}
pub fn compare(&self, other: &Self) -> i8 {
match self.instant.cmp(&other.instant) {
Ordering::Less => -1,
Ordering::Equal => 0,
Ordering::Greater => 1,
}
}
pub fn saturating_add(&self, duration: std::time::Duration) -> Self {
let extra = time::Duration::new(duration.as_secs() as i64, duration.subsec_nanos() as i32);
match self.instant.checked_add(extra) {
Some(instant) => Self {
raw: format_utc(instant),
instant,
},
None => self.clone(),
}
}
pub fn duration_until(&self, later: &Self) -> std::time::Duration {
if later.instant <= self.instant {
return std::time::Duration::ZERO;
}
let delta = later.instant - self.instant;
let secs = delta.whole_seconds().max(0) as u64;
let nanos = delta.subsec_nanoseconds();
let nanos = if nanos < 0 { 0 } else { nanos as u32 };
std::time::Duration::new(secs, nanos)
}
}
fn format_utc(instant: OffsetDateTime) -> String {
let utc = instant.to_offset(UtcOffset::UTC);
let date = utc.date();
let t = utc.time();
let ns = t.nanosecond();
if ns == 0 {
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
date.year(),
u8::from(date.month()),
date.day(),
t.hour(),
t.minute(),
t.second()
)
} else {
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
date.year(),
u8::from(date.month()),
date.day(),
t.hour(),
t.minute(),
t.second(),
ns / 1000
)
}
}
impl PartialEq for Timestamp {
fn eq(&self, other: &Self) -> bool {
self.instant == other.instant
}
}
impl Eq for Timestamp {}
impl PartialOrd for Timestamp {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Timestamp {
fn cmp(&self, other: &Self) -> Ordering {
self.instant.cmp(&other.instant)
}
}
impl fmt::Debug for Timestamp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Timestamp")
.field("profile", &self.raw)
.finish()
}
}
impl fmt::Display for Timestamp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.raw)
}
}
impl Serialize for Timestamp {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.raw)
}
}
impl<'de> Deserialize<'de> for Timestamp {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Timestamp::parse(&raw).map_err(serde::de::Error::custom)
}
}
pub fn parse(text: &str) -> Result<Timestamp, ValidationError> {
Timestamp::parse(text)
}
pub fn compare(left: &str, right: &str) -> Result<i8, ValidationError> {
Ok(Timestamp::parse(left)?.compare(&Timestamp::parse(right)?))
}
fn parse_instant(text: &str) -> Result<OffsetDateTime, ()> {
if text.is_empty() || text != text.trim() {
return Err(());
}
let bytes = text.as_bytes();
if bytes.len() < 20 {
return Err(());
}
let year = parse_n_digits(&bytes[0..4], 4)?;
expect(bytes, 4, b'-')?;
let month = parse_n_digits(&bytes[5..7], 2)?;
expect(bytes, 7, b'-')?;
let day = parse_n_digits(&bytes[8..10], 2)?;
if bytes[10] != b'T' && bytes[10] != b't' {
return Err(());
}
let hour = parse_n_digits(&bytes[11..13], 2)?;
expect(bytes, 13, b':')?;
let minute = parse_n_digits(&bytes[14..16], 2)?;
expect(bytes, 16, b':')?;
let second = parse_n_digits(&bytes[17..19], 2)?;
if hour > 23 || minute > 59 {
return Err(());
}
if second == 60 {
return Err(());
}
if second > 60 {
return Err(());
}
let mut idx = 19;
let mut nanosecond = 0u32;
if idx < bytes.len() && bytes[idx] == b'.' {
idx += 1;
let start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == start {
return Err(());
}
let digits = &text[start..idx];
let mut padded = digits.to_string();
while padded.len() < 6 {
padded.push('0');
}
if padded.len() > 6 {
padded.truncate(6);
}
let microsecond: u32 = padded.parse().map_err(|_| ())?;
nanosecond = microsecond.checked_mul(1000).ok_or(())?;
}
if idx >= bytes.len() {
return Err(());
}
let offset = if bytes[idx] == b'Z' || bytes[idx] == b'z' {
if idx + 1 != bytes.len() {
return Err(());
}
UtcOffset::UTC
} else if bytes[idx] == b'+' || bytes[idx] == b'-' {
let sign = if bytes[idx] == b'+' { 1i8 } else { -1i8 };
idx += 1;
if idx + 5 != bytes.len() {
return Err(());
}
let off_h = parse_n_digits(&bytes[idx..idx + 2], 2)?;
if bytes[idx + 2] != b':' {
return Err(());
}
let off_m = parse_n_digits(&bytes[idx + 3..idx + 5], 2)?;
if off_h > 23 || off_m > 59 {
return Err(());
}
UtcOffset::from_hms(sign * off_h as i8, sign * off_m as i8, 0).map_err(|_| ())?
} else {
return Err(());
};
let month = Month::try_from(month as u8).map_err(|_| ())?;
let date = Date::from_calendar_date(year as i32, month, day as u8).map_err(|_| ())?;
let time =
Time::from_hms_nano(hour as u8, minute as u8, second as u8, nanosecond).map_err(|_| ())?;
Ok(PrimitiveDateTime::new(date, time)
.assume_offset(offset)
.to_offset(UtcOffset::UTC))
}
fn expect(bytes: &[u8], idx: usize, want: u8) -> Result<(), ()> {
if idx < bytes.len() && bytes[idx] == want {
Ok(())
} else {
Err(())
}
}
fn parse_n_digits(slice: &[u8], n: usize) -> Result<u32, ()> {
if slice.len() != n || !slice.iter().all(|b| b.is_ascii_digit()) {
return Err(());
}
let mut value = 0u32;
for b in slice {
value = value * 10 + u32::from(b - b'0');
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
fn reject(label: &str, text: &str) {
assert!(
Timestamp::parse(text).is_err(),
"{label}: expected reject of timestamp"
);
}
fn accept(label: &str, text: &str) {
assert!(
Timestamp::parse(text).is_ok(),
"{label}: expected accept of timestamp"
);
}
#[test]
fn profile_self_test() {
reject("empty", "");
reject("whitespace", " ");
reject("garbage", "not-a-timestamp");
reject("date-only", "2026-08-15");
reject("naive", "2026-08-15T17:00:00");
reject("space-separator", "2026-08-15 17:00:00Z");
reject("basic-date-time", "20260815T170000Z");
reject("week-date", "2026-W33-6T17:00:00Z");
reject("ordinal-date", "2026-227T17:00:00Z");
reject("missing-seconds", "2026-08-15T17:00Z");
reject("offset-without-colon", "2026-08-15T17:00:00+0000");
reject("comma-fraction", "2026-08-15T17:00:00,123Z");
reject("invalid-calendar", "2026-02-30T17:00:00Z");
reject("hour-24", "2026-08-15T24:00:00Z");
reject("leap-second-utc", "2016-12-31T23:59:60Z");
reject("leap-second-offset", "2016-12-31T23:59:60+00:00");
accept("zulu", "2026-08-15T17:00:00Z");
accept("lowercase", "2026-08-15t17:00:00z");
accept("numeric-offset", "2026-08-15T17:00:00+00:00");
accept("negative-offset", "2026-08-15T13:00:00-04:00");
accept("fractional-seconds", "2026-08-15T17:00:00.123Z");
assert_eq!(
compare("1970-01-01T00:00:00Z", "1970-01-01T00:00:00+00:00").unwrap(),
0
);
assert_eq!(
compare("1970-01-01T01:00:00+01:00", "1970-01-01T00:00:00Z").unwrap(),
0
);
assert_eq!(
compare("2026-08-15T17:00:00+00:00", "2026-08-15T17:00:00Z").unwrap(),
0
);
assert_eq!(
compare("1970-01-01T00:00:00Z", "1970-01-01T00:00:01Z").unwrap(),
-1
);
assert_eq!(
compare("1970-01-01T00:00:01Z", "1970-01-01T00:00:00Z").unwrap(),
1
);
assert_eq!(
compare("2026-08-15T17:00:00.100Z", "2026-08-15T17:00:00Z").unwrap(),
1
);
assert_eq!(
compare("2026-08-15T16:00:10Z", "2026-08-15T17:00:00+01:00").unwrap(),
1
);
}
#[test]
fn equivalent_offsets_are_equal_on_the_newtype() {
let z = Timestamp::parse("2026-08-15T17:00:00Z").unwrap();
let offset = Timestamp::parse("2026-08-15T17:00:00+00:00").unwrap();
assert_eq!(z, offset);
assert_eq!(z.as_str(), "2026-08-15T17:00:00Z");
assert_eq!(offset.as_str(), "2026-08-15T17:00:00+00:00");
}
#[test]
fn seven_digit_fractions_compare_equal_after_six_digit_truncate() {
assert_eq!(
compare(
"2026-08-15T17:00:00.1234567Z",
"2026-08-15T17:00:00.1234568Z"
)
.unwrap(),
0
);
}
#[test]
fn fraction_compare_matches_pinned_six_digit_oracle() {
assert_eq!(
compare("2026-08-15T17:00:00.123Z", "2026-08-15T17:00:00.123000Z").unwrap(),
0
);
assert_eq!(
compare(
"2026-08-15T17:00:00.1234569Z",
"2026-08-15T17:00:00.1234560Z"
)
.unwrap(),
0
);
assert_eq!(
compare("2026-08-15T17:00:00.123456Z", "2026-08-15T17:00:00.123457Z").unwrap(),
-1
);
assert_eq!(
compare(
"2026-08-15T17:00:00.1234559Z",
"2026-08-15T17:00:00.1234560Z"
)
.unwrap(),
-1
);
assert_eq!(
compare("2026-08-15T17:00:00.100Z", "2026-08-15T17:00:00Z").unwrap(),
1
);
}
#[test]
fn rfc3339_six_digit_compare_pads_truncates_and_normalizes_offsets() {
assert_eq!(
compare("2026-08-15T17:00:00.1Z", "2026-08-15T17:00:00.100000Z").unwrap(),
0
);
assert_eq!(
compare("2026-08-15T17:00:00.12Z", "2026-08-15T17:00:00.120000Z").unwrap(),
0
);
assert_eq!(
compare(
"2026-08-15T17:00:00.123456999Z",
"2026-08-15T17:00:00.123456000Z"
)
.unwrap(),
0
);
assert_eq!(
compare(
"2026-08-15T17:00:00.123456000Z",
"2026-08-15T17:00:00.123457000Z"
)
.unwrap(),
-1
);
assert_eq!(
compare(
"2026-08-15T17:00:00.123456Z",
"2026-08-15T18:00:00.123456+01:00"
)
.unwrap(),
0
);
let left = Timestamp::parse("2026-08-15T17:00:00.1234567Z").unwrap();
let right = Timestamp::parse("2026-08-15T17:00:00.1234568Z").unwrap();
assert_eq!(left.compare(&right), 0);
assert_eq!(left, right);
}
#[test]
fn one_millisecond_is_a_distinct_logical_instant() {
let a = Timestamp::parse("2026-08-15T16:05:00.001000Z").unwrap();
let b = Timestamp::parse("2026-08-15T16:05:00.002000Z").unwrap();
assert_ne!(a, b);
assert_eq!(a.compare(&b), -1);
let micro_a = Timestamp::parse("2026-08-15T16:05:00.000001Z").unwrap();
let micro_b = Timestamp::parse("2026-08-15T16:05:00.000002Z").unwrap();
assert_ne!(micro_a, micro_b);
assert_eq!(micro_a.compare(µ_b), -1);
let padded = Timestamp::parse("2026-08-15T16:05:00.001Z").unwrap();
assert_eq!(a, padded);
}
#[test]
fn saturating_add_and_duration_until_round_trip() {
let start = Timestamp::parse("2026-08-15T16:00:00Z").unwrap();
let later = start.saturating_add(std::time::Duration::from_secs(90));
assert_eq!(later.as_str(), "2026-08-15T16:01:30Z");
assert_eq!(
start.duration_until(&later),
std::time::Duration::from_secs(90)
);
assert_eq!(later.duration_until(&start), std::time::Duration::ZERO);
}
}