Skip to main content

omgbase_surface/
dates.rs

1//! Dates and durations for the query binding (`spec/surface` §1.3 "Dates and
2//! durations", 4.8): the Dataview-parity function set, served as omgbase HOST
3//! functions through `oqx`'s `call_function` / `call_method` seam. Port of the
4//! reference's `oqx-js/dates.ts`, function for function.
5//!
6//! Two value conventions and no new value kinds:
7//!   • a DATE is an ISO-8601 string — `YYYY-MM-DD` (date-only) or
8//!     `YYYY-MM-DDThh:mm:ss[.fff](Z|±hh:mm)` — exactly what the store writes
9//!     for `updated_at`/`ts` and what OQX already orders chronologically by
10//!     code point (`spec/oqx` SEMANTICS §6);
11//!   • a DURATION is a number of seconds.
12//!
13//! Arithmetic between them is METHODS (`d.plus(s)`, `a.until(b)`), never the
14//! `+`/`-` operators: `+` with a string operand concatenates (SEMANTICS §7),
15//! so `"2026-01-05" + 86400` is `"2026-01-0586400"` and must stay so.
16//!
17//! Everything is total: a value that is not a date (or a duration) yields
18//! absent (`Null`), so a `where` over mixed data filters quietly. Only a wrong
19//! ARITY is an error (`filter_invalid`, the surface's eval error). The one
20//! impure input — the named points `today`, `now`, `sow`… — reads the HOST
21//! clock, UTC, as the instant `now_ms` the caller threads in: the `Surface`
22//! passes the clock it stamps commits with (`OMGBASE_SPEC_CLOCK` pins it; the
23//! fixtures run under the corpus clock, so `date("today")` is `2026-09-27`
24//! there). The language itself has no clock.
25//!
26//! The pure half (parse / render / shift / until / fields / format / the
27//! duration grammar) is public for the unit tests; the two arms at the bottom
28//! ([`date_function`], [`date_method`]) are what `context.rs` and the constant
29//! fold in `query.rs` call. Calendar arithmetic is done by hand (proleptic
30//! Gregorian, days-from-civil) — no date crate, no new dependency.
31
32use oqx::{OqxError, Value};
33
34// ---- the date value -------------------------------------------------------------
35
36/// A parsed date: the instant (milliseconds since the Unix epoch), the offset
37/// it was WRITTEN in (minutes east of UTC; `0` for `Z` and for a date-only
38/// value), whether it is date-only, and whether it carried a fraction of a
39/// second as written (kept on render).
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub struct DateValue {
42    pub ms: i64,
43    pub offset: i64,
44    pub date_only: bool,
45    pub fraction: bool,
46}
47
48const DAY_MS: i64 = 86_400_000;
49const MINUTE_MS: i64 = 60_000;
50
51/// Days since 1970-01-01 of a proleptic Gregorian civil date (Hinnant's
52/// `days_from_civil`; `month` 1–12, `day` 1–31 — an out-of-range day rolls
53/// over, as `Date.UTC` does).
54fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
55    let y = if month <= 2 { year - 1 } else { year };
56    let era = if y >= 0 { y } else { y - 399 } / 400;
57    let yoe = y - era * 400;
58    let mp = (month + 9) % 12;
59    let doy = (153 * mp + 2) / 5 + day - 1;
60    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
61    era * 146_097 + doe - 719_468
62}
63
64/// The civil date `(year, month, day)` of a day count since 1970-01-01
65/// (Hinnant's `civil_from_days`).
66fn civil_from_days(days: i64) -> (i64, i64, i64) {
67    let z = days + 719_468;
68    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
69    let doe = z - era * 146_097;
70    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
71    let y = yoe + era * 400;
72    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
73    let mp = (5 * doy + 2) / 153;
74    let d = doy - (153 * mp + 2) / 5 + 1;
75    let m = if mp < 10 { mp + 3 } else { mp - 9 };
76    (if m <= 2 { y + 1 } else { y }, m, d)
77}
78
79/// `Date.UTC(year, month - 1, day, hour, minute, second, millis)`.
80fn utc_ms(
81    year: i64,
82    month: i64,
83    day: i64,
84    hour: i64,
85    minute: i64,
86    second: i64,
87    millis: i64,
88) -> i64 {
89    days_from_civil(year, month, day) * DAY_MS
90        + hour * 3_600_000
91        + minute * MINUTE_MS
92        + second * 1000
93        + millis
94}
95
96/// The day of the week of a day count: 0 = Sunday … 6 = Saturday
97/// (`getUTCDay`; 1970-01-01 was a Thursday).
98fn dow_of_days(days: i64) -> i64 {
99    (days + 4).rem_euclid(7)
100}
101
102fn is_leap(year: i64) -> bool {
103    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
104}
105
106fn days_in_month(year: i64, month: i64) -> i64 {
107    match month {
108        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
109        4 | 6 | 9 | 11 => 30,
110        2 => {
111            if is_leap(year) {
112                29
113            } else {
114                28
115            }
116        }
117        _ => 30,
118    }
119}
120
121/// `n` ASCII digits at the head of `s`: their value and the rest.
122fn digits(s: &str, n: usize) -> Option<(i64, &str)> {
123    let b = s.as_bytes();
124    if b.len() < n || !b[..n].iter().all(u8::is_ascii_digit) {
125        return None;
126    }
127    // All ASCII digits, so `n` is a char boundary.
128    Some((s[..n].parse().ok()?, &s[n..]))
129}
130
131/// Parse an ISO-8601 date / datetime string; `None` for anything else (a
132/// non-string, a malformed string, an out-of-range field such as month 13 or
133/// hour 24). The shape: `YYYY-MM-DD`, optionally `T`/`t`/space +
134/// `hh:mm[:ss[.fff…]]` + an optional offset (`Z`, `±hh:mm`, `±hhmm`); a time
135/// without an offset is UTC; the fraction (1–9 digits, `.` or `,`) is
136/// truncated to milliseconds.
137#[must_use]
138pub fn parse_date(x: &Value) -> Option<DateValue> {
139    let Value::Str(s) = x else { return None };
140    let s = s.trim();
141    let (year, s) = digits(s, 4)?;
142    let s = s.strip_prefix('-')?;
143    let (month, s) = digits(s, 2)?;
144    let s = s.strip_prefix('-')?;
145    let (day, s) = digits(s, 2)?;
146    if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
147        return None;
148    }
149    if s.is_empty() {
150        return Some(DateValue {
151            ms: utc_ms(year, month, day, 0, 0, 0, 0),
152            offset: 0,
153            date_only: true,
154            fraction: false,
155        });
156    }
157    let s = s
158        .strip_prefix('T')
159        .or_else(|| s.strip_prefix('t'))
160        .or_else(|| s.strip_prefix(' '))?;
161    let (hour, s) = digits(s, 2)?;
162    let s = s.strip_prefix(':')?;
163    let (minute, s) = digits(s, 2)?;
164    let (second, fraction, millis, s) = match s.strip_prefix(':') {
165        Some(rest) => {
166            let (second, rest) = digits(rest, 2)?;
167            match rest.strip_prefix('.').or_else(|| rest.strip_prefix(',')) {
168                Some(after) => {
169                    let n = after.bytes().take_while(u8::is_ascii_digit).count();
170                    if !(1..=9).contains(&n) {
171                        return None;
172                    }
173                    let frac = &after[..n];
174                    let mut three = String::from(&frac[..n.min(3)]);
175                    while three.len() < 3 {
176                        three.push('0');
177                    }
178                    (second, true, three.parse::<i64>().ok()?, &after[n..])
179                }
180                None => (second, false, 0, rest),
181            }
182        }
183        None => (0, false, 0, s),
184    };
185    if hour > 23 || minute > 59 || second > 59 {
186        return None;
187    }
188    let mut offset = 0;
189    let s = if let Some(rest) = s.strip_prefix('Z').or_else(|| s.strip_prefix('z')) {
190        rest
191    } else if let Some(sign) = s.chars().next().filter(|c| *c == '+' || *c == '-') {
192        let rest = &s[1..];
193        let (oh, rest) = digits(rest, 2)?;
194        let rest = rest.strip_prefix(':').unwrap_or(rest);
195        let (om, rest) = digits(rest, 2)?;
196        if oh > 23 || om > 59 {
197            return None;
198        }
199        offset = (oh * 60 + om) * if sign == '-' { -1 } else { 1 };
200        rest
201    } else {
202        s
203    };
204    if !s.is_empty() {
205        return None;
206    }
207    let local = utc_ms(year, month, day, hour, minute, second, millis);
208    Some(DateValue {
209        ms: local - offset * MINUTE_MS,
210        offset,
211        date_only: false,
212        fraction,
213    })
214}
215
216/// The fields of a value in the offset it was written in (`weekday` 1 =
217/// Monday … 7 = Sunday).
218#[derive(Clone, Copy, Debug, PartialEq, Eq)]
219pub struct Fields {
220    pub year: i64,
221    pub month: i64,
222    pub day: i64,
223    pub hour: i64,
224    pub minute: i64,
225    pub second: i64,
226    pub millis: i64,
227    pub weekday: i64,
228}
229
230/// The fields of the value in the offset it was written in.
231#[must_use]
232pub fn fields_of(d: &DateValue) -> Fields {
233    let t = d.ms + d.offset * MINUTE_MS;
234    let days = t.div_euclid(DAY_MS);
235    let rem = t.rem_euclid(DAY_MS);
236    let (year, month, day) = civil_from_days(days);
237    Fields {
238        year,
239        month,
240        day,
241        hour: rem / 3_600_000,
242        minute: rem % 3_600_000 / MINUTE_MS,
243        second: rem % MINUTE_MS / 1000,
244        millis: rem % 1000,
245        weekday: (dow_of_days(days) + 6) % 7 + 1,
246    }
247}
248
249/// The canonical string: `YYYY-MM-DD` for a date-only value; otherwise
250/// `YYYY-MM-DDThh:mm:ss` + `.fff` when the value carries a fraction (as
251/// written, or because arithmetic produced one) + `Z` for UTC or the written
252/// `±hh:mm`.
253#[must_use]
254pub fn render_date(d: &DateValue) -> String {
255    let f = fields_of(d);
256    let ymd = format!("{:04}-{:02}-{:02}", f.year, f.month, f.day);
257    if d.date_only {
258        return ymd;
259    }
260    let mut s = format!("{ymd}T{:02}:{:02}:{:02}", f.hour, f.minute, f.second);
261    if d.fraction || f.millis != 0 {
262        s.push_str(&format!(".{:03}", f.millis));
263    }
264    if d.offset == 0 {
265        s.push('Z');
266    } else {
267        let a = d.offset.abs();
268        s.push_str(&format!(
269            "{}{:02}:{:02}",
270            if d.offset < 0 { '-' } else { '+' },
271            a / 60,
272            a % 60
273        ));
274    }
275    s
276}
277
278/// `date(x)` on a string that is not a named point: the canonical form, or `None`.
279#[must_use]
280pub fn canonical_date(x: &Value) -> Option<String> {
281    parse_date(x).as_ref().map(render_date)
282}
283
284// ---- named points (the host clock, UTC) --------------------------------------------
285
286/// The named points `date()` resolves against the clock.
287pub const NAMED_POINTS: [&str; 10] = [
288    "now",
289    "today",
290    "yesterday",
291    "tomorrow",
292    "sow",
293    "eow",
294    "som",
295    "eom",
296    "soy",
297    "eoy",
298];
299
300const fn date_only(ms: i64) -> DateValue {
301    DateValue {
302        ms,
303        offset: 0,
304        date_only: true,
305        fraction: false,
306    }
307}
308
309/// Resolve a named point at the instant `now_ms` (UTC): `now` is the instant
310/// with milliseconds (the form the store stamps commits in); the others are
311/// date-only — `sow`/`eow` the Monday/Sunday of the current week, `som`/`eom`
312/// the first/last day of the month, `soy`/`eoy` of the year. `None` for a
313/// name that is not a point.
314#[must_use]
315pub fn named_point(name: &str, now_ms: i64) -> Option<DateValue> {
316    let days = now_ms.div_euclid(DAY_MS);
317    let (y, mo, _) = civil_from_days(days);
318    let today = days * DAY_MS;
319    let dow = dow_of_days(days);
320    Some(match name {
321        "now" => DateValue {
322            ms: now_ms,
323            offset: 0,
324            date_only: false,
325            fraction: true,
326        },
327        "today" => date_only(today),
328        "yesterday" => date_only(today - DAY_MS),
329        "tomorrow" => date_only(today + DAY_MS),
330        "sow" => date_only(today - ((dow + 6) % 7) * DAY_MS),
331        "eow" => date_only(today + (6 - (dow + 6) % 7) * DAY_MS),
332        "som" => date_only(utc_ms(y, mo, 1, 0, 0, 0, 0)),
333        "eom" => date_only(utc_ms(y, mo, days_in_month(y, mo), 0, 0, 0, 0)),
334        "soy" => date_only(utc_ms(y, 1, 1, 0, 0, 0, 0)),
335        "eoy" => date_only(utc_ms(y, 12, 31, 0, 0, 0, 0)),
336        _ => return None,
337    })
338}
339
340/// `date(x)`: a named point against the clock, else the canonical ISO form, else `None`.
341#[must_use]
342pub fn date_of(x: &Value, now_ms: i64) -> Option<String> {
343    let Value::Str(s) = x else { return None };
344    let key = s.trim().to_lowercase();
345    if NAMED_POINTS.contains(&key.as_str()) {
346        return named_point(&key, now_ms).as_ref().map(render_date);
347    }
348    canonical_date(x)
349}
350
351// ---- durations -------------------------------------------------------------------
352
353/// A parsed duration: the calendar counts `years` and `months` kept apart
354/// from the fixed-length `seconds`, so `plus` can be calendar-aware while
355/// `dur()` applies the 365-day / 30-day conventions.
356#[derive(Clone, Copy, Debug, PartialEq)]
357pub struct Duration {
358    pub years: f64,
359    pub months: f64,
360    pub seconds: f64,
361}
362
363/// Days per month / year under the fixed conventions `dur()` applies.
364pub const MONTH_SECONDS: f64 = 30.0 * 86_400.0;
365pub const YEAR_SECONDS: f64 = 365.0 * 86_400.0;
366
367fn unit_seconds(unit: &str) -> Option<f64> {
368    Some(match unit {
369        "w" | "wk" | "wks" | "week" | "weeks" => 604_800.0,
370        "d" | "day" | "days" => 86_400.0,
371        "h" | "hr" | "hrs" | "hour" | "hours" => 3600.0,
372        "m" | "min" | "mins" | "minute" | "minutes" => 60.0,
373        "s" | "sec" | "secs" | "second" | "seconds" => 1.0,
374        _ => return None,
375    })
376}
377
378fn is_year_unit(unit: &str) -> bool {
379    matches!(unit, "y" | "yr" | "yrs" | "year" | "years")
380}
381
382fn is_month_unit(unit: &str) -> bool {
383    matches!(unit, "mo" | "mos" | "month" | "months")
384}
385
386/// An unsigned decimal `\d+(?:\.\d+)?` at the head of `s`: its value and the rest.
387fn take_number(s: &str) -> Option<(f64, &str)> {
388    let int = s.bytes().take_while(u8::is_ascii_digit).count();
389    if int == 0 {
390        return None;
391    }
392    let mut end = int;
393    if s[end..].starts_with('.') {
394        let frac = s[end + 1..].bytes().take_while(u8::is_ascii_digit).count();
395        if frac > 0 {
396            end += 1 + frac;
397        }
398    }
399    Some((s[..end].parse().ok()?, &s[end..]))
400}
401
402/// One ISO component `<number><designator>` at the head of `s` (the
403/// designator case-insensitive); the number and the rest, or `None` when the
404/// head is not that component (nothing consumed).
405fn iso_component(s: &str, designator: char) -> Option<(f64, &str)> {
406    let (n, rest) = take_number(s)?;
407    let c = rest.chars().next()?;
408    if c.eq_ignore_ascii_case(&designator) {
409        Some((n, &rest[1..]))
410    } else {
411        None
412    }
413}
414
415/// The ISO-8601 duration `[+-]PnYnMnWnDTnHnMnS` (components in order, any
416/// omitted, decimals allowed, case-insensitive). `Err(())` when `s` is not
417/// of that shape at all; `Ok(None)` when it is but names no component (a
418/// bare `P` / `PT`).
419fn parse_iso_duration(s: &str) -> Result<Option<Duration>, ()> {
420    let (sign, rest) = match s.strip_prefix('-') {
421        Some(r) => (-1.0, r),
422        None => (1.0, s.strip_prefix('+').unwrap_or(s)),
423    };
424    let mut rest = rest
425        .strip_prefix('P')
426        .or_else(|| rest.strip_prefix('p'))
427        .ok_or(())?;
428    let mut parts = [None; 7];
429    for (i, designator) in ['Y', 'M', 'W', 'D'].into_iter().enumerate() {
430        if let Some((n, r)) = iso_component(rest, designator) {
431            parts[i] = Some(n);
432            rest = r;
433        }
434    }
435    if let Some(r) = rest.strip_prefix('T').or_else(|| rest.strip_prefix('t')) {
436        rest = r;
437        for (i, designator) in ['H', 'M', 'S'].into_iter().enumerate() {
438            if let Some((n, r)) = iso_component(rest, designator) {
439                parts[4 + i] = Some(n);
440                rest = r;
441            }
442        }
443    }
444    if !rest.is_empty() {
445        return Err(());
446    }
447    if parts.iter().all(Option::is_none) {
448        return Ok(None);
449    }
450    let n = |i: usize| parts[i].unwrap_or(0.0);
451    Ok(Some(Duration {
452        years: sign * n(0),
453        months: sign * n(1),
454        seconds: sign * (n(2) * 604_800.0 + n(3) * 86_400.0 + n(4) * 3600.0 + n(5) * 60.0 + n(6)),
455    }))
456}
457
458/// Parse a duration string: Dataview's `<number> <unit>` pairs, each pair
459/// separated from the next by spaces, a comma, or nothing (`"1h30m"`, `"2
460/// days, 3 hours"`; a trailing separator is malformed), units
461/// case-insensitive: y|yr|yrs|year|years, mo|mos|month|months,
462/// w|wk|wks|week|weeks, d|day|days, h|hr|hrs|hour|hours,
463/// m|min|mins|minute|minutes, s|sec|secs|second|seconds — or an ISO-8601
464/// duration (`P1DT2H`, `PT90M`, `P2W`). `None` when it is neither.
465#[must_use]
466pub fn parse_duration(x: &Value) -> Option<Duration> {
467    let Value::Str(s) = x else { return None };
468    let s = s.trim();
469    if s.is_empty() {
470        return None;
471    }
472    if let Ok(iso) = parse_iso_duration(s) {
473        return iso;
474    }
475    let mut rest = s;
476    let mut d = Duration {
477        years: 0.0,
478        months: 0.0,
479        seconds: 0.0,
480    };
481    loop {
482        // `^(-?\d+(?:\.\d+)?)\s*([a-zA-Z]+)`
483        let (sign, after_sign) = match rest.strip_prefix('-') {
484            Some(r) => (-1.0, r),
485            None => (1.0, rest),
486        };
487        let (n, after_num) = take_number(after_sign)?;
488        let after_space = after_num.trim_start();
489        let unit_len = after_space
490            .bytes()
491            .take_while(u8::is_ascii_alphabetic)
492            .count();
493        if unit_len == 0 {
494            return None;
495        }
496        let unit = after_space[..unit_len].to_ascii_lowercase();
497        let n = sign * n;
498        if let Some(secs) = unit_seconds(&unit) {
499            d.seconds += n * secs;
500        } else if is_month_unit(&unit) {
501            d.months += n;
502        } else if is_year_unit(&unit) {
503            d.years += n;
504        } else {
505            return None;
506        }
507        rest = &after_space[unit_len..];
508        if rest.is_empty() {
509            break;
510        }
511        // the separator: spaces, one comma among them, or none at all (`1h30m`)
512        rest = rest.trim_start();
513        rest = rest.strip_prefix(',').unwrap_or(rest).trim_start();
514        if rest.is_empty() {
515            return None;
516        }
517    }
518    Some(d)
519}
520
521/// A duration's length in seconds under the conventions: a year is 365 days, a month 30.
522#[must_use]
523pub fn duration_seconds(d: &Duration) -> f64 {
524    d.years * YEAR_SECONDS + d.months * MONTH_SECONDS + d.seconds
525}
526
527/// `dur(x)`: a number is that many seconds; a string is parsed; else `None`.
528#[must_use]
529pub fn dur_of(x: &Value) -> Option<f64> {
530    match x {
531        Value::Number(n) => n.is_finite().then_some(*n),
532        other => parse_duration(other).as_ref().map(duration_seconds),
533    }
534}
535
536// ---- arithmetic -------------------------------------------------------------------
537
538/// JavaScript's `Math.round`: half rounds toward +∞.
539fn js_round(x: f64) -> f64 {
540    (x + 0.5).floor()
541}
542
543/// Shift a date by a whole number of calendar `months` (the month field moves
544/// and the day is clamped to the new month's length) and then by `seconds`.
545/// A date-only value stays date-only when the seconds are a whole number of
546/// days; otherwise it becomes a datetime in its offset (UTC for a date-only
547/// receiver).
548#[must_use]
549pub fn shift_date(d: &DateValue, months: i64, seconds: f64) -> DateValue {
550    let mut ms = d.ms;
551    if months != 0 {
552        let f = fields_of(d);
553        let total = f.year * 12 + (f.month - 1) + months;
554        let year = total.div_euclid(12);
555        let month = total.rem_euclid(12) + 1;
556        let day = f.day.min(days_in_month(year, month));
557        ms = utc_ms(year, month, day, f.hour, f.minute, f.second, f.millis) - d.offset * MINUTE_MS;
558    }
559    // Saturating on the way to i64: a shift this large is absurd anyway.
560    let delta_ms = js_round(seconds * 1000.0) as i64;
561    ms += delta_ms;
562    DateValue {
563        ms,
564        offset: d.offset,
565        date_only: d.date_only && delta_ms % DAY_MS == 0,
566        fraction: d.fraction,
567    }
568}
569
570/// The argument of `plus`/`minus` as a duration: a number of seconds (never
571/// calendar) or a duration string (months/years calendar-aware). `None` otherwise.
572fn duration_arg(x: &Value) -> Option<Duration> {
573    match x {
574        Value::Number(n) => n.is_finite().then_some(Duration {
575            years: 0.0,
576            months: 0.0,
577            seconds: *n,
578        }),
579        other => parse_duration(other),
580    }
581}
582
583/// A duration as `plus` applies it: the WHOLE years and months are calendar
584/// months; a fractional year or month count is not calendar — its fraction is
585/// 365-day / 30-day seconds — and joins the fixed-length seconds.
586#[must_use]
587pub fn calendar_parts(dur: &Duration) -> (i64, f64) {
588    let wy = dur.years.trunc();
589    let wm = dur.months.trunc();
590    (
591        (wy * 12.0 + wm) as i64,
592        dur.seconds + (dur.years - wy) * YEAR_SECONDS + (dur.months - wm) * MONTH_SECONDS,
593    )
594}
595
596/// `d.plus(x)` (`sign` 1) / `d.minus(x)` (`sign` -1).
597#[must_use]
598pub fn plus_minus(recv: &Value, arg: &Value, sign: i64) -> Option<String> {
599    let d = parse_date(recv)?;
600    let dur = duration_arg(arg)?;
601    let (months, seconds) = calendar_parts(&dur);
602    Some(render_date(&shift_date(
603        &d,
604        sign * months,
605        sign as f64 * seconds,
606    )))
607}
608
609/// `a.until(b)`: seconds from `a` to `b` (negative when `b` is earlier), by instant.
610#[must_use]
611pub fn until(a: &Value, b: &Value) -> Option<f64> {
612    let da = parse_date(a)?;
613    let db = parse_date(b)?;
614    Some((db.ms - da.ms) as f64 / 1000.0)
615}
616
617// ---- format -------------------------------------------------------------------------
618
619const MONTHS: [&str; 12] = [
620    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
621];
622const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
623/// The `format` tokens, longest first (a scan takes the longest token at each position).
624const FORMAT_TOKENS: [&str; 8] = ["yyyy", "MMM", "EEE", "MM", "dd", "HH", "mm", "ss"];
625
626/// `d.format(pattern)`: `yyyy` year, `MM` month, `dd` day, `HH` hour, `mm`
627/// minute, `ss` second (two digits each), `MMM` the English month
628/// abbreviation (`Jan`), `EEE` the English weekday abbreviation (`Mon`);
629/// every other character is literal. Fields are read in the written offset;
630/// a date-only value has `00` time fields.
631#[must_use]
632pub fn format_date(recv: &Value, pattern: &Value) -> Option<String> {
633    let d = parse_date(recv)?;
634    let Value::Str(pattern) = pattern else {
635        return None;
636    };
637    let f = fields_of(&d);
638    let mut out = String::new();
639    let mut rest = pattern.as_str();
640    while !rest.is_empty() {
641        match FORMAT_TOKENS.iter().find(|t| rest.starts_with(**t)) {
642            Some(tok) => {
643                match *tok {
644                    "yyyy" => out.push_str(&format!("{:04}", f.year)),
645                    "MMM" => out.push_str(MONTHS[(f.month - 1) as usize]),
646                    "EEE" => out.push_str(WEEKDAYS[(f.weekday - 1) as usize]),
647                    "MM" => out.push_str(&format!("{:02}", f.month)),
648                    "dd" => out.push_str(&format!("{:02}", f.day)),
649                    "HH" => out.push_str(&format!("{:02}", f.hour)),
650                    "mm" => out.push_str(&format!("{:02}", f.minute)),
651                    _ => out.push_str(&format!("{:02}", f.second)),
652                }
653                rest = &rest[tok.len()..];
654            }
655            None => {
656                let c = rest.chars().next()?;
657                out.push(c);
658                rest = &rest[c.len_utf8()..];
659            }
660        }
661    }
662    Some(out)
663}
664
665// ---- duration rendering -----------------------------------------------------------------
666
667struct Split {
668    neg: bool,
669    d: f64,
670    h: f64,
671    m: f64,
672    s: f64,
673}
674
675fn split_seconds(n: f64) -> Split {
676    let neg = n < 0.0;
677    let a = js_round(n.abs() * 1000.0) / 1000.0;
678    Split {
679        neg,
680        d: (a / 86_400.0).floor(),
681        h: (a % 86_400.0 / 3600.0).floor(),
682        m: (a % 3600.0 / 60.0).floor(),
683        s: js_round(a % 60.0 * 1000.0) / 1000.0,
684    }
685}
686
687/// A number as JavaScript prints it (`2`, `1.5`).
688fn num(n: f64) -> String {
689    Value::Number(n).to_string()
690}
691
692/// `n.dur_text()`: `"2d 3h"`, `"1h 30m"`, `"45s"` — days, hours, minutes,
693/// seconds, zero parts omitted, `"0s"` for zero, a leading `-` for a negative
694/// duration, a fractional second kept (`"1.5s"`). `None` for a non-number.
695#[must_use]
696pub fn dur_text(n: &Value) -> Option<String> {
697    let Value::Number(n) = n else { return None };
698    if !n.is_finite() {
699        return None;
700    }
701    let p = split_seconds(*n);
702    let mut parts = Vec::new();
703    if p.d != 0.0 {
704        parts.push(format!("{}d", num(p.d)));
705    }
706    if p.h != 0.0 {
707        parts.push(format!("{}h", num(p.h)));
708    }
709    if p.m != 0.0 {
710        parts.push(format!("{}m", num(p.m)));
711    }
712    if p.s != 0.0 {
713        parts.push(format!("{}s", num(p.s)));
714    }
715    if parts.is_empty() {
716        return Some("0s".to_owned());
717    }
718    Some(format!(
719        "{}{}",
720        if p.neg { "-" } else { "" },
721        parts.join(" ")
722    ))
723}
724
725/// `n.dur_iso()`: the ISO-8601 duration `P[nD][T[nH][nM][nS]]` — `PT1H30M`,
726/// `P2DT3H`, `PT45S`, `PT0S` for zero, a leading `-` for a negative duration.
727/// `None` for a non-number.
728#[must_use]
729pub fn dur_iso(n: &Value) -> Option<String> {
730    let Value::Number(n) = n else { return None };
731    if !n.is_finite() {
732        return None;
733    }
734    let p = split_seconds(*n);
735    let mut out = String::from("P");
736    if p.d != 0.0 {
737        out.push_str(&format!("{}D", num(p.d)));
738    }
739    if p.h != 0.0 || p.m != 0.0 || p.s != 0.0 {
740        out.push('T');
741        if p.h != 0.0 {
742            out.push_str(&format!("{}H", num(p.h)));
743        }
744        if p.m != 0.0 {
745            out.push_str(&format!("{}M", num(p.m)));
746        }
747        if p.s != 0.0 {
748            out.push_str(&format!("{}S", num(p.s)));
749        }
750    }
751    if out == "P" {
752        out = "PT0S".to_owned();
753    }
754    Some(format!("{}{out}", if p.neg { "-" } else { "" }))
755}
756
757// ---- the clock --------------------------------------------------------------------------
758
759/// The process wall clock in milliseconds since the Unix epoch — what a
760/// caller without a pinned clock resolves the named points against.
761#[must_use]
762pub fn wall_clock_ms() -> i64 {
763    std::time::SystemTime::now()
764        .duration_since(std::time::UNIX_EPOCH)
765        .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
766        .unwrap_or(0)
767}
768
769/// A clock stamp (`YYYY-MM-DDThh:mm:ss.fffZ`, the form the `Surface` stamps
770/// commits in; any ISO datetime accepted) as milliseconds, or the wall clock
771/// when it does not parse.
772#[must_use]
773pub fn stamp_ms(ts: &str) -> i64 {
774    parse_date(&Value::Str(ts.to_owned())).map_or_else(wall_clock_ms, |d| d.ms)
775}
776
777// ---- the OQX arms ---------------------------------------------------------------------
778
779/// The free functions and their arity.
780pub const DATE_FUNCTIONS: [(&str, usize); 2] = [("date", 1), ("dur", 1)];
781/// The methods and their arity: on a date string, and (`dur_text`/`dur_iso`) on a number.
782pub const DATE_METHODS: [(&str, usize); 14] = [
783    ("plus", 1),
784    ("minus", 1),
785    ("until", 1),
786    ("format", 1),
787    ("year", 0),
788    ("month", 0),
789    ("day", 0),
790    ("weekday", 0),
791    ("hour", 0),
792    ("minute", 0),
793    ("second", 0),
794    ("date", 0),
795    ("dur_text", 0),
796    ("dur_iso", 0),
797];
798
799/// The arity of `date`/`dur`, or `None` for any other name.
800#[must_use]
801pub fn date_function_arity(name: &str) -> Option<usize> {
802    DATE_FUNCTIONS
803        .iter()
804        .find(|(n, _)| *n == name)
805        .map(|(_, a)| *a)
806}
807
808/// The arity of a date method, or `None` for any other name.
809#[must_use]
810pub fn date_method_arity(name: &str) -> Option<usize> {
811    DATE_METHODS
812        .iter()
813        .find(|(n, _)| *n == name)
814        .map(|(_, a)| *a)
815}
816
817fn arity(spelling: &str, want: usize, got: usize) -> Result<(), OqxError> {
818    if got == want {
819        Ok(())
820    } else {
821        Err(OqxError::eval(format!(
822            "{spelling} takes exactly {want} argument{}, got {got}",
823            if want == 1 { "" } else { "s" }
824        )))
825    }
826}
827
828fn str_or_null(s: Option<String>) -> Value {
829    s.map_or(Value::Null, Value::Str)
830}
831
832fn num_or_null(n: Option<f64>) -> Value {
833    n.map_or(Value::Null, Value::Number)
834}
835
836/// `call_function` arm: `date(x)` / `dur(x)`; `None` for any other name.
837#[must_use]
838pub fn date_function(name: &str, args: &[Value], now_ms: i64) -> Option<oqx::Result<Value>> {
839    let want = date_function_arity(name)?;
840    if let Err(e) = arity(&format!("{name}()"), want, args.len()) {
841        return Some(Err(e));
842    }
843    let x = args.first().unwrap_or(&Value::Undefined);
844    Some(Ok(if name == "date" {
845        str_or_null(date_of(x, now_ms))
846    } else {
847        num_or_null(dur_of(x))
848    }))
849}
850
851/// `call_method` arm: the date methods on a string receiver and the duration
852/// renderers on a number; a receiver of any other kind yields absent (the
853/// methods are total). `None` for any other name.
854#[must_use]
855pub fn date_method(name: &str, recv: &Value, args: &[Value]) -> Option<oqx::Result<Value>> {
856    let want = date_method_arity(name)?;
857    if let Err(e) = arity(&format!(".{name}()"), want, args.len()) {
858        return Some(Err(e));
859    }
860    let arg = args.first().unwrap_or(&Value::Undefined);
861    let field = |pick: fn(&Fields) -> i64| {
862        num_or_null(parse_date(recv).map(|d| pick(&fields_of(&d)) as f64))
863    };
864    Some(Ok(match name {
865        "plus" => str_or_null(plus_minus(recv, arg, 1)),
866        "minus" => str_or_null(plus_minus(recv, arg, -1)),
867        "until" => num_or_null(until(recv, arg)),
868        "format" => str_or_null(format_date(recv, arg)),
869        "year" => field(|f| f.year),
870        "month" => field(|f| f.month),
871        "day" => field(|f| f.day),
872        "weekday" => field(|f| f.weekday),
873        "hour" => field(|f| f.hour),
874        "minute" => field(|f| f.minute),
875        "second" => field(|f| f.second),
876        "date" => str_or_null(parse_date(recv).map(|d| {
877            render_date(&DateValue {
878                date_only: true,
879                ..d
880            })
881        })),
882        "dur_text" => str_or_null(dur_text(recv)),
883        _ => str_or_null(dur_iso(recv)),
884    }))
885}
886
887#[cfg(test)]
888mod tests {
889    // Mirrors the reference's `dates.test.ts` (the fold's two tests live beside
890    // `fold_dates` in `query.rs`).
891    use super::*;
892
893    /// 2026-09-27T00:00:00.000Z — the corpus clock, a Sunday.
894    const NOW: i64 = 1_790_467_200_000;
895
896    fn s(x: &str) -> Value {
897        Value::Str(x.to_owned())
898    }
899    fn n(x: f64) -> Value {
900        Value::Number(x)
901    }
902    fn canon(x: &str) -> Option<String> {
903        canonical_date(&s(x))
904    }
905    fn utc(y: i64, mo: i64, d: i64, h: i64, mi: i64, sec: i64, ms: i64) -> i64 {
906        utc_ms(y, mo, d, h, mi, sec, ms)
907    }
908
909    #[test]
910    fn corpus_clock_constant_is_the_instant() {
911        assert_eq!(NOW, utc(2026, 9, 27, 0, 0, 0, 0));
912        assert_eq!(stamp_ms("2026-09-27T00:00:00.000Z"), NOW);
913    }
914
915    // ---- parse and render ----
916
917    #[test]
918    fn date_only_stays_date_only_and_a_datetime_is_canonical() {
919        assert_eq!(canon("2026-10-15").as_deref(), Some("2026-10-15"));
920        assert_eq!(
921            canon("2026-10-15T09:00:00Z").as_deref(),
922            Some("2026-10-15T09:00:00Z")
923        );
924        assert_eq!(
925            canon("2026-10-15T09:00:00+00:00").as_deref(),
926            Some("2026-10-15T09:00:00Z")
927        );
928        assert_eq!(
929            canon("2026-10-15T09:00:00+02:00").as_deref(),
930            Some("2026-10-15T09:00:00+02:00")
931        );
932        assert_eq!(
933            canon("2026-10-15T09:00:00-0500").as_deref(),
934            Some("2026-10-15T09:00:00-05:00")
935        );
936        assert_eq!(
937            canon("2026-10-15T09:00").as_deref(),
938            Some("2026-10-15T09:00:00Z")
939        );
940        assert_eq!(
941            canon("2026-10-15 09:00:00").as_deref(),
942            Some("2026-10-15T09:00:00Z")
943        );
944        assert_eq!(
945            canon("2026-10-15t09:00:00z").as_deref(),
946            Some("2026-10-15T09:00:00Z")
947        );
948        assert_eq!(
949            canon("2026-10-15T09:00:00.5Z").as_deref(),
950            Some("2026-10-15T09:00:00.500Z")
951        );
952        assert_eq!(
953            canon("2026-10-15T09:00:00.123456Z").as_deref(),
954            Some("2026-10-15T09:00:00.123Z")
955        );
956        assert_eq!(
957            canon("2026-09-27T00:00:00.000Z").as_deref(),
958            Some("2026-09-27T00:00:00.000Z")
959        );
960        assert_eq!(canon(" 2026-10-15 ").as_deref(), Some("2026-10-15"));
961    }
962
963    #[test]
964    fn rejects_what_is_not_an_iso_date() {
965        for bad in [
966            "15/10/2026",
967            "2026-13-01",
968            "2026-02-30",
969            "2026-00-10",
970            "2026-10-15T24:00:00Z",
971            "2026-10-15T09:60:00Z",
972            "2026-10-15T09:00:60Z",
973            "2026-10-15T09:00:00+24:00",
974            "",
975            "today",
976            "2026-1-5",
977            "20261015",
978            "2026-10-15T09:00:00.Z",
979            "2026-10-15T09:00:00.1234567890Z",
980        ] {
981            assert!(parse_date(&s(bad)).is_none(), "{bad}");
982        }
983        assert!(parse_date(&n(5.0)).is_none());
984        assert!(parse_date(&Value::Null).is_none());
985        assert!(parse_date(&Value::Undefined).is_none());
986        assert!(parse_date(&Value::Bool(true)).is_none());
987    }
988
989    #[test]
990    fn leap_days_parse_in_leap_years_only() {
991        assert_eq!(canon("2024-02-29").as_deref(), Some("2024-02-29"));
992        assert_eq!(canon("2026-02-29"), None);
993        assert_eq!(canon("2000-02-29").as_deref(), Some("2000-02-29"));
994        assert_eq!(canon("1900-02-29"), None);
995    }
996
997    #[test]
998    fn keeps_the_written_offset_as_the_values_fields() {
999        let d = parse_date(&s("2026-10-15T23:30:00-05:00")).unwrap();
1000        assert_eq!(d.offset, -300);
1001        assert_eq!(d.ms, utc(2026, 10, 16, 4, 30, 0, 0));
1002        assert_eq!(render_date(&d), "2026-10-15T23:30:00-05:00");
1003    }
1004
1005    // ---- named points ----
1006
1007    #[test]
1008    fn resolves_every_point_at_the_corpus_clock() {
1009        let at = |name: &str| render_date(&named_point(name, NOW).unwrap());
1010        assert_eq!(at("now"), "2026-09-27T00:00:00.000Z");
1011        assert_eq!(at("today"), "2026-09-27");
1012        assert_eq!(at("yesterday"), "2026-09-26");
1013        assert_eq!(at("tomorrow"), "2026-09-28");
1014        assert_eq!(at("sow"), "2026-09-21");
1015        assert_eq!(at("eow"), "2026-09-27");
1016        assert_eq!(at("som"), "2026-09-01");
1017        assert_eq!(at("eom"), "2026-09-30");
1018        assert_eq!(at("soy"), "2026-01-01");
1019        assert_eq!(at("eoy"), "2026-12-31");
1020        assert!(NAMED_POINTS.iter().all(|p| named_point(p, NOW).is_some()));
1021        assert!(named_point("noon", NOW).is_none());
1022    }
1023
1024    #[test]
1025    fn week_and_month_edges_on_other_instants() {
1026        let wed = utc(2026, 10, 14, 15, 0, 0, 0); // Wednesday
1027        assert_eq!(render_date(&named_point("sow", wed).unwrap()), "2026-10-12");
1028        assert_eq!(render_date(&named_point("eow", wed).unwrap()), "2026-10-18");
1029        let mon = utc(2026, 10, 12, 0, 0, 0, 0);
1030        assert_eq!(render_date(&named_point("sow", mon).unwrap()), "2026-10-12");
1031        assert_eq!(
1032            render_date(&named_point("eom", utc(2024, 2, 10, 0, 0, 0, 0)).unwrap()),
1033            "2024-02-29"
1034        );
1035        assert_eq!(
1036            render_date(&named_point("eom", utc(2026, 12, 10, 0, 0, 0, 0)).unwrap()),
1037            "2026-12-31"
1038        );
1039        assert_eq!(
1040            render_date(&named_point("now", utc(2026, 10, 14, 15, 0, 7, 250)).unwrap()),
1041            "2026-10-14T15:00:07.250Z"
1042        );
1043    }
1044
1045    #[test]
1046    fn date_of_a_named_point_trimmed_case_insensitive_else_canonical_else_none() {
1047        assert_eq!(date_of(&s("Today"), NOW).as_deref(), Some("2026-09-27"));
1048        assert_eq!(
1049            date_of(&s(" now "), NOW).as_deref(),
1050            Some("2026-09-27T00:00:00.000Z")
1051        );
1052        assert_eq!(
1053            date_of(&s("2026-01-05"), NOW).as_deref(),
1054            Some("2026-01-05")
1055        );
1056        assert_eq!(date_of(&s("nope"), NOW), None);
1057        assert_eq!(date_of(&n(5.0), NOW), None);
1058        assert_eq!(date_of(&Value::Undefined, NOW), None);
1059    }
1060
1061    // ---- the duration grammar ----
1062
1063    fn dur(x: &str) -> Option<f64> {
1064        dur_of(&s(x))
1065    }
1066
1067    #[test]
1068    fn dataview_pairs_spaces_commas_no_spaces_every_unit_case_decimals() {
1069        assert_eq!(dur("1h30m"), Some(5400.0));
1070        assert_eq!(dur("1 hour 30 minutes"), Some(5400.0));
1071        assert_eq!(dur("2 days, 3 hours"), Some(2.0 * 86400.0 + 3.0 * 3600.0));
1072        assert_eq!(dur("1 week"), Some(604_800.0));
1073        assert_eq!(dur("1 month"), Some(30.0 * 86400.0));
1074        assert_eq!(dur("1 year"), Some(365.0 * 86400.0));
1075        assert_eq!(dur("90 seconds"), Some(90.0));
1076        assert_eq!(dur("1.5h"), Some(5400.0));
1077        assert_eq!(dur("2 YRS"), Some(2.0 * 365.0 * 86400.0));
1078        assert_eq!(dur("3 wks 2 d"), Some(3.0 * 604_800.0 + 2.0 * 86400.0));
1079        assert_eq!(dur("2mo"), Some(60.0 * 86400.0));
1080        assert_eq!(dur("5 mins, 10 secs"), Some(310.0));
1081        assert_eq!(
1082            dur("1y 1mo 1w 1d 1h 1m 1s"),
1083            Some(365.0 * 86400.0 + 30.0 * 86400.0 + 604_800.0 + 86400.0 + 3600.0 + 61.0)
1084        );
1085        assert_eq!(dur("-1 day"), Some(-86400.0));
1086    }
1087
1088    #[test]
1089    fn iso_8601_durations_and_numbers() {
1090        assert_eq!(dur("PT1H30M"), Some(5400.0));
1091        assert_eq!(dur("P1DT2H"), Some(86400.0 + 7200.0));
1092        assert_eq!(dur("P2W"), Some(2.0 * 604_800.0));
1093        assert_eq!(dur("P1M"), Some(30.0 * 86400.0));
1094        assert_eq!(
1095            dur("P1Y2M3DT4H5M6S"),
1096            Some(
1097                365.0 * 86400.0 + 60.0 * 86400.0 + 3.0 * 86400.0 + 4.0 * 3600.0 + 5.0 * 60.0 + 6.0
1098            )
1099        );
1100        assert_eq!(dur("PT0S"), Some(0.0));
1101        assert_eq!(dur("pt90m"), Some(5400.0));
1102        assert_eq!(dur("-PT1H"), Some(-3600.0));
1103        assert_eq!(dur_of(&n(90.0)), Some(90.0));
1104        assert_eq!(dur_of(&n(1.5)), Some(1.5));
1105        assert_eq!(dur_of(&n(-60.0)), Some(-60.0));
1106        assert_eq!(dur_of(&n(f64::NAN)), None);
1107        assert_eq!(dur_of(&n(f64::INFINITY)), None);
1108    }
1109
1110    #[test]
1111    fn keeps_calendar_months_apart_from_seconds() {
1112        assert_eq!(
1113            parse_duration(&s("1 year 2 months 3 days")),
1114            Some(Duration {
1115                years: 1.0,
1116                months: 2.0,
1117                seconds: 3.0 * 86400.0
1118            })
1119        );
1120        assert_eq!(
1121            parse_duration(&s("P1Y2M")),
1122            Some(Duration {
1123                years: 1.0,
1124                months: 2.0,
1125                seconds: 0.0
1126            })
1127        );
1128        assert_eq!(
1129            duration_seconds(&Duration {
1130                years: 1.0,
1131                months: 1.0,
1132                seconds: 1.0
1133            }),
1134            365.0 * 86400.0 + 30.0 * 86400.0 + 1.0
1135        );
1136    }
1137
1138    #[test]
1139    fn rejects_what_is_not_a_duration() {
1140        for bad in [
1141            "",
1142            "   ",
1143            "abc",
1144            "3 fortnights",
1145            "90",
1146            "P",
1147            "PT",
1148            "1h 2x",
1149            "h1",
1150            "1 h,",
1151            ",1h",
1152            "1h,,2m",
1153            "1,5h",
1154            "one hour",
1155            "P1X",
1156            "1.",
1157        ] {
1158            assert!(parse_duration(&s(bad)).is_none(), "{bad:?}");
1159        }
1160        assert_eq!(dur_of(&Value::Null), None);
1161        assert_eq!(dur_of(&Value::Bool(true)), None);
1162        assert_eq!(dur_of(&Value::Undefined), None);
1163        assert_eq!(dur_of(&Value::Object(oqx::Object::default())), None);
1164    }
1165
1166    // ---- plus / minus ----
1167
1168    fn pm(recv: &str, arg: Value, sign: i64) -> Option<String> {
1169        plus_minus(&s(recv), &arg, sign)
1170    }
1171
1172    #[test]
1173    fn seconds_date_only_stays_date_only_for_whole_days_else_a_utc_datetime() {
1174        assert_eq!(
1175            pm("2026-01-31", n(86400.0), 1).as_deref(),
1176            Some("2026-02-01")
1177        );
1178        assert_eq!(
1179            pm("2026-01-31", n(2.0 * 86400.0), 1).as_deref(),
1180            Some("2026-02-02")
1181        );
1182        assert_eq!(
1183            pm("2026-01-31", n(3600.0), 1).as_deref(),
1184            Some("2026-01-31T01:00:00Z")
1185        );
1186        assert_eq!(
1187            pm("2026-01-31", n(30.0 * 86400.0), 1).as_deref(),
1188            Some("2026-03-02")
1189        ); // dur("1 month") is 30 days
1190        assert_eq!(pm("2026-01-31", n(0.0), 1).as_deref(), Some("2026-01-31"));
1191        assert_eq!(
1192            pm("2026-01-01", n(86400.0), -1).as_deref(),
1193            Some("2025-12-31")
1194        );
1195        assert_eq!(
1196            pm("2026-01-01", n(-86400.0), 1).as_deref(),
1197            Some("2025-12-31")
1198        );
1199    }
1200
1201    #[test]
1202    fn a_duration_string_is_calendar_aware_in_the_month_year_part_clamping_the_day() {
1203        assert_eq!(
1204            pm("2026-01-31", s("1 month"), 1).as_deref(),
1205            Some("2026-02-28")
1206        );
1207        assert_eq!(
1208            pm("2024-01-31", s("1 month"), 1).as_deref(),
1209            Some("2024-02-29")
1210        );
1211        assert_eq!(
1212            pm("2026-03-31", s("1 month"), -1).as_deref(),
1213            Some("2026-02-28")
1214        );
1215        assert_eq!(
1216            pm("2026-02-28", s("1 year"), 1).as_deref(),
1217            Some("2027-02-28")
1218        );
1219        assert_eq!(
1220            pm("2024-02-29", s("1 year"), 1).as_deref(),
1221            Some("2025-02-28")
1222        );
1223        assert_eq!(
1224            pm("2026-01-31", s("1 month 1 day"), 1).as_deref(),
1225            Some("2026-03-01")
1226        );
1227        assert_eq!(pm("2026-01-31", s("P1M"), 1).as_deref(), Some("2026-02-28"));
1228        assert_eq!(
1229            pm("2026-11-15", s("3 months"), 1).as_deref(),
1230            Some("2027-02-15")
1231        );
1232        assert_eq!(
1233            pm("2026-01-15", s("13 months"), -1).as_deref(),
1234            Some("2024-12-15")
1235        );
1236        assert_eq!(
1237            pm("2026-01-31T09:00:00Z", s("1 month"), 1).as_deref(),
1238            Some("2026-02-28T09:00:00Z")
1239        );
1240        assert_eq!(
1241            pm("2026-01-31T09:00:00+02:00", s("1 month"), 1).as_deref(),
1242            Some("2026-02-28T09:00:00+02:00")
1243        );
1244        assert_eq!(
1245            pm("2026-01-31", s("-1 month"), 1).as_deref(),
1246            Some("2025-12-31")
1247        );
1248        // a fractional month or year is not calendar: its fraction is 30-day / 365-day seconds
1249        assert_eq!(
1250            pm("2026-01-01", s("0.5 months"), 1).as_deref(),
1251            Some("2026-01-16")
1252        );
1253        assert_eq!(
1254            pm("2026-01-01", s("1.5 years"), 1).as_deref(),
1255            Some("2027-07-02T12:00:00Z")
1256        ); // 1 calendar year, then 182.5 days
1257        assert_eq!(
1258            pm("2026-01-01", s("1.5 years"), 1),
1259            pm("2027-01-01", n(182.5 * 86400.0), 1)
1260        );
1261    }
1262
1263    #[test]
1264    fn a_datetime_keeps_its_offset_and_shows_a_fraction_when_one_appears() {
1265        assert_eq!(
1266            pm("2026-10-15T09:00:00Z", n(5400.0), 1).as_deref(),
1267            Some("2026-10-15T10:30:00Z")
1268        );
1269        assert_eq!(
1270            pm("2026-10-15T09:00:00+02:00", n(86400.0), 1).as_deref(),
1271            Some("2026-10-16T09:00:00+02:00")
1272        );
1273        assert_eq!(
1274            pm("2026-10-15T23:30:00Z", n(3600.0), 1).as_deref(),
1275            Some("2026-10-16T00:30:00Z")
1276        );
1277        assert_eq!(
1278            pm("2026-10-15T09:00:00Z", n(0.5), 1).as_deref(),
1279            Some("2026-10-15T09:00:00.500Z")
1280        );
1281        assert_eq!(
1282            pm("2026-10-15T09:00:00.000Z", n(60.0), 1).as_deref(),
1283            Some("2026-10-15T09:01:00.000Z")
1284        );
1285        assert_eq!(
1286            pm("2026-10-15T09:00:00Z", n(1.0), -1).as_deref(),
1287            Some("2026-10-15T08:59:59Z")
1288        );
1289        assert_eq!(
1290            pm("2026-01-01", n(0.5), 1).as_deref(),
1291            Some("2026-01-01T00:00:00.500Z")
1292        );
1293    }
1294
1295    #[test]
1296    fn is_total_absent_on_a_non_date_receiver_or_a_non_duration_argument() {
1297        assert_eq!(pm("x", n(1.0), 1), None);
1298        assert_eq!(pm("2026-01-01", s("bogus"), 1), None);
1299        assert_eq!(pm("2026-01-01", Value::Null, 1), None);
1300        assert_eq!(plus_minus(&Value::Undefined, &n(1.0), 1), None);
1301        assert_eq!(plus_minus(&n(5.0), &n(1.0), 1), None);
1302        assert_eq!(pm("2026-01-01", n(f64::NAN), 1), None);
1303    }
1304
1305    #[test]
1306    fn shift_date_keeps_the_receivers_fraction_flag() {
1307        let d = parse_date(&s("2026-10-15T09:00:00.000Z")).unwrap();
1308        assert_eq!(
1309            render_date(&shift_date(&d, 0, 60.0)),
1310            "2026-10-15T09:01:00.000Z"
1311        );
1312        assert_eq!(
1313            render_date(&shift_date(
1314                &parse_date(&s("2026-10-15T09:00:00Z")).unwrap(),
1315                1,
1316                0.0
1317            )),
1318            "2026-11-15T09:00:00Z"
1319        );
1320    }
1321
1322    // ---- until, fields, format ----
1323
1324    #[test]
1325    fn until_is_seconds_by_instant_signed_fractional_absent_propagating() {
1326        let u = |a: &str, b: &str| until(&s(a), &s(b));
1327        assert_eq!(u("2026-01-01", "2026-01-02"), Some(86400.0));
1328        assert_eq!(u("2026-01-02", "2026-01-01"), Some(-86400.0));
1329        assert_eq!(u("2026-01-01", "2026-01-01"), Some(0.0));
1330        assert_eq!(
1331            u("2026-10-15T09:00:00Z", "2026-10-15T10:30:00Z"),
1332            Some(5400.0)
1333        );
1334        assert_eq!(
1335            u("2026-01-01T10:00:00+02:00", "2026-01-01T10:00:00Z"),
1336            Some(7200.0)
1337        );
1338        assert_eq!(u("2026-01-01", "2026-01-01T06:00:00Z"), Some(21600.0));
1339        assert_eq!(
1340            u("2026-01-01T00:00:00Z", "2026-01-01T00:00:00.500Z"),
1341            Some(0.5)
1342        );
1343        assert_eq!(until(&s("2026-01-01"), &Value::Undefined), None);
1344        assert_eq!(until(&Value::Undefined, &s("2026-01-01")), None);
1345        assert_eq!(u("x", "2026-01-01"), None);
1346    }
1347
1348    #[test]
1349    fn fields_read_the_written_offset_weekday_is_1_monday_to_7_sunday() {
1350        let f = |name: &str, recv: Value| date_method(name, &recv, &[]).unwrap().unwrap();
1351        let d = "2026-10-15T09:05:07Z";
1352        assert_eq!(
1353            [
1354                f("year", s(d)),
1355                f("month", s(d)),
1356                f("day", s(d)),
1357                f("weekday", s(d)),
1358                f("hour", s(d)),
1359                f("minute", s(d)),
1360                f("second", s(d))
1361            ],
1362            [n(2026.0), n(10.0), n(15.0), n(4.0), n(9.0), n(5.0), n(7.0)]
1363        );
1364        assert_eq!(f("hour", s("2026-10-15")), n(0.0));
1365        assert_eq!(f("weekday", s("2026-09-21")), n(1.0));
1366        assert_eq!(f("weekday", s("2026-09-27")), n(7.0));
1367        assert_eq!(f("date", s(d)), s("2026-10-15"));
1368        assert_eq!(f("date", s("2026-10-15")), s("2026-10-15"));
1369        assert_eq!(f("date", s("2026-10-15T23:30:00-05:00")), s("2026-10-15"));
1370        assert_eq!(f("hour", s("2026-10-15T23:30:00-05:00")), n(23.0));
1371        assert_eq!(f("second", s("2026-10-15T09:05:07.900Z")), n(7.0));
1372        assert_eq!(f("year", Value::Undefined), Value::Null);
1373        assert_eq!(f("year", s("abc")), Value::Null);
1374        assert_eq!(f("year", n(5.0)), Value::Null);
1375        assert_eq!(f("date", s("2026-13-01")), Value::Null);
1376    }
1377
1378    #[test]
1379    fn format_the_token_set_longest_first_everything_else_literal() {
1380        let fmt = |recv: &str, p: &str| format_date(&s(recv), &s(p));
1381        let d = "2026-10-15T09:05:07Z";
1382        assert_eq!(
1383            fmt(d, "yyyy-MM-dd HH:mm:ss").as_deref(),
1384            Some("2026-10-15 09:05:07")
1385        );
1386        assert_eq!(
1387            fmt(d, "EEE dd MMM yyyy").as_deref(),
1388            Some("Thu 15 Oct 2026")
1389        );
1390        assert_eq!(fmt(d, "dd/MM/yyyy").as_deref(), Some("15/10/2026"));
1391        assert_eq!(fmt(d, "MMMM").as_deref(), Some("OctM"));
1392        assert_eq!(fmt(d, "yyyy 'at' HH").as_deref(), Some("2026 'at' 09"));
1393        assert_eq!(fmt(d, "").as_deref(), Some(""));
1394        assert_eq!(fmt(d, "yyyyyyyy").as_deref(), Some("20262026"));
1395        assert_eq!(fmt("2026-01-05", "EEE").as_deref(), Some("Mon"));
1396        assert_eq!(fmt("2026-01-05", "HH:mm").as_deref(), Some("00:00"));
1397        assert_eq!(
1398            fmt("2026-10-15T23:30:00-05:00", "dd HH").as_deref(),
1399            Some("15 23")
1400        );
1401        assert_eq!(fmt("2026-12-31", "MMM EEE").as_deref(), Some("Dec Thu"));
1402        assert_eq!(fmt(d, "année → yyyy").as_deref(), Some("année → 2026"));
1403        assert_eq!(format_date(&s(d), &n(5.0)), None);
1404        assert_eq!(format_date(&Value::Undefined, &s("yyyy")), None);
1405        assert_eq!(fmt("x", "yyyy"), None);
1406    }
1407
1408    // ---- dur_text / dur_iso ----
1409
1410    #[test]
1411    fn dur_text_renders() {
1412        let t = |x: f64| dur_text(&n(x));
1413        assert_eq!(t(5400.0).as_deref(), Some("1h 30m"));
1414        assert_eq!(t(2.0 * 86400.0 + 3.0 * 3600.0).as_deref(), Some("2d 3h"));
1415        assert_eq!(t(45.0).as_deref(), Some("45s"));
1416        assert_eq!(t(0.0).as_deref(), Some("0s"));
1417        assert_eq!(t(-90.0).as_deref(), Some("-1m 30s"));
1418        assert_eq!(t(1.5).as_deref(), Some("1.5s"));
1419        assert_eq!(t(604_800.0).as_deref(), Some("7d"));
1420        assert_eq!(t(86401.0).as_deref(), Some("1d 1s"));
1421        assert_eq!(t(0.0004).as_deref(), Some("0s"));
1422        assert_eq!(dur_text(&s("x")), None);
1423        assert_eq!(dur_text(&Value::Undefined), None);
1424        assert_eq!(t(f64::NAN), None);
1425    }
1426
1427    #[test]
1428    fn dur_iso_renders() {
1429        let t = |x: f64| dur_iso(&n(x));
1430        assert_eq!(t(5400.0).as_deref(), Some("PT1H30M"));
1431        assert_eq!(t(2.0 * 86400.0 + 3.0 * 3600.0).as_deref(), Some("P2DT3H"));
1432        assert_eq!(t(45.0).as_deref(), Some("PT45S"));
1433        assert_eq!(t(0.0).as_deref(), Some("PT0S"));
1434        assert_eq!(t(-90.0).as_deref(), Some("-PT1M30S"));
1435        assert_eq!(t(30.0 * 86400.0).as_deref(), Some("P30D"));
1436        assert_eq!(t(86400.0).as_deref(), Some("P1D"));
1437        assert_eq!(t(1.5).as_deref(), Some("PT1.5S"));
1438        assert_eq!(dur_iso(&s("x")), None);
1439        assert_eq!(dur_iso(&Value::Undefined), None);
1440    }
1441
1442    #[test]
1443    fn round_trips_through_dur() {
1444        for x in [
1445            0.0,
1446            1.0,
1447            45.0,
1448            5400.0,
1449            86401.0,
1450            2.0 * 86400.0 + 3.0 * 3600.0,
1451            1.5,
1452        ] {
1453            assert_eq!(dur_of(&s(&dur_iso(&n(x)).unwrap())), Some(x));
1454            assert_eq!(dur_of(&s(&dur_text(&n(x)).unwrap())), Some(x));
1455        }
1456    }
1457
1458    // ---- the OQX arms ----
1459
1460    fn msg(r: Option<oqx::Result<Value>>) -> String {
1461        r.unwrap().unwrap_err().to_string()
1462    }
1463
1464    #[test]
1465    fn date_function_date_dur_handled_anything_else_deferred_arity_is_the_one_error() {
1466        assert_eq!(
1467            date_function("date", &[s("2026-01-05")], NOW),
1468            Some(Ok(s("2026-01-05")))
1469        );
1470        assert_eq!(
1471            date_function("date", &[s("today")], NOW),
1472            Some(Ok(s("2026-09-27")))
1473        );
1474        assert_eq!(date_function("dur", &[s("1h")], NOW), Some(Ok(n(3600.0))));
1475        assert!(date_function("size", &[Value::Array(vec![])], NOW).is_none());
1476        assert!(date_function("date", &[], NOW).unwrap().is_err());
1477        assert!(
1478            msg(date_function("date", &[s("a"), s("b")], NOW))
1479                .contains("date() takes exactly 1 argument, got 2")
1480        );
1481        assert!(
1482            msg(date_function("dur", &[], NOW)).contains("dur() takes exactly 1 argument, got 0")
1483        );
1484    }
1485
1486    #[test]
1487    fn date_method_every_method_handled_on_its_receiver_kind_total_on_others_arity_checked() {
1488        assert_eq!(
1489            date_method("plus", &s("2026-01-01"), &[n(86400.0)]),
1490            Some(Ok(s("2026-01-02")))
1491        );
1492        assert_eq!(
1493            date_method("minus", &s("2026-01-01"), &[s("1 day")]),
1494            Some(Ok(s("2025-12-31")))
1495        );
1496        assert_eq!(
1497            date_method("until", &s("2026-01-01"), &[s("2026-01-02")]),
1498            Some(Ok(n(86400.0)))
1499        );
1500        assert_eq!(
1501            date_method("format", &s("2026-01-01"), &[s("yyyy")]),
1502            Some(Ok(s("2026")))
1503        );
1504        assert_eq!(
1505            date_method("dur_text", &n(5400.0), &[]),
1506            Some(Ok(s("1h 30m")))
1507        );
1508        assert_eq!(
1509            date_method("dur_iso", &n(5400.0), &[]),
1510            Some(Ok(s("PT1H30M")))
1511        );
1512        assert_eq!(
1513            date_method("dur_text", &s("2026-01-01"), &[]),
1514            Some(Ok(Value::Null))
1515        );
1516        assert_eq!(
1517            date_method("plus", &n(5.0), &[n(1.0)]),
1518            Some(Ok(Value::Null))
1519        );
1520        assert!(date_method("lower", &s("X"), &[]).is_none());
1521        assert!(
1522            msg(date_method("plus", &s("2026-01-01"), &[]))
1523                .contains(".plus() takes exactly 1 argument, got 0")
1524        );
1525        assert!(
1526            msg(date_method("dur_text", &n(1.0), &[n(1.0)]))
1527                .contains(".dur_text() takes exactly 0 arguments, got 1")
1528        );
1529        assert!(
1530            date_method("year", &s("2026-01-01"), &[n(1.0)])
1531                .unwrap()
1532                .is_err()
1533        );
1534        for (name, arity) in DATE_METHODS {
1535            let args = vec![n(1.0); arity];
1536            assert!(
1537                date_method(name, &Value::Undefined, &args).is_some(),
1538                "{name}"
1539            );
1540        }
1541    }
1542
1543    #[test]
1544    fn civil_arithmetic_round_trips_across_eras() {
1545        for days in [-719_468, -1, 0, 1, 10_957, 20_724, 2_932_896] {
1546            let (y, m, d) = civil_from_days(days);
1547            assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}");
1548        }
1549        assert_eq!(civil_from_days(0), (1970, 1, 1));
1550        assert_eq!(dow_of_days(0), 4); // Thursday
1551        assert_eq!(civil_from_days(days_from_civil(2026, 9, 27)), (2026, 9, 27));
1552    }
1553}