Skip to main content

laser_wire/filter/
coerce.rs

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/// How a timestamp is written in the payload or literal.
10#[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    /// RFC 3339 text with an explicit `Z` or `+hh:mm` offset. Fractions up to
26    /// nanoseconds are accepted when the digits past microseconds are zero.
27    Rfc3339,
28    /// Whole seconds since the Unix epoch, as a number or an integer string.
29    EpochSeconds,
30    /// Whole milliseconds since the Unix epoch.
31    EpochMillis,
32    /// Whole microseconds since the Unix epoch.
33    EpochMicros,
34}
35
36impl TimestampFormat {
37    /// Microseconds since the Unix epoch for a text value, `None` when the text
38    /// does not parse exactly under this format.
39    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    /// Microseconds since the Unix epoch for an integer value, `None` for the
50    /// text-only RFC 3339 format and on overflow.
51    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/// An exact decimal number, compared without floating-point rounding.
63///
64/// Built from decimal text (`-12.500`), an integer, or the shortest exact
65/// rendering of a finite `f64`. Leading and trailing zeros are normalized away,
66/// so `1.50` equals `1.5` and `-0` equals `0`.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct ExactDecimal {
69    negative: bool,
70    integer: String,
71    fraction: String,
72}
73
74impl ExactDecimal {
75    /// Parse `[+-]digits[.digits]`. No exponent, no surrounding whitespace, at
76    /// most 512 digits.
77    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    /// The shortest decimal that round-trips a finite double, so a JSON
106    /// number compares at `f64` precision. `None` for infinities and NaN.
107    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
151/// A decimal integer string: optional sign, digits only.
152pub(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
251// Days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant's algorithm).
252const 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}