1use serde::{Deserialize, Serialize};
2use std::cmp::Ordering;
3
4const MICROS_PER_SECOND: i64 = 1_000_000;
5const MICROS_PER_MILLI: i64 = 1_000;
6const SECONDS_PER_DAY: i64 = 86_400;
7const MAX_DECIMAL_DIGITS: usize = 512;
8
9#[derive(
11 Clone,
12 Copy,
13 Debug,
14 PartialEq,
15 Eq,
16 Serialize,
17 Deserialize,
18 strum::Display,
19 strum::EnumString,
20 strum::VariantArray,
21)]
22#[serde(rename_all = "snake_case")]
23#[strum(serialize_all = "snake_case")]
24pub enum TimestampFormat {
25 Rfc3339,
28 EpochSeconds,
30 EpochMillis,
32 EpochMicros,
34}
35
36impl TimestampFormat {
37 pub fn micros_from_text(self, text: &str) -> Option<i64> {
40 match self {
41 Self::Rfc3339 => rfc3339_micros(text),
42 Self::EpochSeconds | Self::EpochMillis | Self::EpochMicros => {
43 let value = parse_integer(text)?;
44 self.micros_from_integer(value)
45 }
46 }
47 }
48
49 pub fn micros_from_integer(self, value: i128) -> Option<i64> {
52 let value = i64::try_from(value).ok()?;
53 match self {
54 Self::Rfc3339 => None,
55 Self::EpochSeconds => value.checked_mul(MICROS_PER_SECOND),
56 Self::EpochMillis => value.checked_mul(MICROS_PER_MILLI),
57 Self::EpochMicros => Some(value),
58 }
59 }
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct ExactDecimal {
69 negative: bool,
70 integer: String,
71 fraction: String,
72}
73
74impl ExactDecimal {
75 pub fn parse(text: &str) -> Option<Self> {
78 let (negative, unsigned) = match text.as_bytes().first()? {
79 b'-' => (true, &text[1..]),
80 b'+' => (false, &text[1..]),
81 _ => (false, text),
82 };
83 let (integer, fraction) = match unsigned.split_once('.') {
84 Some((integer, fraction)) => (integer, fraction),
85 None => (unsigned, ""),
86 };
87 if integer.is_empty() && fraction.is_empty() {
88 return None;
89 }
90 if integer.len() + fraction.len() > MAX_DECIMAL_DIGITS
91 || !integer.bytes().all(|byte| byte.is_ascii_digit())
92 || !fraction.bytes().all(|byte| byte.is_ascii_digit())
93 || (unsigned.contains('.') && fraction.is_empty())
94 {
95 return None;
96 }
97 Some(Self::normalized(negative, integer, fraction))
98 }
99
100 pub fn from_integer(value: i128) -> Self {
101 let digits = value.unsigned_abs().to_string();
102 Self::normalized(value < 0, &digits, "")
103 }
104
105 pub fn from_f64(value: f64) -> Option<Self> {
108 if !value.is_finite() {
109 return None;
110 }
111 Self::parse(&value.to_string())
112 }
113
114 fn normalized(negative: bool, integer: &str, fraction: &str) -> Self {
115 let integer = integer.trim_start_matches('0').to_owned();
116 let fraction = fraction.trim_end_matches('0').to_owned();
117 let negative = negative && !(integer.is_empty() && fraction.is_empty());
118 Self {
119 negative,
120 integer,
121 fraction,
122 }
123 }
124
125 fn magnitude_cmp(&self, other: &Self) -> Ordering {
126 self.integer
127 .len()
128 .cmp(&other.integer.len())
129 .then_with(|| self.integer.cmp(&other.integer))
130 .then_with(|| self.fraction.cmp(&other.fraction))
131 }
132}
133
134impl Ord for ExactDecimal {
135 fn cmp(&self, other: &Self) -> Ordering {
136 match (self.negative, other.negative) {
137 (false, true) => Ordering::Greater,
138 (true, false) => Ordering::Less,
139 (false, false) => self.magnitude_cmp(other),
140 (true, true) => other.magnitude_cmp(self),
141 }
142 }
143}
144
145impl PartialOrd for ExactDecimal {
146 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
147 Some(self.cmp(other))
148 }
149}
150
151pub(crate) fn parse_integer(text: &str) -> Option<i128> {
153 let digits = text.strip_prefix(['-', '+']).unwrap_or(text);
154 if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
155 return None;
156 }
157 text.parse::<i128>().ok()
158}
159
160fn rfc3339_micros(text: &str) -> Option<i64> {
161 let bytes = text.as_bytes();
162 if bytes.len() < 20 {
163 return None;
164 }
165 let year = digits_at(bytes, 0, 4)?;
166 let month = digits_at(bytes, 5, 2)?;
167 let day = digits_at(bytes, 8, 2)?;
168 let hour = digits_at(bytes, 11, 2)?;
169 let minute = digits_at(bytes, 14, 2)?;
170 let second = digits_at(bytes, 17, 2)?;
171 if bytes[4] != b'-'
172 || bytes[7] != b'-'
173 || !matches!(bytes[10], b'T' | b't')
174 || bytes[13] != b':'
175 || bytes[16] != b':'
176 {
177 return None;
178 }
179 if !(1..=12).contains(&month)
180 || day == 0
181 || day > days_in_month(year, month)
182 || hour > 23
183 || minute > 59
184 || second > 59
185 {
186 return None;
187 }
188 let mut position = 19;
189 let mut fraction_micros = 0i64;
190 if bytes.get(position) == Some(&b'.') {
191 position += 1;
192 let start = position;
193 while bytes.get(position).is_some_and(u8::is_ascii_digit) {
194 position += 1;
195 }
196 let fraction = &text[start..position];
197 if fraction.is_empty() || fraction.len() > 9 {
198 return None;
199 }
200 let (kept, dropped) = fraction.split_at(fraction.len().min(6));
201 if dropped.bytes().any(|byte| byte != b'0') {
202 return None;
203 }
204 let scale = 10i64.pow(u32::try_from(6 - kept.len()).ok()?);
205 fraction_micros = kept.parse::<i64>().ok()? * scale;
206 }
207 let offset_seconds = match bytes.get(position)? {
208 b'Z' | b'z' if position + 1 == bytes.len() => 0,
209 sign @ (b'+' | b'-') if position + 6 == bytes.len() => {
210 let offset_hour = digits_at(bytes, position + 1, 2)?;
211 let offset_minute = digits_at(bytes, position + 4, 2)?;
212 if bytes[position + 3] != b':' || offset_hour > 23 || offset_minute > 59 {
213 return None;
214 }
215 let magnitude = offset_hour * 3600 + offset_minute * 60;
216 if *sign == b'-' { -magnitude } else { magnitude }
217 }
218 _ => return None,
219 };
220 let days = days_from_civil(year, month, day);
221 let seconds = days
222 .checked_mul(SECONDS_PER_DAY)?
223 .checked_add(hour * 3600 + minute * 60 + second)?
224 .checked_sub(offset_seconds)?;
225 seconds
226 .checked_mul(MICROS_PER_SECOND)?
227 .checked_add(fraction_micros)
228}
229
230fn digits_at(bytes: &[u8], start: usize, len: usize) -> Option<i64> {
231 let slice = bytes.get(start..start + len)?;
232 slice.iter().try_fold(0i64, |value, byte| {
233 byte.is_ascii_digit()
234 .then(|| value * 10 + i64::from(byte - b'0'))
235 })
236}
237
238const fn is_leap_year(year: i64) -> bool {
239 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
240}
241
242const fn days_in_month(year: i64, month: i64) -> i64 {
243 match month {
244 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
245 4 | 6 | 9 | 11 => 30,
246 _ if is_leap_year(year) => 29,
247 _ => 28,
248 }
249}
250
251const fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
253 let year = if month <= 2 { year - 1 } else { year };
254 let era = if year >= 0 { year } else { year - 399 } / 400;
255 let year_of_era = year - era * 400;
256 let shifted_month = if month > 2 { month - 3 } else { month + 9 };
257 let day_of_year = (153 * shifted_month + 2) / 5 + day - 1;
258 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
259 era * 146_097 + day_of_era - 719_468
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn given_rfc3339_instants_when_parsed_then_should_normalize_to_utc_micros() {
268 let utc = TimestampFormat::Rfc3339
269 .micros_from_text("2026-09-21T18:04:12.331Z")
270 .expect("parses");
271 let offset = TimestampFormat::Rfc3339
272 .micros_from_text("2026-09-21T20:04:12.331000+02:00")
273 .expect("parses");
274 assert_eq!(utc, offset, "the same instant in two offsets is equal");
275 assert_eq!(
276 TimestampFormat::Rfc3339.micros_from_text("1970-01-01T00:00:00Z"),
277 Some(0)
278 );
279 assert_eq!(
280 TimestampFormat::Rfc3339.micros_from_text("1969-12-31T23:59:59.999999Z"),
281 Some(-1)
282 );
283 }
284
285 #[test]
286 fn given_ambiguous_or_lossy_rfc3339_when_parsed_then_should_be_rejected() {
287 for text in [
288 "2026-09-21T18:04:12.331000",
289 "2026-09-21 18:04:12Z",
290 "2026-02-29T00:00:00Z",
291 "2026-09-21T24:00:00Z",
292 "2026-09-21T18:04:60Z",
293 "2026-09-21T18:04:12.0000001Z",
294 "2026-09-21T18:04:12.Z",
295 "2026-09-21T18:04:12+2:00",
296 ] {
297 assert_eq!(
298 TimestampFormat::Rfc3339.micros_from_text(text),
299 None,
300 "`{text}` must be rejected"
301 );
302 }
303 assert_eq!(
304 TimestampFormat::Rfc3339.micros_from_text("2024-02-29T00:00:00.000000000Z"),
305 Some(1_709_164_800_000_000)
306 );
307 }
308
309 #[test]
310 fn given_epoch_units_when_coerced_then_should_scale_exactly() {
311 assert_eq!(
312 TimestampFormat::EpochSeconds.micros_from_integer(1_758_470_652),
313 Some(1_758_470_652_000_000)
314 );
315 assert_eq!(
316 TimestampFormat::EpochMillis.micros_from_text("1758470652331"),
317 Some(1_758_470_652_331_000)
318 );
319 assert_eq!(
320 TimestampFormat::EpochMicros.micros_from_integer(-5),
321 Some(-5)
322 );
323 assert_eq!(
324 TimestampFormat::EpochSeconds.micros_from_integer(i128::from(i64::MAX)),
325 None
326 );
327 assert_eq!(TimestampFormat::EpochMillis.micros_from_text("12.5"), None);
328 }
329
330 #[test]
331 fn given_decimals_when_compared_then_should_order_exactly() {
332 let parse = |text: &str| ExactDecimal::parse(text).expect("parses");
333 assert_eq!(parse("1.50"), parse("1.5"));
334 assert_eq!(parse("-0"), parse("0.000"));
335 assert!(parse("9007199254740993") > parse("9007199254740992"));
336 assert!(parse("-2") < parse("-1.999"));
337 assert!(parse("0.1") < parse("0.10001"));
338 assert!(parse("10") > parse("9.999999"));
339 assert_eq!(ExactDecimal::from_integer(-42), parse("-42.0"));
340 assert_eq!(ExactDecimal::from_f64(0.5), Some(parse("0.5")));
341 }
342
343 #[test]
344 fn given_malformed_decimals_when_parsed_then_should_be_rejected() {
345 for text in ["", "-", ".", "1.", "1e5", " 1", "1,5", "0x10", "--1"] {
346 assert_eq!(ExactDecimal::parse(text), None, "`{text}` must be rejected");
347 }
348 assert!(ExactDecimal::parse(".5").is_some());
349 }
350}