use core::str::FromStr;
use crate::validate::{self, CivilSecondPolicy, FieldError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct NdmEpoch {
pub(crate) year: i32,
pub(crate) month: u32,
pub(crate) day: u32,
pub(crate) hour: u32,
pub(crate) minute: u32,
pub(crate) second: u32,
pub(crate) microsecond: u32,
pub(crate) femtosecond: u32,
}
impl NdmEpoch {
pub(crate) fn parse(
text: &str,
second_policy: CivilSecondPolicy,
) -> Result<NdmEpoch, FieldError> {
let raw = text.trim();
let text = raw.strip_suffix('Z').unwrap_or(raw);
let (date, time) = text
.split_once('T')
.ok_or(FieldError::Missing { field: "epoch" })?;
let mut date_parts = date.split('-');
let year: i32 = epoch_int(date_parts.next())?;
let month: u32 = epoch_int(date_parts.next())?;
let day: u32 = epoch_int(date_parts.next())?;
if let Some(extra) = date_parts.next() {
return Err(FieldError::IntParse {
field: "epoch",
value: extra.to_string(),
});
}
let mut time_parts = time.split(':');
let hour: u32 = epoch_int(time_parts.next())?;
let minute: u32 = epoch_int(time_parts.next())?;
let sec_field = time_parts
.next()
.ok_or(FieldError::Missing { field: "epoch" })?;
if let Some(extra) = time_parts.next() {
return Err(FieldError::FloatParse {
field: "epoch",
value: extra.to_string(),
});
}
let civil = validate::civil_datetime_with_femtosecond_policy(
i64::from(year),
i64::from(month),
i64::from(day),
i64::from(hour),
i64::from(minute),
sec_field,
second_policy,
)?;
Ok(NdmEpoch {
year: civil.year as i32,
month: civil.month,
day: civil.day,
hour: civil.hour,
minute: civil.minute,
second: civil.second,
microsecond: civil.microsecond,
femtosecond: civil.femtosecond,
})
}
#[allow(clippy::wrong_self_convention)]
pub(crate) fn to_iso8601(&self) -> String {
let fractional = if self.femtosecond == 0 {
format!("{:06}", self.microsecond)
} else {
format!("{:06}{:09}", self.microsecond, self.femtosecond)
};
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{}",
self.year, self.month, self.day, self.hour, self.minute, self.second, fractional
)
}
}
fn epoch_int<T>(value: Option<&str>) -> Result<T, FieldError>
where
T: FromStr,
{
let value = value.ok_or(FieldError::Missing { field: "epoch" })?;
validate::strict_int::<T>(value, "epoch")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_epoch_components_with_trailing_z() {
assert_eq!(
NdmEpoch::parse("2026-06-17T04:32:52.099296Z", CivilSecondPolicy::UtcLike).unwrap(),
NdmEpoch {
year: 2026,
month: 6,
day: 17,
hour: 4,
minute: 32,
second: 52,
microsecond: 99_296,
femtosecond: 0,
}
);
}
#[test]
fn six_digit_epoch_parses_with_zero_femtosecond_remainder() {
for text in [
"2026-06-17T04:32:52.099296",
"2026-06-17T04:32:52.5",
"2026-06-17T04:32:52",
] {
let epoch = NdmEpoch::parse(text, CivilSecondPolicy::UtcLike).unwrap();
assert_eq!(epoch.femtosecond, 0, "{text} must carry no femtoseconds");
}
assert_eq!(
NdmEpoch::parse("2026-06-17T04:32:52.5", CivilSecondPolicy::UtcLike)
.unwrap()
.microsecond,
500_000,
);
}
#[test]
fn sub_microsecond_digits_split_without_rounding() {
assert_eq!(
NdmEpoch::parse("2026-06-17T04:32:52.9999995", CivilSecondPolicy::UtcLike).unwrap(),
NdmEpoch {
year: 2026,
month: 6,
day: 17,
hour: 4,
minute: 32,
second: 52,
microsecond: 999_999,
femtosecond: 500_000_000,
}
);
}
#[test]
fn sixteenth_fractional_digit_rounds_and_carries() {
assert_eq!(
NdmEpoch::parse(
"2026-06-17T23:59:59.9999999999999995",
CivilSecondPolicy::Continuous,
)
.unwrap(),
NdmEpoch {
year: 2026,
month: 6,
day: 18,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
femtosecond: 0,
}
);
}
#[test]
fn to_iso8601_round_trips_epoch_value() {
let epoch =
NdmEpoch::parse("2026-06-17T04:32:52.099296Z", CivilSecondPolicy::Continuous).unwrap();
let encoded = epoch.to_iso8601();
assert_eq!(encoded, "2026-06-17T04:32:52.099296");
assert_eq!(
NdmEpoch::parse(&encoded, CivilSecondPolicy::Continuous).unwrap(),
epoch
);
}
#[test]
fn to_iso8601_round_trips_femtosecond_epoch_value() {
let epoch =
NdmEpoch::parse("2026-06-17T04:32:52.9999995", CivilSecondPolicy::Continuous).unwrap();
let encoded = epoch.to_iso8601();
assert_eq!(encoded, "2026-06-17T04:32:52.999999500000000");
assert_eq!(
NdmEpoch::parse(&encoded, CivilSecondPolicy::Continuous).unwrap(),
epoch
);
}
#[test]
fn utc_like_accepts_leap_second_label() {
assert_eq!(
NdmEpoch::parse("2016-12-31T23:59:60.000000Z", CivilSecondPolicy::UtcLike,).unwrap(),
NdmEpoch {
year: 2016,
month: 12,
day: 31,
hour: 23,
minute: 59,
second: 60,
microsecond: 0,
femtosecond: 0,
}
);
}
#[test]
fn continuous_time_rejects_leap_second_label() {
assert_eq!(
NdmEpoch::parse("2016-12-31T23:59:60.000000Z", CivilSecondPolicy::Continuous,),
Err(FieldError::InvalidCivilTime {
field: "civil datetime",
hour: 23,
minute: 59,
second: 60.0,
})
);
}
#[test]
fn malformed_epoch_without_t_yields_field_error() {
assert_eq!(
NdmEpoch::parse("2026-06-17 04:32:52.099296Z", CivilSecondPolicy::UtcLike),
Err(FieldError::Missing { field: "epoch" })
);
}
#[test]
fn surplus_date_or_time_segments_are_rejected() {
assert!(
NdmEpoch::parse("2026-06-17-05T04:32:52.099296", CivilSecondPolicy::UtcLike).is_err()
);
assert!(
NdmEpoch::parse("2026-06-17T04:32:52.099296:00", CivilSecondPolicy::UtcLike).is_err()
);
}
}