use broadcast_common::units::MICROS_PER_SEC;
use jiff::Span;
use jiff::civil::{Date, DateTime};
use jiff::tz::{Offset, TimeZone};
use thiserror::Error;
const NANOS_DIGITS: usize = 9;
const MICROS_DIGITS: usize = 6;
const SECONDS_PER_HOUR: i32 = 3_600;
const SECONDS_PER_MINUTE: i32 = 60;
const MAX_ZONE_HOUR: u8 = 14;
const MAX_MINUTE: u8 = 59;
const MAX_SECOND: u8 = 59;
const END_OF_DAY_HOUR: u8 = 24;
const FIRST_YEAR: i16 = 1;
const YEAR_DIGITS: usize = 4;
pub const NTP_RESOLUTION_US: u64 = 1_000;
const NTP_FRACTION_BITS: u32 = 32;
const NTP_TIMESTAMP_LEN: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum DateTimeError {
#[error("not an accepted xs:dateTime: {0}")]
Syntax(&'static str),
#[error("xs:dateTime field out of range: {0}")]
Field(&'static str),
#[error("instant outside the representable range")]
OutOfRange,
#[error("not an 8-byte NTP timestamp")]
Ntp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct XsDateTime {
pub unix_us: i64,
pub fraction_digits: u8,
}
impl XsDateTime {
#[must_use]
pub fn resolution_us(&self) -> u64 {
let digits = usize::from(self.fraction_digits).min(MICROS_DIGITS);
let mut res = (MICROS_PER_SEC as i64).unsigned_abs();
if self.fraction_digits > 0 {
res = 10u64.pow(u32::try_from(MICROS_DIGITS - digits).unwrap_or(0));
}
res
}
}
struct Cursor<'a> {
b: &'a [u8],
i: usize,
}
impl Cursor<'_> {
fn eat(&mut self, c: u8) -> bool {
if self.b.get(self.i) == Some(&c) {
self.i += 1;
true
} else {
false
}
}
fn digits(&mut self, n: usize) -> Result<u32, DateTimeError> {
let end = self.i + n;
let slice = self
.b
.get(self.i..end)
.ok_or(DateTimeError::Syntax("truncated"))?;
if !slice.iter().all(u8::is_ascii_digit) {
return Err(DateTimeError::Syntax("expected digits"));
}
self.i = end;
Ok(slice
.iter()
.fold(0u32, |acc, d| acc * 10 + u32::from(d - b'0')))
}
fn expect(&mut self, c: u8, what: &'static str) -> Result<(), DateTimeError> {
if self.eat(c) {
Ok(())
} else {
Err(DateTimeError::Syntax(what))
}
}
}
fn narrow<T: TryFrom<u32>>(v: u32) -> Result<T, DateTimeError> {
T::try_from(v).map_err(|_| DateTimeError::Field("value"))
}
pub fn parse_xs_datetime(text: &str) -> Result<XsDateTime, DateTimeError> {
let trimmed = text.trim_matches([' ', '\t', '\n', '\r']);
let mut c = Cursor {
b: trimmed.as_bytes(),
i: 0,
};
let year: i16 = narrow(c.digits(YEAR_DIGITS)?)?;
if year < FIRST_YEAR {
return Err(DateTimeError::Field("year"));
}
c.expect(b'-', "expected '-' after the year")?;
let month: u8 = narrow(c.digits(2)?)?;
c.expect(b'-', "expected '-' after the month")?;
let day: u8 = narrow(c.digits(2)?)?;
c.expect(b'T', "expected upper-case 'T'")?;
let hour: u8 = narrow(c.digits(2)?)?;
c.expect(b':', "expected ':' after the hour")?;
let minute: u8 = narrow(c.digits(2)?)?;
c.expect(b':', "expected ':' after the minute")?;
let second: u8 = narrow(c.digits(2)?)?;
let mut fraction_digits = 0usize;
let mut nanos: i32 = 0;
let mut fraction_zero = true;
if c.eat(b'.') {
let start = c.i;
while c.b.get(c.i).is_some_and(u8::is_ascii_digit) {
c.i += 1;
}
let frac = &c.b[start..c.i];
if frac.is_empty() {
return Err(DateTimeError::Syntax("empty fraction"));
}
fraction_digits = frac.len();
fraction_zero = frac.iter().all(|d| *d == b'0');
for d in frac.iter().take(NANOS_DIGITS) {
nanos = nanos * 10 + i32::from(d - b'0');
}
for _ in frac.len().min(NANOS_DIGITS)..NANOS_DIGITS {
nanos *= 10;
}
}
let offset = parse_zone(&mut c)?;
if c.i != c.b.len() {
return Err(DateTimeError::Syntax("trailing characters"));
}
if month == 0 || month > 12 {
return Err(DateTimeError::Field("month"));
}
let date = Date::new(
year,
i8::try_from(month).map_err(|_| DateTimeError::Field("month"))?,
i8::try_from(day).map_err(|_| DateTimeError::Field("day"))?,
)
.map_err(|_| DateTimeError::Field("day"))?;
if minute > MAX_MINUTE || second > MAX_SECOND {
return Err(DateTimeError::Field("minute or second"));
}
let (date, hour) = if hour == END_OF_DAY_HOUR {
if minute != 0 || second != 0 || !fraction_zero {
return Err(DateTimeError::Field("hour 24 with a non-zero remainder"));
}
let next = date
.checked_add(Span::new().days(1))
.map_err(|_| DateTimeError::OutOfRange)?;
(next, 0)
} else if hour > END_OF_DAY_HOUR {
return Err(DateTimeError::Field("hour"));
} else {
(date, hour)
};
let civil: DateTime = date.at(
i8::try_from(hour).map_err(|_| DateTimeError::Field("hour"))?,
i8::try_from(minute).map_err(|_| DateTimeError::Field("minute"))?,
i8::try_from(second).map_err(|_| DateTimeError::Field("second"))?,
nanos,
);
let ts = offset
.to_timestamp(civil)
.map_err(|_| DateTimeError::OutOfRange)?;
Ok(XsDateTime {
unix_us: ts.as_microsecond(),
fraction_digits: u8::try_from(fraction_digits.min(NANOS_DIGITS)).unwrap_or(0),
})
}
fn parse_zone(c: &mut Cursor<'_>) -> Result<Offset, DateTimeError> {
if c.i == c.b.len() {
return Ok(Offset::UTC);
}
if c.eat(b'Z') {
return Ok(Offset::UTC);
}
let sign: i32 = if c.eat(b'+') {
1
} else if c.eat(b'-') {
-1
} else {
return Err(DateTimeError::Syntax("unexpected character after the time"));
};
let h: u8 = narrow(c.digits(2)?)?;
c.expect(b':', "zone offset needs 'hh:mm'")?;
let m: u8 = narrow(c.digits(2)?)?;
if h > MAX_ZONE_HOUR || m > MAX_MINUTE || (h == MAX_ZONE_HOUR && m != 0) {
return Err(DateTimeError::Field("zone offset"));
}
let seconds = sign * (i32::from(h) * SECONDS_PER_HOUR + i32::from(m) * SECONDS_PER_MINUTE);
Offset::from_seconds(seconds).map_err(|_| DateTimeError::Field("zone offset"))
}
pub fn parse_ntp_timestamp(bytes: &[u8]) -> Result<i64, DateTimeError> {
let arr: [u8; NTP_TIMESTAMP_LEN] = bytes.try_into().map_err(|_| DateTimeError::Ntp)?;
let seconds = u32::from_be_bytes([arr[0], arr[1], arr[2], arr[3]]);
let fraction = u32::from_be_bytes([arr[4], arr[5], arr[6], arr[7]]);
let micros = (u128::from(fraction) * u128::from((MICROS_PER_SEC as i64).unsigned_abs()))
>> NTP_FRACTION_BITS;
let micros = i64::try_from(micros).map_err(|_| DateTimeError::Ntp)?;
let prime_epoch = Date::new(1900, 1, 1)
.map_err(|_| DateTimeError::Ntp)?
.at(0, 0, 0, 0)
.to_zoned(TimeZone::UTC)
.map_err(|_| DateTimeError::Ntp)?
.timestamp()
.as_second();
let unix_seconds = i64::from(seconds) + prime_epoch;
unix_seconds
.checked_mul(MICROS_PER_SEC as i64)
.and_then(|us| us.checked_add(micros))
.ok_or(DateTimeError::Ntp)
}
pub fn format_xs_datetime(unix_us: i64) -> Result<String, DateTimeError> {
let ts = jiff::Timestamp::from_microsecond(unix_us).map_err(|_| DateTimeError::OutOfRange)?;
let s = ts.strftime("%Y-%m-%dT%H:%M:%S%.6fZ").to_string();
let core = s.trim_end_matches('Z');
if let Some(dot) = core.rfind('.') {
let head = &core[..dot];
let frac = core[dot + 1..].trim_end_matches('0');
if frac.is_empty() {
return Ok(format!("{}Z", head));
}
return Ok(format!("{}.{}Z", head, frac));
}
Ok(s)
}