broadcast_dash/
xs_datetime.rs1use broadcast_common::units::MICROS_PER_SEC;
10use jiff::Span;
11use jiff::civil::{Date, DateTime};
12use jiff::tz::{Offset, TimeZone};
13use thiserror::Error;
14
15const NANOS_DIGITS: usize = 9;
16const MICROS_DIGITS: usize = 6;
17const SECONDS_PER_HOUR: i32 = 3_600;
18const SECONDS_PER_MINUTE: i32 = 60;
19const MAX_ZONE_HOUR: u8 = 14;
20const MAX_MINUTE: u8 = 59;
21const MAX_SECOND: u8 = 59;
22const END_OF_DAY_HOUR: u8 = 24;
23const FIRST_YEAR: i16 = 1;
24const YEAR_DIGITS: usize = 4;
25pub const NTP_RESOLUTION_US: u64 = 1_000;
27const NTP_FRACTION_BITS: u32 = 32;
28const NTP_TIMESTAMP_LEN: usize = 8;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
32#[non_exhaustive]
33pub enum DateTimeError {
34 #[error("not an accepted xs:dateTime: {0}")]
36 Syntax(&'static str),
37 #[error("xs:dateTime field out of range: {0}")]
40 Field(&'static str),
41 #[error("instant outside the representable range")]
43 OutOfRange,
44 #[error("not an 8-byte NTP timestamp")]
46 Ntp,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct XsDateTime {
53 pub unix_us: i64,
56 pub fraction_digits: u8,
58}
59
60impl XsDateTime {
61 #[must_use]
64 pub fn resolution_us(&self) -> u64 {
65 let digits = usize::from(self.fraction_digits).min(MICROS_DIGITS);
66 let mut res = (MICROS_PER_SEC as i64).unsigned_abs();
67 if self.fraction_digits > 0 {
68 res = 10u64.pow(u32::try_from(MICROS_DIGITS - digits).unwrap_or(0));
69 }
70 res
71 }
72}
73
74struct Cursor<'a> {
75 b: &'a [u8],
76 i: usize,
77}
78
79impl Cursor<'_> {
80 fn eat(&mut self, c: u8) -> bool {
81 if self.b.get(self.i) == Some(&c) {
82 self.i += 1;
83 true
84 } else {
85 false
86 }
87 }
88
89 fn digits(&mut self, n: usize) -> Result<u32, DateTimeError> {
90 let end = self.i + n;
91 let slice = self
92 .b
93 .get(self.i..end)
94 .ok_or(DateTimeError::Syntax("truncated"))?;
95 if !slice.iter().all(u8::is_ascii_digit) {
96 return Err(DateTimeError::Syntax("expected digits"));
97 }
98 self.i = end;
99 Ok(slice
100 .iter()
101 .fold(0u32, |acc, d| acc * 10 + u32::from(d - b'0')))
102 }
103
104 fn expect(&mut self, c: u8, what: &'static str) -> Result<(), DateTimeError> {
105 if self.eat(c) {
106 Ok(())
107 } else {
108 Err(DateTimeError::Syntax(what))
109 }
110 }
111}
112
113fn narrow<T: TryFrom<u32>>(v: u32) -> Result<T, DateTimeError> {
114 T::try_from(v).map_err(|_| DateTimeError::Field("value"))
115}
116
117pub fn parse_xs_datetime(text: &str) -> Result<XsDateTime, DateTimeError> {
122 let trimmed = text.trim_matches([' ', '\t', '\n', '\r']);
123 let mut c = Cursor {
124 b: trimmed.as_bytes(),
125 i: 0,
126 };
127 let year: i16 = narrow(c.digits(YEAR_DIGITS)?)?;
128 if year < FIRST_YEAR {
129 return Err(DateTimeError::Field("year"));
130 }
131 c.expect(b'-', "expected '-' after the year")?;
132 let month: u8 = narrow(c.digits(2)?)?;
133 c.expect(b'-', "expected '-' after the month")?;
134 let day: u8 = narrow(c.digits(2)?)?;
135 c.expect(b'T', "expected upper-case 'T'")?;
136 let hour: u8 = narrow(c.digits(2)?)?;
137 c.expect(b':', "expected ':' after the hour")?;
138 let minute: u8 = narrow(c.digits(2)?)?;
139 c.expect(b':', "expected ':' after the minute")?;
140 let second: u8 = narrow(c.digits(2)?)?;
141
142 let mut fraction_digits = 0usize;
143 let mut nanos: i32 = 0;
144 let mut fraction_zero = true;
145 if c.eat(b'.') {
146 let start = c.i;
147 while c.b.get(c.i).is_some_and(u8::is_ascii_digit) {
148 c.i += 1;
149 }
150 let frac = &c.b[start..c.i];
151 if frac.is_empty() {
152 return Err(DateTimeError::Syntax("empty fraction"));
153 }
154 fraction_digits = frac.len();
155 fraction_zero = frac.iter().all(|d| *d == b'0');
156 for d in frac.iter().take(NANOS_DIGITS) {
157 nanos = nanos * 10 + i32::from(d - b'0');
158 }
159 for _ in frac.len().min(NANOS_DIGITS)..NANOS_DIGITS {
160 nanos *= 10;
161 }
162 }
163
164 let offset = parse_zone(&mut c)?;
165 if c.i != c.b.len() {
166 return Err(DateTimeError::Syntax("trailing characters"));
167 }
168
169 if month == 0 || month > 12 {
170 return Err(DateTimeError::Field("month"));
171 }
172 let date = Date::new(
173 year,
174 i8::try_from(month).map_err(|_| DateTimeError::Field("month"))?,
175 i8::try_from(day).map_err(|_| DateTimeError::Field("day"))?,
176 )
177 .map_err(|_| DateTimeError::Field("day"))?;
178 if minute > MAX_MINUTE || second > MAX_SECOND {
179 return Err(DateTimeError::Field("minute or second"));
180 }
181 let (date, hour) = if hour == END_OF_DAY_HOUR {
182 if minute != 0 || second != 0 || !fraction_zero {
183 return Err(DateTimeError::Field("hour 24 with a non-zero remainder"));
184 }
185 let next = date
186 .checked_add(Span::new().days(1))
187 .map_err(|_| DateTimeError::OutOfRange)?;
188 (next, 0)
189 } else if hour > END_OF_DAY_HOUR {
190 return Err(DateTimeError::Field("hour"));
191 } else {
192 (date, hour)
193 };
194 let civil: DateTime = date.at(
195 i8::try_from(hour).map_err(|_| DateTimeError::Field("hour"))?,
196 i8::try_from(minute).map_err(|_| DateTimeError::Field("minute"))?,
197 i8::try_from(second).map_err(|_| DateTimeError::Field("second"))?,
198 nanos,
199 );
200 let ts = offset
201 .to_timestamp(civil)
202 .map_err(|_| DateTimeError::OutOfRange)?;
203 Ok(XsDateTime {
204 unix_us: ts.as_microsecond(),
205 fraction_digits: u8::try_from(fraction_digits.min(NANOS_DIGITS)).unwrap_or(0),
206 })
207}
208
209fn parse_zone(c: &mut Cursor<'_>) -> Result<Offset, DateTimeError> {
210 if c.i == c.b.len() {
211 return Ok(Offset::UTC);
212 }
213 if c.eat(b'Z') {
214 return Ok(Offset::UTC);
215 }
216 let sign: i32 = if c.eat(b'+') {
217 1
218 } else if c.eat(b'-') {
219 -1
220 } else {
221 return Err(DateTimeError::Syntax("unexpected character after the time"));
222 };
223 let h: u8 = narrow(c.digits(2)?)?;
224 c.expect(b':', "zone offset needs 'hh:mm'")?;
225 let m: u8 = narrow(c.digits(2)?)?;
226 if h > MAX_ZONE_HOUR || m > MAX_MINUTE || (h == MAX_ZONE_HOUR && m != 0) {
227 return Err(DateTimeError::Field("zone offset"));
228 }
229 let seconds = sign * (i32::from(h) * SECONDS_PER_HOUR + i32::from(m) * SECONDS_PER_MINUTE);
230 Offset::from_seconds(seconds).map_err(|_| DateTimeError::Field("zone offset"))
231}
232
233pub fn parse_ntp_timestamp(bytes: &[u8]) -> Result<i64, DateTimeError> {
239 let arr: [u8; NTP_TIMESTAMP_LEN] = bytes.try_into().map_err(|_| DateTimeError::Ntp)?;
240 let seconds = u32::from_be_bytes([arr[0], arr[1], arr[2], arr[3]]);
241 let fraction = u32::from_be_bytes([arr[4], arr[5], arr[6], arr[7]]);
242 let micros = (u128::from(fraction) * u128::from((MICROS_PER_SEC as i64).unsigned_abs()))
243 >> NTP_FRACTION_BITS;
244 let micros = i64::try_from(micros).map_err(|_| DateTimeError::Ntp)?;
245 let prime_epoch = Date::new(1900, 1, 1)
246 .map_err(|_| DateTimeError::Ntp)?
247 .at(0, 0, 0, 0)
248 .to_zoned(TimeZone::UTC)
249 .map_err(|_| DateTimeError::Ntp)?
250 .timestamp()
251 .as_second();
252 let unix_seconds = i64::from(seconds) + prime_epoch;
253 unix_seconds
254 .checked_mul(MICROS_PER_SEC as i64)
255 .and_then(|us| us.checked_add(micros))
256 .ok_or(DateTimeError::Ntp)
257}
258
259pub fn format_xs_datetime(unix_us: i64) -> Result<String, DateTimeError> {
264 let ts = jiff::Timestamp::from_microsecond(unix_us).map_err(|_| DateTimeError::OutOfRange)?;
265 let s = ts.strftime("%Y-%m-%dT%H:%M:%S%.6fZ").to_string();
266 let core = s.trim_end_matches('Z');
267 if let Some(dot) = core.rfind('.') {
268 let head = &core[..dot];
269 let frac = core[dot + 1..].trim_end_matches('0');
270 if frac.is_empty() {
271 return Ok(format!("{}Z", head));
272 }
273 return Ok(format!("{}.{}Z", head, frac));
274 }
275 Ok(s)
276}