Skip to main content

ravenrs_criteria/
value.rs

1//! Values that expressions compute and fields hold.
2
3use std::cmp::Ordering;
4use std::fmt;
5
6/// A calendar date with an optional time of day, without a time zone.
7#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub struct DateTime {
9    /// Days since 1970-01-01 (negative before).
10    pub days: i64,
11    /// Seconds since midnight.
12    pub seconds: u32,
13}
14
15impl DateTime {
16    /// The date `year-month-day` at midnight, or `None` if it does not exist.
17    pub fn from_ymd(year: i32, month: u32, day: u32) -> Option<Self> {
18        if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
19            return None;
20        }
21        Some(Self { days: days_from_civil(year, month, day), seconds: 0 })
22    }
23
24    /// Year, month (1–12) and day (1–31).
25    pub fn ymd(self) -> (i32, u32, u32) {
26        civil_from_days(self.days)
27    }
28
29    /// Hours, minutes and seconds.
30    pub fn hms(self) -> (u32, u32, u32) {
31        (self.seconds / 3600, self.seconds / 60 % 60, self.seconds % 60)
32    }
33
34    /// Day of the week, 0 = Sunday.
35    pub fn weekday(self) -> u32 {
36        (self.days + 4).rem_euclid(7) as u32
37    }
38
39    /// Parses ISO 8601 dates: `2026-11-01`, `2026-11-01T14:30`, `2026-11-01 14:30:05`
40    /// (a trailing zone such as `Z` or `-03:00` is accepted and ignored).
41    pub fn parse(s: &str) -> Option<Self> {
42        let s = s.trim();
43        let bytes = s.as_bytes();
44        if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' {
45            return None;
46        }
47        let year: i32 = s.get(0..4)?.parse().ok()?;
48        let month: u32 = s.get(5..7)?.parse().ok()?;
49        let day: u32 = s.get(8..10)?.parse().ok()?;
50        let mut date = Self::from_ymd(year, month, day)?;
51        let rest = &s[10..];
52        if rest.is_empty() {
53            return Some(date);
54        }
55        let time = rest.strip_prefix('T').or_else(|| rest.strip_prefix(' '))?;
56        let h: u32 = time.get(0..2)?.parse().ok()?;
57        let m: u32 = time.get(3..5)?.parse().ok()?;
58        let sec: u32 = if time.as_bytes().get(5) == Some(&b':') { time.get(6..8)?.parse().ok()? } else { 0 };
59        if h > 23 || m > 59 || sec > 59 {
60            return None;
61        }
62        date.seconds = h * 3600 + m * 60 + sec;
63        Some(date)
64    }
65
66    /// The current local date and time in the browser; the current UTC date
67    /// and time elsewhere (the standard library has no time zones).
68    pub fn now() -> Self {
69        #[cfg(target_arch = "wasm32")]
70        let secs = {
71            let now = js_sys::Date::new_0();
72            // getTimezoneOffset is minutes behind UTC.
73            ((now.get_time() / 1000.0) - now.get_timezone_offset() * 60.0) as i64
74        };
75        #[cfg(not(target_arch = "wasm32"))]
76        let secs = std::time::SystemTime::now()
77            .duration_since(std::time::UNIX_EPOCH)
78            .map_or(0, |d| d.as_secs() as i64);
79        Self { days: secs.div_euclid(86_400), seconds: secs.rem_euclid(86_400) as u32 }
80    }
81
82    /// The date `n` days later (earlier for negative `n`).
83    pub fn add_days(self, n: i64) -> Self {
84        Self { days: self.days + n, ..self }
85    }
86
87    /// The same day `n` months later, clamped to the month's last day.
88    pub fn add_months(self, n: i64) -> Self {
89        let (y, m, d) = self.ymd();
90        let total = i64::from(y) * 12 + i64::from(m) - 1 + n;
91        let (year, month) = (total.div_euclid(12) as i32, (total.rem_euclid(12) + 1) as u32);
92        let day = d.min(days_in_month(year, month));
93        Self { days: days_from_civil(year, month, day), seconds: self.seconds }
94    }
95}
96
97impl fmt::Display for DateTime {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        let (y, m, d) = self.ymd();
100        write!(f, "{y:04}-{m:02}-{d:02}")?;
101        if self.seconds != 0 {
102            let (h, mi, s) = self.hms();
103            write!(f, "T{h:02}:{mi:02}:{s:02}")?;
104        }
105        Ok(())
106    }
107}
108
109pub(crate) fn is_leap(year: i32) -> bool {
110    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
111}
112
113pub(crate) fn days_in_month(year: i32, month: u32) -> u32 {
114    match month {
115        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
116        4 | 6 | 9 | 11 => 30,
117        2 if is_leap(year) => 29,
118        2 => 28,
119        _ => 0,
120    }
121}
122
123// Howard Hinnant's civil date algorithms.
124fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
125    let y = i64::from(y) - i64::from(m <= 2);
126    let era = y.div_euclid(400);
127    let yoe = y - era * 400;
128    let m = i64::from(m);
129    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(d) - 1;
130    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
131    era * 146_097 + doe - 719_468
132}
133
134fn civil_from_days(z: i64) -> (i32, u32, u32) {
135    let z = z + 719_468;
136    let era = z.div_euclid(146_097);
137    let doe = z - era * 146_097;
138    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
139    let y = yoe + era * 400;
140    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
141    let mp = (5 * doy + 2) / 153;
142    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
143    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
144    ((y + i64::from(m <= 2)) as i32, m, d)
145}
146
147/// A value: what fields hold and expressions compute.
148#[derive(Clone, Debug, Default, PartialEq)]
149#[non_exhaustive]
150pub enum Value {
151    /// No value (a missing field, a JSON `null`).
152    #[default]
153    Null,
154    /// True or false.
155    Bool(bool),
156    /// A number.
157    Number(f64),
158    /// Text.
159    Text(String),
160    /// A date and time.
161    Date(DateTime),
162}
163
164impl Value {
165    /// Converts a JSON scalar; arrays and objects become `Null` (they are
166    /// collections, reached with `[Items][].Sum(...)`).
167    #[cfg(feature = "json")]
168    pub fn from_json(json: &serde_json::Value) -> Self {
169        match json {
170            serde_json::Value::Bool(b) => Self::Bool(*b),
171            serde_json::Value::Number(n) => n.as_f64().map_or(Self::Null, Self::Number),
172            serde_json::Value::String(s) => Self::Text(s.clone()),
173            _ => Self::Null,
174        }
175    }
176
177    /// True for `Null`.
178    pub fn is_null(&self) -> bool {
179        matches!(self, Self::Null)
180    }
181
182    /// The value as a number: numbers, booleans (1/0) and numeric text.
183    pub fn as_number(&self) -> Option<f64> {
184        match self {
185            Self::Number(n) => Some(*n),
186            Self::Bool(b) => Some(f64::from(u8::from(*b))),
187            Self::Text(s) => s.trim().parse().ok(),
188            _ => None,
189        }
190    }
191
192    /// The value as a date: dates and ISO 8601 text.
193    pub fn as_date(&self) -> Option<DateTime> {
194        match self {
195            Self::Date(d) => Some(*d),
196            Self::Text(s) => DateTime::parse(s),
197            _ => None,
198        }
199    }
200
201    /// Truthiness for conditions: `true`, non-zero numbers and non-empty text.
202    pub fn is_true(&self) -> bool {
203        match self {
204            Self::Bool(b) => *b,
205            Self::Number(n) => *n != 0.0 && !n.is_nan(),
206            Self::Text(s) => !s.is_empty(),
207            Self::Date(_) => true,
208            Self::Null => false,
209        }
210    }
211
212    /// Orders values for sorting and comparisons: nulls first, then by kind;
213    /// numbers numerically, text case-insensitively, dates chronologically.
214    pub fn compare(&self, other: &Self) -> Ordering {
215        match (self, other) {
216            (Self::Null, Self::Null) => Ordering::Equal,
217            (Self::Null, _) => Ordering::Less,
218            (_, Self::Null) => Ordering::Greater,
219            (Self::Number(a), Self::Number(b)) => a.total_cmp(b),
220            (Self::Date(a), Self::Date(b)) => a.cmp(b),
221            (Self::Bool(a), Self::Bool(b)) => a.cmp(b),
222            (Self::Text(a), Self::Text(b)) => {
223                let o = cmp_ignore_case(a, b);
224                if o == Ordering::Equal { a.cmp(b) } else { o }
225            }
226            (a, b) => match (a.as_date(), b.as_date()) {
227                (Some(x), Some(y)) => x.cmp(&y),
228                _ => match (a.as_number(), b.as_number()) {
229                    (Some(x), Some(y)) => x.total_cmp(&y),
230                    _ => a.to_text().cmp(&b.to_text()),
231                },
232            },
233        }
234    }
235
236    /// Equality as the `=` operator sees it (`1 = '1'` is false, `1 = 1.0` is true).
237    pub fn loose_eq(&self, other: &Self) -> bool {
238        match (self, other) {
239            (Self::Text(a), Self::Text(b)) => a == b,
240            (Self::Date(_), Self::Text(_)) | (Self::Text(_), Self::Date(_)) => {
241                self.as_date().is_some() && self.as_date() == other.as_date()
242            }
243            _ => {
244                self.compare(other) == Ordering::Equal
245                    && std::mem::discriminant(self) == std::mem::discriminant(other)
246            }
247        }
248    }
249
250    /// Text for display without a format: numbers in invariant form.
251    pub fn to_text(&self) -> String {
252        match self {
253            Self::Null => String::new(),
254            Self::Bool(b) => b.to_string(),
255            Self::Number(n) => format_plain(*n),
256            Self::Text(s) => s.clone(),
257            Self::Date(d) => d.to_string(),
258        }
259    }
260}
261
262fn cmp_ignore_case(a: &str, b: &str) -> Ordering {
263    a.chars().flat_map(char::to_lowercase).cmp(b.chars().flat_map(char::to_lowercase))
264}
265
266/// A number without trailing zeros (`2`, `2.5`, `0.1`).
267pub(crate) fn format_plain(n: f64) -> String {
268    if n.fract() == 0.0 && n.abs() < 1e15 {
269        format!("{}", n as i64)
270    } else {
271        let s = format!("{n:.10}");
272        s.trim_end_matches('0').trim_end_matches('.').to_owned()
273    }
274}
275
276impl From<f64> for Value {
277    fn from(n: f64) -> Self {
278        Self::Number(n)
279    }
280}
281
282impl From<i64> for Value {
283    fn from(n: i64) -> Self {
284        Self::Number(n as f64)
285    }
286}
287
288impl From<bool> for Value {
289    fn from(b: bool) -> Self {
290        Self::Bool(b)
291    }
292}
293
294impl From<&str> for Value {
295    fn from(s: &str) -> Self {
296        Self::Text(s.to_owned())
297    }
298}
299
300impl From<String> for Value {
301    fn from(s: String) -> Self {
302        Self::Text(s)
303    }
304}
305
306impl From<DateTime> for Value {
307    fn from(d: DateTime) -> Self {
308        Self::Date(d)
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn civil_dates_round_trip() {
318        for days in [-800_000, -1, 0, 1, 19_000, 20_393, 2_000_000] {
319            let d = DateTime { days, seconds: 0 };
320            let (y, m, dd) = d.ymd();
321            assert_eq!(DateTime::from_ymd(y, m, dd), Some(d));
322        }
323        assert_eq!(DateTime::from_ymd(1970, 1, 1).map(|d| d.days), Some(0));
324        assert_eq!(DateTime::from_ymd(2026, 2, 29), None);
325        assert_eq!(DateTime::from_ymd(2024, 2, 29).map(|d| d.weekday()), Some(4), "a Thursday");
326    }
327
328    #[test]
329    fn parses_iso_dates_and_times() {
330        let d = DateTime::parse("2026-11-01T14:30:05Z").expect("valid");
331        assert_eq!((d.ymd(), d.hms()), ((2026, 11, 1), (14, 30, 5)));
332        assert_eq!(DateTime::parse("2026-11-01 08:15").map(|d| d.hms()), Some((8, 15, 0)));
333        assert!(DateTime::parse("01/11/2026").is_none());
334        assert!(DateTime::parse("2026-13-01").is_none());
335    }
336
337    #[test]
338    fn month_arithmetic_clamps_to_the_last_day() {
339        let jan31 = DateTime::from_ymd(2026, 1, 31).expect("valid");
340        assert_eq!(jan31.add_months(1).ymd(), (2026, 2, 28));
341        assert_eq!(jan31.add_months(-2).ymd(), (2025, 11, 30));
342        assert_eq!(jan31.add_days(1).ymd(), (2026, 2, 1));
343    }
344
345    #[test]
346    fn comparison_and_equality() {
347        assert_eq!(Value::from("apple").compare(&Value::from("Banana")), Ordering::Less);
348        assert_eq!(Value::Null.compare(&Value::from(0.0)), Ordering::Less);
349        assert!(Value::from(1.0).loose_eq(&Value::from(1_i64)));
350        assert!(!Value::from(1.0).loose_eq(&Value::from("1")));
351        assert!(
352            Value::from("2026-01-02").loose_eq(&Value::Date(DateTime::from_ymd(2026, 1, 2).expect("valid")))
353        );
354        assert_eq!(format_plain(2.50), "2.5");
355        assert_eq!(format_plain(-3.0), "-3");
356    }
357}