Skip to main content

marsdb_query/
temporal.rs

1//! Calendar math and ISO-8601 text conversion for `PropertyValue::Date`/
2//! `PropertyValue::Duration` -- kept out of `marsdb-graph` deliberately
3//! (that crate stores the value, it doesn't know Cypher's construction/
4//! formatting rules -- see `PropertyValue`'s own doc comment) and out of
5//! `executor.rs` (which owns *dispatching* to these, not the arithmetic
6//! itself, matching the split `apply_arith`/`compare` already have from
7//! e.g. the planner).
8//!
9//! Scope, honestly: `DATE` (calendar year/month/day, ISO week-date, and
10//! ordinal/quarter-date construction forms), `DURATION`, `LOCAL TIME`,
11//! `TIME`, `LOCAL DATETIME`, and `DATETIME` are all supported -- but
12//! `TIME`/`DATETIME` only accept a *fixed* UTC offset (`'+01:00'`,
13//! `{timezone: '+01:00'}`), never a named timezone (`'Europe/Stockholm'`)
14//! -- that needs a real IANA timezone database, deliberately out of
15//! scope (no DST/zone-rule awareness anywhere in this module). See the
16//! README's "Cypher coverage" section for the exact list of what that
17//! leaves out of TCK's `expressions/temporal` suite.
18
19use chrono::{LocalResult, NaiveDateTime, Offset, TimeZone, Timelike};
20
21/// A `DateTime`'s zone -- a plain, `marsdb_graph`-independent mirror of
22/// `PropertyValue::DateTime`'s own `zone: marsdb_graph::model::TzId`
23/// field (same reasoning as `DurationParts` below: this module doesn't
24/// depend on `marsdb_graph`), translated at the `executor.rs` boundary.
25#[derive(Debug, Clone, PartialEq)]
26pub enum TzId {
27    Offset(i32),
28    Named(String),
29}
30
31const SECONDS_PER_DAY: i64 = 86_400;
32
33/// Average Gregorian month length in days (365.2425 / 12) -- Neo4j's own
34/// documented conversion factor for folding a fractional month (e.g. the
35/// `0.75` in `duration({months: 0.75})`) down into days, since "0.75
36/// months" has no exact length in days without a reference date. Only
37/// ever applied to the *fractional remainder* of a month count, never the
38/// whole-number part (a whole month always stays a whole month in the
39/// normalized representation, added/subtracted from a `Date` via real
40/// calendar month arithmetic in `add_duration_to_date`, not this
41/// average).
42const AVG_MONTH_DAYS: f64 = 365.2425 / 12.0;
43
44const NANOS_PER_SEC: i128 = 1_000_000_000;
45
46// ---------------------------------------------------------------------
47// Proleptic-Gregorian civil-calendar core (Howard Hinnant's algorithms)
48// ---------------------------------------------------------------------
49// Pure i64 integer math, deliberately not chrono: chrono's `NaiveDate`
50// caps years at ±262_143, far short of Cypher's ±999_999_999 (ISO 8601
51// expanded years -- TCK Temporal10 [9]/[10] exercises the full range).
52// chrono remains only for `capture_now` and named-IANA-zone resolution
53// (which is inherently bounded by chrono-tz's own range; a named zone at
54// year ±10^9 has no meaningful IANA data anyway). Epoch-day origin is
55// 1970-01-01, same as `std::time::UNIX_EPOCH`.
56
57/// Cypher's documented year range (java.time's, which real Cypher
58/// mirrors). Every constructor validates against it; epoch days for
59/// this range (±365 billion) always fit i64 with room for nanosecond
60/// totals in i128.
61pub const MIN_YEAR: i64 = -999_999_999;
62pub const MAX_YEAR: i64 = 999_999_999;
63
64fn is_leap_year(y: i64) -> bool {
65    y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
66}
67
68fn last_day_of_month(y: i64, m: u32) -> u32 {
69    match m {
70        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
71        4 | 6 | 9 | 11 => 30,
72        2 => {
73            if is_leap_year(y) {
74                29
75            } else {
76                28
77            }
78        }
79        _ => 0,
80    }
81}
82
83/// Epoch days for an already-validated civil y/m/d.
84fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
85    let y = y - (m <= 2) as i64;
86    let era = if y >= 0 { y } else { y - 399 } / 400;
87    let yoe = y - era * 400; // [0, 399]
88    let mp = (m as i64 + 9) % 12; // March=0 .. February=11
89    let doy = (153 * mp + 2) / 5 + d as i64 - 1; // [0, 365]
90    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
91    era * 146_097 + doe - 719_468
92}
93
94/// Inverse of `days_from_civil`: `(year, month, day)` for an epoch day.
95fn civil_from_days(z: i64) -> (i64, u32, u32) {
96    let z = z + 719_468;
97    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
98    let doe = z - era * 146_097; // [0, 146096]
99    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
100    let y = yoe + era * 400;
101    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
102    let mp = (5 * doy + 2) / 153; // [0, 11]
103    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
104    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12]
105    (y + (m <= 2) as i64, m, d)
106}
107
108/// ISO weekday, 1=Monday..7=Sunday (1970-01-01 was a Thursday, 4).
109fn iso_weekday_from_days(z: i64) -> i64 {
110    (z + 3).rem_euclid(7) + 1
111}
112
113fn ordinal_day_of(y: i64, m: u32, d: u32) -> i64 {
114    days_from_civil(y, m, d) - days_from_civil(y, 1, 1) + 1
115}
116
117fn days_in_year(y: i64) -> i64 {
118    if is_leap_year(y) {
119        366
120    } else {
121        365
122    }
123}
124
125/// ISO 8601 week count for a week-numbering year: 53 iff Jan 1 falls on
126/// a Thursday, or on a Wednesday of a leap year; else 52.
127fn iso_weeks_in_year(y: i64) -> i64 {
128    let jan1 = iso_weekday_from_days(days_from_civil(y, 1, 1));
129    if jan1 == 4 || (is_leap_year(y) && jan1 == 3) {
130        53
131    } else {
132        52
133    }
134}
135
136/// `(iso_week_year, iso_week)` for an epoch day -- the week-numbering
137/// year diverges from the calendar year near a year boundary.
138fn iso_week_of(z: i64) -> (i64, i64) {
139    let (y, m, d) = civil_from_days(z);
140    let doy = ordinal_day_of(y, m, d);
141    let wd = iso_weekday_from_days(z);
142    let week = (doy - wd + 10) / 7;
143    if week < 1 {
144        (y - 1, iso_weeks_in_year(y - 1))
145    } else if week > iso_weeks_in_year(y) {
146        (y + 1, 1)
147    } else {
148        (y, week)
149    }
150}
151
152/// The Monday of ISO week 1 of `week_year` -- January 4 is always in
153/// week 1, so it anchors the calculation.
154fn iso_week1_monday(week_year: i64) -> i64 {
155    let jan4 = days_from_civil(week_year, 1, 4);
156    jan4 - (iso_weekday_from_days(jan4) - 1)
157}
158
159/// Calendar month shift with end-of-month clamping (Jan 31 + 1 month =
160/// Feb 28/29, not an error and not Mar 3) -- the same rule
161/// `checked_add_months` had when this was chrono-backed.
162fn add_months_to_epoch_day(z: i64, months: i64) -> Option<i64> {
163    let (y, m, d) = civil_from_days(z);
164    let total = y
165        .checked_mul(12)?
166        .checked_add(m as i64 - 1)?
167        .checked_add(months)?;
168    let ny = total.div_euclid(12);
169    let nm = total.rem_euclid(12) as u32 + 1;
170    if !(MIN_YEAR..=MAX_YEAR).contains(&ny) {
171        return None;
172    }
173    let nd = d.min(last_day_of_month(ny, nm));
174    Some(days_from_civil(ny, nm, nd))
175}
176
177pub fn epoch_day_from_ymd(year: i64, month: u32, day: u32) -> Option<i64> {
178    if !(MIN_YEAR..=MAX_YEAR).contains(&year) || !(1..=12).contains(&month) {
179        return None;
180    }
181    if day < 1 || day > last_day_of_month(year, month) {
182        return None;
183    }
184    Some(days_from_civil(year, month, day))
185}
186
187/// A single captured instant, pre-derived into every shape a no-arg
188/// `date()`/`localtime()`/`time()`/`localdatetime()`/`datetime()` call
189/// needs -- real Cypher guarantees every such call *within the same
190/// query* returns the same value (so `duration.between(date(), date())`
191/// is always `PT0S`, never a few-microseconds-off nonzero duration from
192/// two independent `now()` reads); capturing one `chrono::Utc::now()`
193/// and deriving every field from it (not one `now()` call per field)
194/// is what makes that guarantee hold even within a single construction.
195#[derive(Clone, Copy)]
196pub struct NowSnapshot {
197    pub epoch_day: i64,
198    pub nanos_of_day: i64,
199    pub epoch_seconds: i64,
200    pub nanos: i32,
201}
202
203pub fn capture_now() -> NowSnapshot {
204    let now = chrono::Utc::now();
205    let epoch_seconds = now.timestamp();
206    let nanos = now.nanosecond() as i32;
207    let (epoch_day, nanos_of_day) = split_epoch_seconds(epoch_seconds);
208    NowSnapshot {
209        epoch_day,
210        nanos_of_day: nanos_of_day + nanos as i64,
211        epoch_seconds,
212        nanos,
213    }
214}
215
216pub fn format_date(epoch_day: i64) -> String {
217    let (y, m, d) = civil_from_days(epoch_day);
218    if (0..=9999).contains(&y) {
219        format!("{y:04}-{m:02}-{d:02}")
220    } else {
221        // ISO 8601 expanded year: explicit sign outside 0000..=9999
222        // (`+999999999-12-31`, `-999999999-01-01`) -- round-trips
223        // through `parse_date`'s own sign handling.
224        format!("{y:+}-{m:02}-{d:02}")
225    }
226}
227
228/// Parses every date string form MarsDB supports: the plain calendar
229/// forms `YYYY-MM-DD`/`YYYYMMDD`/`YYYY-MM`/`YYYYMM`/`YYYY` (missing
230/// month/day default to `1`), ISO week-date `YYYY-Www[-D]`/`YYYYWww[D]`
231/// (missing day defaults to `1`), ordinal-date `YYYY-DDD`/`YYYYDDD`
232/// (see `parse_week_or_ordinal_date`), and ISO 8601 expanded years --
233/// an explicit leading sign with up to 9 year digits
234/// (`'-999999999-01-01'`, `'+999999999-12-31'`, TCK Temporal10
235/// [9]/[10]). The sign is stripped here and applied to whichever year
236/// field the body then parses (calendar, week, or ordinal alike).
237pub fn parse_date(s: &str) -> Option<i64> {
238    let s = s.trim();
239    // The compact forms below use byte offsets because their grammar is
240    // ASCII-only. Reject non-ASCII input before slicing so malformed user
241    // input can never put an offset in the middle of a UTF-8 code point.
242    if !s.is_ascii() {
243        return None;
244    }
245    let (year_sign, s) = match s.as_bytes().first()? {
246        b'+' => (1i64, &s[1..]),
247        b'-' => (-1i64, &s[1..]),
248        _ => (1, s),
249    };
250    // ISO week-date (`YYYY-Www[-D]` / `YYYYWww[D]`) and ordinal-date
251    // (`YYYY-DDD` / `YYYYDDD`) forms -- checked before the plain calendar
252    // forms below since a `W` unambiguously marks a week-date, and a
253    // 7-digit no-`-` run is ordinal (a plain compact calendar date is
254    // either 4, 6, or 8 digits, never 7).
255    if let Some(epoch_day) = parse_week_or_ordinal_date(s, year_sign) {
256        return Some(epoch_day);
257    }
258    let (year, month, day) = if let Some((y, rest)) = s.split_once('-') {
259        let year: i64 = y.parse().ok()?;
260        match rest.split_once('-') {
261            Some((m, d)) => (year, m.parse().ok()?, d.parse().ok()?),
262            None => (year, rest.parse().ok()?, 1),
263        }
264    } else {
265        match s.len() {
266            8 => (
267                s[0..4].parse().ok()?,
268                s[4..6].parse().ok()?,
269                s[6..8].parse().ok()?,
270            ),
271            6 => (s[0..4].parse().ok()?, s[4..6].parse().ok()?, 1),
272            4 => (s[0..4].parse().ok()?, 1, 1),
273            _ => return None,
274        }
275    };
276    epoch_day_from_ymd(year_sign * year, month, day)
277}
278
279/// ISO week-date (`YYYY-Www[-D]` / `YYYYWww[D]`, day defaults to `1` when
280/// omitted) and ordinal-date (`YYYY-DDD` / `YYYYDDD`) string forms --
281/// `None` for anything not matching one of these two shapes (the plain
282/// calendar forms fall through to `parse_date`'s own parsing).
283fn parse_week_or_ordinal_date(s: &str, year_sign: i64) -> Option<i64> {
284    if let Some((y, rest)) = s.split_once('-') {
285        if let Some(w) = rest.strip_prefix('W') {
286            let week_year: i64 = y.parse().ok()?;
287            let (week, day) = match w.split_once('-') {
288                Some((w, d)) => (w.parse().ok()?, d.parse().ok()?),
289                None => (w.parse().ok()?, 1),
290            };
291            return epoch_day_from_week_fields(year_sign * week_year, week, day);
292        }
293        // `YYYY-DDD` -- an ordinal date, distinguished from the plain
294        // `YYYY-MM` calendar form by `rest`'s length (3 digits, not 2).
295        if rest.len() == 3 && rest.bytes().all(|b| b.is_ascii_digit()) {
296            let year: i64 = y.parse().ok()?;
297            let ordinal: u32 = rest.parse().ok()?;
298            return epoch_day_from_ordinal_fields(year_sign * year, ordinal);
299        }
300        return None;
301    }
302    if s.len() >= 5 {
303        if let Some(w) = s[4..].strip_prefix('W') {
304            let week_year: i64 = s[0..4].parse().ok()?;
305            let (week, day) = match w.len() {
306                2 => (w.parse().ok()?, 1),
307                3 => (w[0..2].parse().ok()?, w[2..3].parse().ok()?),
308                _ => return None,
309            };
310            return epoch_day_from_week_fields(year_sign * week_year, week, day);
311        }
312    }
313    if s.len() == 7 && s.bytes().all(|b| b.is_ascii_digit()) {
314        let year: i64 = s[0..4].parse().ok()?;
315        let ordinal: u32 = s[4..7].parse().ok()?;
316        return epoch_day_from_ordinal_fields(year_sign * year, ordinal);
317    }
318    None
319}
320
321/// `d.<prop>` component access for a `Date` -- the "forward" (date ->
322/// components) half of ISO week/quarter calendar math; the "backward"
323/// half (`week`/`dayOfWeek`/`quarter`/`dayOfQuarter`/`ordinalDay` ->
324/// date) lives in `epoch_day_from_week_fields`/`epoch_day_from_ordinal_
325/// fields`/`epoch_day_from_quarter_fields` below. Returns `None` for any
326/// property name this doesn't recognize (the caller treats that the same
327/// as a missing property, matching every other `.prop` access in this
328/// codebase).
329pub fn date_component(epoch_day: i64, prop: &str) -> Option<i64> {
330    let (y, m, d) = civil_from_days(epoch_day);
331    Some(match prop {
332        "year" => y,
333        "month" => m as i64,
334        "day" => d as i64,
335        "quarter" => ((m - 1) / 3 + 1) as i64,
336        "ordinalDay" => ordinal_day_of(y, m, d),
337        "weekDay" | "dayOfWeek" => iso_weekday_from_days(epoch_day),
338        "week" => iso_week_of(epoch_day).1,
339        "weekYear" => iso_week_of(epoch_day).0,
340        "dayOfQuarter" => {
341            let quarter_start_month = (m - 1) / 3 * 3 + 1;
342            epoch_day - days_from_civil(y, quarter_start_month, 1) + 1
343        }
344        _ => return None,
345    })
346}
347
348/// Constructs an epoch-day from ISO week-date fields -- the inverse of
349/// `date_component`'s `"weekYear"`/`"week"`/`"dayOfWeek"` accessors.
350/// `week_year` is the ISO week-numbering year, not necessarily the
351/// calendar year of the resulting date (they diverge near a year
352/// boundary -- e.g. week-year 1817 week 1 day 2 is calendar date
353/// 1816-12-31, TCK's Temporal1 [1]).
354pub fn epoch_day_from_week_fields(week_year: i64, week: u32, day_of_week: i64) -> Option<i64> {
355    if !(MIN_YEAR..=MAX_YEAR).contains(&week_year)
356        || !(1..=7).contains(&day_of_week)
357        || week < 1
358        || week as i64 > iso_weeks_in_year(week_year)
359    {
360        return None;
361    }
362    Some(iso_week1_monday(week_year) + (week as i64 - 1) * 7 + (day_of_week - 1))
363}
364
365/// Constructs an epoch-day from a calendar year plus an ordinal day
366/// (`1..=365`/`366`) -- the inverse of `date_component`'s `"ordinalDay"`.
367pub fn epoch_day_from_ordinal_fields(year: i64, ordinal_day: u32) -> Option<i64> {
368    if !(MIN_YEAR..=MAX_YEAR).contains(&year)
369        || ordinal_day < 1
370        || ordinal_day as i64 > days_in_year(year)
371    {
372        return None;
373    }
374    Some(days_from_civil(year, 1, 1) + ordinal_day as i64 - 1)
375}
376
377/// Constructs an epoch-day from a calendar year, quarter (`1..=4`), and
378/// day-of-quarter (`1`-based) -- the inverse of `date_component`'s
379/// `"quarter"`/`"dayOfQuarter"`.
380pub fn epoch_day_from_quarter_fields(year: i64, quarter: u32, day_of_quarter: i64) -> Option<i64> {
381    if !(MIN_YEAR..=MAX_YEAR).contains(&year) || !(1..=4).contains(&quarter) {
382        return None;
383    }
384    let quarter_start_month = (quarter - 1) * 3 + 1;
385    Some(days_from_civil(year, quarter_start_month, 1) + (day_of_quarter - 1))
386}
387
388/// Adds a `Duration` to a `Date` via real calendar month arithmetic
389/// (`checked_add_months`/`checked_sub_months`, which clamps to the
390/// shorter month's last day -- e.g. Jan 31 + 1 month = Feb 28/29, not an
391/// error and not Mar 3) followed by a plain day offset. `negate`: `true`
392/// for `date - duration` (real Cypher's other overload), reusing the same
393/// function rather than duplicating it with `-` in every arithmetic
394/// expression.
395///
396/// `seconds`/`nanos` can't shift a `Date` by a fraction of a day (it has
397/// no time-of-day to carry a remainder into), but they're *not* simply
398/// dropped either -- any *whole* extra day they add still counts: e.g.
399/// `duration({months: 0.5, days: 14.5, hours: 16.5, ...})` normalizes to
400/// `days: 29` plus a `seconds`/`nanos` remainder equivalent to ~34 hours,
401/// and that 34 hours contributes one more whole day (34h > 24h) on top of
402/// the 29 -- verified against Temporal8's fractional-duration date-
403/// arithmetic scenario, which is exactly the case that exposed this (an
404/// earlier version of this function dropped `seconds`/`nanos` outright
405/// and was a day off). `seconds/86_400` (truncated towards zero, so a
406/// negative duration's extra day is subtracted, not added) is the whole-
407/// day count; anything finer than that is genuinely discarded, matching
408/// "adding a Duration to a value with less precision than the Duration
409/// provides truncates to that lower precision" -- Date's precision floor
410/// is one day.
411pub fn add_duration_to_date(
412    epoch_day: i64,
413    months: i64,
414    days: i64,
415    seconds: i64,
416    nanos: i32,
417    negate: bool,
418) -> Option<i64> {
419    let total_ns: i128 = seconds as i128 * NANOS_PER_SEC + nanos as i128;
420    let extra_days = (total_ns / (86_400 * NANOS_PER_SEC)) as i64;
421    let days = days.checked_add(extra_days)?;
422    let (months, days) = if negate {
423        (months.checked_neg()?, days.checked_neg()?)
424    } else {
425        (months, days)
426    };
427    let result = add_months_to_epoch_day(epoch_day, months)?.checked_add(days)?;
428    // Keep the result inside Cypher's year range -- the chrono-backed
429    // version got this via NaiveDate's own (smaller) range failing.
430    let (y, _, _) = civil_from_days(result);
431    if !(MIN_YEAR..=MAX_YEAR).contains(&y) {
432        return None;
433    }
434    Some(result)
435}
436
437/// The four independently-signed components of a normalized `Duration`,
438/// matching `PropertyValue::Duration`'s own fields exactly -- a plain
439/// tuple alias, not a re-export of the `PropertyValue` variant itself,
440/// since this module deliberately doesn't depend on `marsdb_graph` (see
441/// this file's top-of-module doc comment on the crate split).
442pub type DurationParts = (i64, i64, i64, i32);
443
444/// Raw, not-yet-normalized inputs to `duration({...})`/`duration('...')`
445/// construction -- one `f64` per Cypher map key (`0.0` when absent), kept
446/// as a struct (not 10 positional `f64` args) so call sites read as
447/// `years: 12.0, ..Default::default()` rather than an unlabeled tuple.
448#[derive(Default, Clone, Copy)]
449pub struct DurationFields {
450    pub years: f64,
451    pub months: f64,
452    pub weeks: f64,
453    pub days: f64,
454    pub hours: f64,
455    pub minutes: f64,
456    pub seconds: f64,
457    pub milliseconds: f64,
458    pub microseconds: f64,
459    pub nanoseconds: f64,
460}
461
462/// Folds raw (possibly fractional, possibly negative) field values into
463/// `PropertyValue::Duration`'s normalized `(months, days, seconds,
464/// nanos)` form. The cascade only ever flows one direction -- years into
465/// months, a fractional month's remainder into days (via `AVG_MONTH_DAYS`
466/// -- the only place that average is used), a fractional day's remainder
467/// into seconds, sub-second fields into nanoseconds -- matching Neo4j's
468/// own documented normalization, verified line-by-line against every
469/// `duration(...)` example in the TCK's Temporal1/Temporal2 feature
470/// files. Never the other direction (seconds never cascade *into* days --
471/// `duration({hours: 40})` stays `PT40H`, not `P1DT16H`; a "day" isn't a
472/// fixed number of hours once timezones/DST exist, so real Cypher never
473/// makes that assumption even though MarsDB's own `Date` type is
474/// timezone-naive).
475pub fn normalize_duration(f: DurationFields) -> DurationParts {
476    let months_f = f.years * 12.0 + f.months;
477    let days_f = f.weeks * 7.0 + f.days;
478    let seconds_f = f.hours * 3600.0 + f.minutes * 60.0 + f.seconds;
479    // Sub-second fields are exact integer nanosecond counts in every real
480    // scenario (`nanosecond: 789`, never a fractional nanosecond) --
481    // `.trunc()`, not `.round()`, so a hypothetical fractional input
482    // doesn't get a phantom extra nanosecond rounded in.
483    let extra_nanos =
484        (f.milliseconds * 1_000_000.0 + f.microseconds * 1_000.0 + f.nanoseconds).trunc() as i128;
485    cascade(months_f, days_f, seconds_f, extra_nanos)
486}
487
488/// Shared cascade core for both `normalize_duration` (raw map/string
489/// fields) and `scale_duration` (multiply/divide by a scalar) -- the only
490/// difference between the two callers is what they pass as `seconds_f`/
491/// `extra_nanos`, not the cascade logic itself.
492fn cascade(months_f: f64, days_f: f64, seconds_f: f64, extra_nanos: i128) -> DurationParts {
493    let whole_months = months_f.trunc();
494    let frac_months = months_f - whole_months;
495    let days_f2 = days_f + frac_months * AVG_MONTH_DAYS;
496    let whole_days = days_f2.trunc();
497    let frac_days = days_f2 - whole_days;
498    let seconds_f2 = seconds_f + frac_days * 86_400.0;
499    // `.round()` here (not `.trunc()`) -- `seconds_f2` is a continuous
500    // quantity built from several multiplications/additions (e.g. the
501    // `0.75` months -> `71509.5` seconds case), so it can land a
502    // few-ULP hair off the exact value; rounding to the nearest whole
503    // nanosecond recovers the exact intended value, whereas truncating
504    // would occasionally drop a real nanosecond that FP noise pushed
505    // just under the integer.
506    let total_ns = (seconds_f2 * NANOS_PER_SEC as f64).round() as i128 + extra_nanos;
507    let seconds = (total_ns / NANOS_PER_SEC) as i64;
508    let nanos = (total_ns % NANOS_PER_SEC) as i32;
509    (whole_months as i64, whole_days as i64, seconds, nanos)
510}
511
512/// Component-wise `a + b` -- *not* a re-cascade through `normalize_
513/// duration` (months/days add directly, no re-derivation via
514/// `AVG_MONTH_DAYS`), matching the TCK's "add two already-normalized
515/// durations" examples, which sum months and days independently and only
516/// ever carry between `seconds`/`nanos` (via the exact `i128` total,
517/// avoiding the sign-mismatch bug a naive `a.nanos + b.nanos` would hit
518/// when the two operands' `seconds` signs differ). Returns `None` if any
519/// component would overflow its persisted integer representation.
520pub fn add_duration(a: DurationParts, b: DurationParts) -> Option<DurationParts> {
521    let months = a.0.checked_add(b.0)?;
522    let days = a.1.checked_add(b.1)?;
523    let total_ns =
524        a.2 as i128 * NANOS_PER_SEC + a.3 as i128 + b.2 as i128 * NANOS_PER_SEC + b.3 as i128;
525    Some((
526        months,
527        days,
528        (total_ns / NANOS_PER_SEC).try_into().ok()?,
529        (total_ns % NANOS_PER_SEC) as i32,
530    ))
531}
532
533pub fn negate_duration(a: DurationParts) -> Option<DurationParts> {
534    Some((
535        a.0.checked_neg()?,
536        a.1.checked_neg()?,
537        a.2.checked_neg()?,
538        a.3.checked_neg()?,
539    ))
540}
541
542pub fn sub_duration(a: DurationParts, b: DurationParts) -> Option<DurationParts> {
543    add_duration(a, negate_duration(b)?)
544}
545
546/// `duration * factor` / `duration / factor` (`factor` is `1.0 / n` for
547/// division) -- re-cascades through the same `AVG_MONTH_DAYS`-based logic
548/// `normalize_duration` uses (scaling a whole month by a non-integer
549/// factor produces a fractional month again, e.g. `P1M / 2` needs to
550/// become "15.2 days", not stay a fractional month), so this calls the
551/// shared `cascade` directly with `months`/`days` pre-multiplied and the
552/// exact `seconds`+`nanos` total pre-multiplied as one `i128` quantity
553/// (truncated, same "no phantom sub-nanosecond digit" reasoning as
554/// `normalize_duration`'s `extra_nanos`).
555pub fn scale_duration(a: DurationParts, factor: f64) -> DurationParts {
556    let months_f = a.0 as f64 * factor;
557    let days_f = a.1 as f64 * factor;
558    let total_ns_exact = a.2 as i128 * NANOS_PER_SEC + a.3 as i128;
559    let extra_nanos = (total_ns_exact as f64 * factor).trunc() as i128;
560    cascade(months_f, days_f, 0.0, extra_nanos)
561}
562
563/// `d.<prop>` component access for a `Duration` -- every field (`years`,
564/// `quarters`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds`,
565/// `milliseconds`, `microseconds`, `nanoseconds`) is simply the *whole
566/// duration re-expressed in that one unit alone*, truncated towards zero
567/// -- not a calendar-style "the months-of-year part" breakdown. E.g. for
568/// `duration({years: 1, months: 4, ...})` (16 total months), `d.years` is
569/// `16 / 12 = 1` and `d.months` is `16` itself, not `4`. Verified against
570/// every field in Temporal5's "accessors for duration" scenario. The
571/// `*OfX` fields (`monthsOfYear`, `secondsOfMinute`, ...) are each the
572/// same computation's *remainder* instead of its quotient -- literally
573/// "what `d.<prop>` would be, mod the next unit up".
574/// `seconds`/`nanos` are stored the same way real Cypher's own `Duration`
575/// stores them (mirroring Java's `Duration`): `seconds` carries the whole
576/// sign, `nanos` is always non-negative (0..999_999_999) -- see
577/// `PropertyValue::Duration`'s own docs. Component accessors must read
578/// off *these two raw fields directly*, not recombine them into one
579/// signed total and re-split -- that would silently reintroduce a
580/// negative `nanos` (`-23H-59M-59.9S`'s stored form is `seconds: -86400,
581/// nanos: 100_000_000`; re-splitting `-86399.9s` via truncating division
582/// gives the wrong `seconds: -86399, nanosecondsOfSecond: -900_000_000`
583/// instead, TCK's Temporal10 `[1]`). `hours`/`minutes`/`seconds` (and
584/// their `-OfHour`/`-OfMinute` cousins) only ever divide `seconds` itself
585/// (never touch `nanos` -- a whole hour/minute can't hide inside a
586/// sub-second remainder); `milliseconds`/`microseconds`/`nanoseconds`
587/// (the fine-grained *totals*, not `-OfSecond` splits) are the one place
588/// that legitimately combines both fields, since `nanos`' own
589/// always-non-negative convention means simple addition (not `total_ns`
590/// division-then-truncation) already gives the right signed result.
591pub fn duration_component(
592    months: i64,
593    days: i64,
594    seconds: i64,
595    nanos: i32,
596    prop: &str,
597) -> Option<i64> {
598    let nanos = nanos as i64;
599    Some(match prop {
600        "years" => months / 12,
601        "quarters" => months / 3,
602        "months" => months,
603        "weeks" => days / 7,
604        "days" => days,
605        "hours" => seconds / 3600,
606        "minutes" => seconds / 60,
607        "seconds" => seconds,
608        "milliseconds" => seconds * 1000 + nanos / 1_000_000,
609        "microseconds" => seconds * 1_000_000 + nanos / 1_000,
610        "nanoseconds" => seconds * NANOS_PER_SEC as i64 + nanos,
611        "quartersOfYear" => (months % 12) / 3,
612        "monthsOfQuarter" => (months % 12) % 3,
613        "monthsOfYear" => months % 12,
614        "daysOfWeek" => days % 7,
615        "minutesOfHour" => (seconds / 60) % 60,
616        "secondsOfMinute" => seconds % 60,
617        "millisecondsOfSecond" => nanos / 1_000_000,
618        "microsecondsOfSecond" => nanos / 1_000,
619        "nanosecondsOfSecond" => nanos,
620        _ => return None,
621    })
622}
623
624/// Renders `(months, days, seconds, nanos)` as MarsDB's canonical
625/// ISO-8601 duration text -- always in `PnYnMnDTnHnMn.fS` order (never
626/// `W`, even though `duration({weeks: 1})` accepts it as an *input*
627/// unit -- weeks fold into `days` during normalization and never come
628/// back out, matching every `toString(duration(...))` example in the
629/// TCK). Each component is a straight divmod of the sign-independent
630/// whole -- a negative `months`/`days`/`seconds` prints its own `-`
631/// (`P-6M-15D...`), not one shared sign prefix, matching the TCK's mixed-
632/// sign examples exactly (see Temporal8's duration-subtraction table).
633pub fn format_duration(months: i64, days: i64, seconds: i64, nanos: i32) -> String {
634    if months == 0 && days == 0 && seconds == 0 && nanos == 0 {
635        return "PT0S".to_string();
636    }
637    let mut out = String::from("P");
638    let years = months / 12;
639    let rem_months = months % 12;
640    if years != 0 {
641        out.push_str(&format!("{years}Y"));
642    }
643    if rem_months != 0 {
644        out.push_str(&format!("{rem_months}M"));
645    }
646    if days != 0 {
647        out.push_str(&format!("{days}D"));
648    }
649    let total_time_ns = seconds as i128 * NANOS_PER_SEC + nanos as i128;
650    if total_time_ns != 0 {
651        out.push('T');
652        if total_time_ns >= 0 {
653            let hours = seconds / 3600;
654            let rem = seconds % 3600;
655            let minutes = rem / 60;
656            let secs = rem % 60;
657            if hours != 0 {
658                out.push_str(&format!("{hours}H"));
659            }
660            if minutes != 0 {
661                out.push_str(&format!("{minutes}M"));
662            }
663            if secs != 0 || nanos != 0 {
664                out.push_str(&format_seconds_fraction(secs, nanos));
665                out.push('S');
666            }
667        } else {
668            let total_ns = total_time_ns;
669            let hours = (total_ns / 3_600_000_000_000) as i64;
670            let rem_h = total_ns % 3_600_000_000_000;
671            let minutes = (rem_h / 60_000_000_000) as i64;
672            let rem_m = rem_h % 60_000_000_000;
673            let secs = (rem_m / 1_000_000_000) as i64;
674            let sub_nanos = (rem_m % 1_000_000_000) as i32;
675            if hours != 0 {
676                out.push_str(&format!("{hours}H"));
677            }
678            if minutes != 0 {
679                out.push_str(&format!("{minutes}M"));
680            }
681            if secs != 0 || sub_nanos != 0 {
682                out.push_str(&format_seconds_fraction(secs, sub_nanos));
683                out.push('S');
684            }
685        }
686    }
687    out
688}
689
690/// `secs` and `nanos` (same sign, or one of them zero -- the
691/// `PropertyValue::Duration` invariant) rendered as one signed decimal,
692/// e.g. `(1, 999_000_000)` -> `"1.999"`, `(0, -500_000_000)` -> `"-0.5"`.
693/// Trailing zero digits (but not a bare trailing `.`) are trimmed -- real
694/// Cypher's `toString` never prints `10.100000000S`.
695fn format_seconds_fraction(secs: i64, nanos: i32) -> String {
696    if nanos == 0 {
697        return secs.to_string();
698    }
699    let negative = secs < 0 || nanos < 0;
700    let mut frac = format!("{:09}", nanos.unsigned_abs());
701    while frac.ends_with('0') {
702        frac.pop();
703    }
704    format!(
705        "{}{}.{}",
706        if negative { "-" } else { "" },
707        secs.unsigned_abs(),
708        frac
709    )
710}
711
712/// Parses an ISO-8601 duration string (`P[nY][nM][nW][nD][T[nH][nM][nS]]`,
713/// each `n` an optional-sign decimal) into raw `DurationFields`, then
714/// normalizes the same way `duration({...})` does -- construction from
715/// text and from a map are the same operation once the units are pulled
716/// apart, see `normalize_duration`'s docs.
717///
718/// Deliberately does *not* handle the alternative "combined date-time"
719/// duration representation (`P2012-02-02T14:37:21.545`, ISO-8601's other
720/// duration syntax) -- a real gap (see the README), not a silent
721/// misparse: that string doesn't match `P` followed by number+letter
722/// pairs, so this returns `None`, the same "reject, don't guess" outcome
723/// `parse_date` gives an unsupported date string form.
724pub fn parse_duration(s: &str) -> Option<DurationParts> {
725    let s = s.trim();
726    let s = s.strip_prefix('P')?;
727    let (date_part, time_part) = match s.split_once('T') {
728        Some((d, t)) => (d, Some(t)),
729        None => (s, None),
730    };
731    if let Some(fields) = parse_combined_date_time_duration(date_part, time_part) {
732        return Some(normalize_duration(fields));
733    }
734    let date_pairs = scan_number_unit_pairs(date_part)?;
735    let time_pairs = match time_part {
736        Some(part) => scan_number_unit_pairs(part)?,
737        None => Vec::new(),
738    };
739    if date_pairs.is_empty() && time_pairs.is_empty() {
740        return None;
741    }
742
743    let mut f = DurationFields::default();
744    for (value, unit) in date_pairs {
745        match unit {
746            'Y' => f.years = value,
747            'M' => f.months = value,
748            'W' => f.weeks = value,
749            'D' => f.days = value,
750            _ => return None,
751        }
752    }
753    for (value, unit) in time_pairs {
754        match unit {
755            'H' => f.hours = value,
756            'M' => f.minutes = value,
757            'S' => f.seconds = value,
758            _ => return None,
759        }
760    }
761    Some(normalize_duration(f))
762}
763
764/// ISO-8601's alternate "combined date-time" duration representation
765/// (`P<date>T<time>`, e.g. `P2012-02-02T14:37:21.545` -- date/time
766/// formatted exactly like a calendar date/time-of-day, but each field
767/// means "this many years/months/days/hours/minutes/seconds", not an
768/// actual calendar date -- no day-of-month validity check, `P2012-13-40`
769/// is a legal 12-year-13-month-40-day duration under this form. TCK's
770/// Temporal2 `[7]`. Only matches when `date_part` genuinely has this
771/// shape (plain `N-N-N`, no unit letters) -- an ordinary `PnYnMnD`
772/// string never does, and a negative duration's leading `-` makes the
773/// first split empty rather than a valid number, so neither can be
774/// mistaken for this form.
775fn parse_combined_date_time_duration(
776    date_part: &str,
777    time_part: Option<&str>,
778) -> Option<DurationFields> {
779    let mut date_fields = date_part.splitn(3, '-');
780    let years: f64 = date_fields.next()?.parse().ok()?;
781    let months: f64 = date_fields.next()?.parse().ok()?;
782    let days: f64 = date_fields.next()?.parse().ok()?;
783    if date_fields.next().is_some() {
784        return None;
785    }
786    let mut f = DurationFields {
787        years,
788        months,
789        days,
790        ..Default::default()
791    };
792    if let Some(time_part) = time_part {
793        let mut time_fields = time_part.splitn(3, ':');
794        let hours: f64 = time_fields.next()?.parse().ok()?;
795        let minutes: f64 = time_fields.next()?.parse().ok()?;
796        let seconds: f64 = time_fields.next()?.parse().ok()?;
797        if time_fields.next().is_some() {
798            return None;
799        }
800        f.hours = hours;
801        f.minutes = minutes;
802        f.seconds = seconds;
803    }
804    Some(f)
805}
806
807/// Hand-scans `"12Y5M1.5D"`-style text into `(value, unit_letter)` pairs
808/// -- no regex dependency for a grammar this small (a sign, digits, an
809/// optional `.digits`, then exactly one unit letter), matching this
810/// codebase's other hand-rolled small parsers (e.g. `marsdb-tck`'s
811/// `CellParser`). The entire input must match: returning a successfully
812/// parsed prefix would make malformed text such as `P1Ygarbage` silently
813/// construct a one-year duration.
814fn scan_number_unit_pairs(s: &str) -> Option<Vec<(f64, char)>> {
815    let mut out = Vec::new();
816    let chars: Vec<char> = s.chars().collect();
817    let mut i = 0;
818    while i < chars.len() {
819        let start = i;
820        if chars[i] == '-' || chars[i] == '+' {
821            i += 1;
822        }
823        let digits_start = i;
824        while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
825            i += 1;
826        }
827        if i == digits_start {
828            return None;
829        }
830        let &unit = chars.get(i)?;
831        let value = chars[start..i]
832            .iter()
833            .collect::<String>()
834            .parse::<f64>()
835            .ok()?;
836        out.push((value, unit));
837        i += 1;
838    }
839    Some(out)
840}
841
842// ---------------------------------------------------------------------
843// LocalTime / Time
844// ---------------------------------------------------------------------
845
846/// Builds a `LocalTime`'s nanos-of-day from calendar-style fields
847/// (`localtime({hour, minute, second, nanosecond})`'s already-summed
848/// sub-second `nanos`) -- range-checked the same way `date_from_map`
849/// checks year/month/day, `None` for anything out of range.
850pub fn local_time_nanos_from_fields(
851    hour: i64,
852    minute: i64,
853    second: i64,
854    nanos: i64,
855) -> Option<i64> {
856    if !(0..24).contains(&hour)
857        || !(0..60).contains(&minute)
858        || !(0..60).contains(&second)
859        || !(0..1_000_000_000).contains(&nanos)
860    {
861        return None;
862    }
863    Some(hour * 3_600_000_000_000 + minute * 60_000_000_000 + second * 1_000_000_000 + nanos)
864}
865
866/// Parses `HH[:MM[:SS[.fraction]]]` or the compact `HHMM[SS[.fraction]]`/
867/// `HH` forms into nanoseconds since midnight -- the same colon-vs-
868/// compact dispatch `parse_date` uses for the calendar forms.
869fn parse_time_of_day(s: &str) -> Option<i64> {
870    if !s.is_ascii() || s.is_empty() {
871        return None;
872    }
873    let (hour, minute, second, nanos) = if s.contains(':') {
874        let mut parts = s.splitn(3, ':');
875        let h: u32 = parts.next()?.parse().ok()?;
876        let m: u32 = match parts.next() {
877            Some(p) => p.parse().ok()?,
878            None => 0,
879        };
880        let (sec, nanos) = match parts.next() {
881            Some(p) => parse_seconds_fraction(p)?,
882            None => (0, 0),
883        };
884        (h, m, sec, nanos)
885    } else {
886        match s.len() {
887            2 => (s.parse().ok()?, 0, 0, 0),
888            4 => (s[0..2].parse().ok()?, s[2..4].parse().ok()?, 0, 0),
889            n if n > 4 => {
890                let (sec, nanos) = parse_seconds_fraction(&s[4..])?;
891                (s[0..2].parse().ok()?, s[2..4].parse().ok()?, sec, nanos)
892            }
893            _ => return None,
894        }
895    };
896    local_time_nanos_from_fields(hour as i64, minute as i64, second as i64, nanos as i64)
897}
898
899/// `"32.142"` / `"32"` -> `(seconds, nanos)`. The whole-number part must
900/// be exactly 2 digits when called from the compact (no-`:`) form's
901/// tail, but this function itself doesn't enforce that -- `parse_time_of_day`
902/// slices the fixed-width prefix before calling it.
903fn parse_seconds_fraction(s: &str) -> Option<(u32, u32)> {
904    let (sec_str, frac_str) = match s.split_once('.') {
905        Some((a, b)) => (a, Some(b)),
906        None => (s, None),
907    };
908    let sec: u32 = sec_str.parse().ok()?;
909    if sec >= 60 {
910        return None;
911    }
912    let nanos = match frac_str {
913        None => 0,
914        Some(f) => {
915            if f.is_empty() || !f.bytes().all(|b| b.is_ascii_digit()) {
916                return None;
917            }
918            let mut digits = f.to_string();
919            digits.truncate(9);
920            while digits.len() < 9 {
921                digits.push('0');
922            }
923            digits.parse().ok()?
924        }
925    };
926    Some((sec, nanos))
927}
928
929/// Splits a time-of-day-with-offset string into `(time_part,
930/// offset_part)` -- the offset marker is a trailing `Z` or the first
931/// `+`/`-` at index >= 1 (a bare time-of-day's own components are
932/// digits/`:`/`.` only, so that's always the offset sign, never
933/// something inside the time itself). Only ever called on the *time*
934/// half of a combined date+time string (after splitting on `T`), never
935/// the date half, which legitimately contains `-`.
936fn split_time_offset(s: &str) -> (&str, Option<&str>) {
937    if let Some(stripped) = s.strip_suffix('Z') {
938        return (stripped, Some("Z"));
939    }
940    let bytes = s.as_bytes();
941    for i in 1..bytes.len() {
942        if bytes[i] == b'+' || bytes[i] == b'-' {
943            return (&s[..i], Some(&s[i..]));
944        }
945    }
946    (s, None)
947}
948
949/// `Z` or `[+-]HH[:MM[:SS]]` / compact `[+-]HHMM[SS]` -> whole seconds
950/// east of UTC.
951pub fn parse_offset_seconds(s: &str) -> Option<i32> {
952    if s == "Z" {
953        return Some(0);
954    }
955    let bytes = s.as_bytes();
956    let sign: i32 = match bytes.first()? {
957        b'+' => 1,
958        b'-' => -1,
959        _ => return None,
960    };
961    let rest = &s[1..];
962    let (h, m, sec): (i32, i32, i32) = if rest.contains(':') {
963        let mut parts = rest.splitn(3, ':');
964        let h = parts.next()?.parse().ok()?;
965        let m = match parts.next() {
966            Some(p) => p.parse().ok()?,
967            None => 0,
968        };
969        let sec = match parts.next() {
970            Some(p) => p.parse().ok()?,
971            None => 0,
972        };
973        (h, m, sec)
974    } else {
975        match rest.len() {
976            2 => (rest.parse().ok()?, 0, 0),
977            4 => (rest[0..2].parse().ok()?, rest[2..4].parse().ok()?, 0),
978            6 => (
979                rest[0..2].parse().ok()?,
980                rest[2..4].parse().ok()?,
981                rest[4..6].parse().ok()?,
982            ),
983            _ => return None,
984        }
985    };
986    if !(0..24).contains(&h) || !(0..60).contains(&m) || !(0..60).contains(&sec) {
987        return None;
988    }
989    Some(sign * (h * 3600 + m * 60 + sec))
990}
991
992/// `localtime('21:40:32.142')` -- a bare time-of-day, no offset allowed
993/// (a trailing `Z`/`+HH:MM` makes the whole string fail the strict
994/// digit/`:`/`.`-only parse above and correctly return `None`, the same
995/// "reject, don't guess" stance as every other malformed-input case in
996/// this module).
997pub fn parse_local_time(s: &str) -> Option<i64> {
998    parse_time_of_day(s.trim())
999}
1000
1001/// `time('21:40:32.142+01:00')` -- a time-of-day *with* a required
1002/// offset. Returns `None` if the string has no offset at all, or if it
1003/// carries a bracketed named-zone suffix (`[Europe/Stockholm]`) -- the
1004/// caller (`Executor::call_builtin`'s `"time"` arm) checks for `[`
1005/// itself first and raises a specific "named zones aren't supported"
1006/// error rather than this generic parse failure, but this function
1007/// still refuses to silently ignore/misparse the bracket if called
1008/// directly.
1009pub fn parse_time(s: &str) -> Option<(i64, i32)> {
1010    let s = s.trim();
1011    if s.contains('[') {
1012        return None;
1013    }
1014    let (time_part, offset_part) = split_time_offset(s);
1015    // A missing offset defaults to UTC (`+00:00`) -- real Cypher's
1016    // `time()` falls back to the statement's default time zone rather
1017    // than rejecting the string outright (TCK's Temporal10: `time('14:30')`
1018    // is a valid, offset-less argument).
1019    let offset_seconds = match offset_part {
1020        Some(part) => parse_offset_seconds(part)?,
1021        None => 0,
1022    };
1023    Some((parse_time_of_day(time_part)?, offset_seconds))
1024}
1025
1026/// `d.<prop>` component access shared by `LocalTime` and (for its own
1027/// wall-clock time-of-day fields) `Time`/`LocalDateTime`/`DateTime`.
1028pub fn local_time_component(nanos_of_day: i64, prop: &str) -> Option<i64> {
1029    Some(match prop {
1030        "hour" => nanos_of_day / 3_600_000_000_000,
1031        "minute" => (nanos_of_day / 60_000_000_000) % 60,
1032        "second" => (nanos_of_day / 1_000_000_000) % 60,
1033        "millisecond" => (nanos_of_day / 1_000_000) % 1000,
1034        "microsecond" => (nanos_of_day / 1_000) % 1_000_000,
1035        "nanosecond" => nanos_of_day % 1_000_000_000,
1036        _ => return None,
1037    })
1038}
1039
1040/// Formats an offset as Cypher's canonical text: `Z` for UTC, else
1041/// `[+-]HH:MM` (extended with `:SS` only when the offset has a non-zero
1042/// seconds component -- real offsets are almost always whole minutes,
1043/// but the TCK's timezone grep found at least one `-02:05:07` example).
1044pub fn format_offset(offset_seconds: i32) -> String {
1045    if offset_seconds == 0 {
1046        return "Z".to_string();
1047    }
1048    let sign = if offset_seconds < 0 { "-" } else { "+" };
1049    let abs = offset_seconds.unsigned_abs();
1050    let h = abs / 3600;
1051    let m = (abs / 60) % 60;
1052    let sec = abs % 60;
1053    if sec != 0 {
1054        format!("{sign}{h:02}:{m:02}:{sec:02}")
1055    } else {
1056        format!("{sign}{h:02}:{m:02}")
1057    }
1058}
1059
1060/// `HH:MM` always; `:SS` only if seconds/nanos are non-zero; `.fraction`
1061/// only if nanos is non-zero (trailing zeros trimmed) -- matches every
1062/// `toString(localtime(...))`/`toString(time(...))` example in the TCK,
1063/// where `'21:40'` (no seconds given) prints without `:00`, but
1064/// `'21:40:32'` (seconds given, even if it were `:00`... though no TCK
1065/// example actually exercises that edge) prints with it.
1066fn format_time_of_day(nanos_of_day: i64) -> String {
1067    let hour = nanos_of_day / 3_600_000_000_000;
1068    let minute = (nanos_of_day / 60_000_000_000) % 60;
1069    let second = (nanos_of_day / 1_000_000_000) % 60;
1070    let nanos = (nanos_of_day % 1_000_000_000) as u32;
1071    let mut out = format!("{hour:02}:{minute:02}");
1072    if second != 0 || nanos != 0 {
1073        out.push_str(&format!(":{second:02}"));
1074        if nanos != 0 {
1075            let mut frac = format!("{nanos:09}");
1076            while frac.ends_with('0') {
1077                frac.pop();
1078            }
1079            out.push('.');
1080            out.push_str(&frac);
1081        }
1082    }
1083    out
1084}
1085
1086pub fn format_local_time(nanos_of_day: i64) -> String {
1087    format_time_of_day(nanos_of_day)
1088}
1089
1090pub fn format_time(nanos_of_day: i64, offset_seconds: i32) -> String {
1091    format!(
1092        "{}{}",
1093        format_time_of_day(nanos_of_day),
1094        format_offset(offset_seconds)
1095    )
1096}
1097
1098// ---------------------------------------------------------------------
1099// LocalDateTime / DateTime
1100// ---------------------------------------------------------------------
1101
1102/// Decomposes total (possibly negative) `epoch_seconds` into an
1103/// `(epoch_day, nanos_of_day)` pair -- `div_euclid`/`rem_euclid`, not
1104/// plain `/`/`%`, so a pre-1970 instant (negative `epoch_seconds`)
1105/// still gets a `nanos_of_day` in `0..NANOS_PER_DAY` (Rust's `%` on a
1106/// negative dividend returns a negative remainder, which would put the
1107/// "same calendar day" one day off).
1108pub fn split_epoch_seconds(epoch_seconds: i64) -> (i64, i64) {
1109    let epoch_day = epoch_seconds.div_euclid(SECONDS_PER_DAY);
1110    let secs_of_day = epoch_seconds.rem_euclid(SECONDS_PER_DAY);
1111    (epoch_day, secs_of_day * 1_000_000_000)
1112}
1113
1114pub fn combine_epoch_day_and_nanos_of_day(epoch_day: i64, nanos_of_day: i64) -> i64 {
1115    epoch_day * SECONDS_PER_DAY + nanos_of_day / 1_000_000_000
1116}
1117
1118/// Combines an `(epoch_day, nanos_of_day)` pair into `LocalDateTime`'s
1119/// own `(epoch_seconds, nanos)` storage shape -- shared by `<type>.
1120/// truncate()`'s date+time recombination step.
1121pub fn combine_date_and_time(epoch_day: i64, nanos_of_day: i64) -> (i64, i32) {
1122    (
1123        combine_epoch_day_and_nanos_of_day(epoch_day, nanos_of_day),
1124        (nanos_of_day % 1_000_000_000) as i32,
1125    )
1126}
1127
1128/// Calendar + time-of-day fields for `localdatetime({...})`/
1129/// `datetime({...})`'s map constructors -- bundled into one struct (not
1130/// 7 positional args) purely to stay under clippy's argument-count cap,
1131/// matching this codebase's established convention for that lint (see
1132/// e.g. `executor.rs`'s `VarExpandSpec`/`IndexSeekSpec`).
1133pub struct CalendarDateTime {
1134    pub year: i64,
1135    pub month: u32,
1136    pub day: u32,
1137    pub hour: i64,
1138    pub minute: i64,
1139    pub second: i64,
1140    pub nanos: i64,
1141}
1142
1143/// Builds a naive (zone-less) `(epoch_seconds, nanos)` instant from
1144/// calendar + time-of-day fields -- shared by `localdatetime({...})`'s
1145/// map form and (before the UTC offset adjustment) `datetime({...})`'s.
1146pub fn local_date_time_from_fields(f: CalendarDateTime) -> Option<(i64, i32)> {
1147    let epoch_day = epoch_day_from_ymd(f.year, f.month, f.day)?;
1148    let nanos_of_day = local_time_nanos_from_fields(f.hour, f.minute, f.second, f.nanos)?;
1149    Some((
1150        combine_epoch_day_and_nanos_of_day(epoch_day, nanos_of_day),
1151        (nanos_of_day % 1_000_000_000) as i32,
1152    ))
1153}
1154
1155/// Same as `local_date_time_from_fields`, but the wall-clock reading is
1156/// in the given zone -- for a fixed `Offset`, subtracts it to get the
1157/// UTC instant `DateTime` actually stores (see its doc comment); for a
1158/// `Named` zone, resolves the real, DST-aware offset for *this specific*
1159/// local date-time via `chrono-tz` (the same zone can mean a different
1160/// offset on a different date, which is why this needs the full
1161/// calendar context `resolve_offset` alone doesn't have).
1162pub fn date_time_from_fields(f: CalendarDateTime, zone: &TzId) -> Option<(i64, i32)> {
1163    match zone {
1164        TzId::Offset(offset_seconds) => {
1165            let (local_epoch_seconds, nanos) = local_date_time_from_fields(f)?;
1166            Some((local_epoch_seconds - *offset_seconds as i64, nanos))
1167        }
1168        TzId::Named(name) => {
1169            let tz = parse_timezone_name(name)?;
1170            let epoch_day = epoch_day_from_ymd(f.year, f.month, f.day)?;
1171            let nanos_of_day = local_time_nanos_from_fields(f.hour, f.minute, f.second, f.nanos)?;
1172            let naive = chrono_naive_from(epoch_day, nanos_of_day)?;
1173            let (epoch_seconds, _offset) = utc_from_local_and_named_zone(naive, tz)?;
1174            Some((epoch_seconds, (nanos_of_day % 1_000_000_000) as i32))
1175        }
1176    }
1177}
1178
1179/// Parses `YYYY-MM-DDTHH:MM:SS.fff` (and the compact/date-only-precision
1180/// variants `parse_date` already supports for the date half) into a
1181/// naive `(epoch_seconds, nanos)` instant. A date-only string (no `T`)
1182/// is also accepted, reading as midnight -- real Cypher's
1183/// `localdatetime('-999999999-01-01')` (TCK Temporal10 [10]).
1184pub fn parse_local_date_time(s: &str) -> Option<(i64, i32)> {
1185    let s = s.trim();
1186    let (date_part, time_part) = match s.split_once('T') {
1187        Some(parts) => parts,
1188        None => (s, ""),
1189    };
1190    let epoch_day = parse_date(date_part)?;
1191    let nanos_of_day = if time_part.is_empty() {
1192        0
1193    } else {
1194        parse_time_of_day(time_part)?
1195    };
1196    Some((
1197        combine_epoch_day_and_nanos_of_day(epoch_day, nanos_of_day),
1198        (nanos_of_day % 1_000_000_000) as i32,
1199    ))
1200}
1201
1202/// Same date+time parse as `parse_local_date_time`, plus a required
1203/// zone on the time half -- either a fixed offset (`+01:00`), a
1204/// bracketed named zone with no explicit offset (`[Europe/London]`, the
1205/// true offset derived from the zone for *this* local date-time, TCK's
1206/// Temporal2 [6]), or both together (`+02:00[Europe/Stockholm]`, the
1207/// explicit offset is trusted for the instant and the bracket is kept
1208/// only for `TzId::Named`'s round-trip display).
1209pub fn parse_date_time(s: &str) -> Option<(i64, i32, TzId)> {
1210    let s = s.trim();
1211    let (date_part, time_part) = s.split_once('T')?;
1212    let epoch_day = parse_date(date_part)?;
1213    let (time_part, zone_name) = match time_part.split_once('[') {
1214        Some((t, rest)) => (t, Some(rest.strip_suffix(']')?)),
1215        None => (time_part, None),
1216    };
1217    let (time_only, offset_part) = split_time_offset(time_part);
1218    let nanos_of_day = parse_time_of_day(time_only)?;
1219    match (offset_part, zone_name) {
1220        (Some(offset_str), zone_name) => {
1221            let offset_seconds = parse_offset_seconds(offset_str)?;
1222            let local_epoch_seconds = combine_epoch_day_and_nanos_of_day(epoch_day, nanos_of_day);
1223            let zone = match zone_name {
1224                Some(zone_str) => {
1225                    parse_timezone_name(zone_str)?;
1226                    TzId::Named(zone_str.to_string())
1227                }
1228                None => TzId::Offset(offset_seconds),
1229            };
1230            Some((
1231                local_epoch_seconds - offset_seconds as i64,
1232                (nanos_of_day % 1_000_000_000) as i32,
1233                zone,
1234            ))
1235        }
1236        (None, Some(zone_str)) => {
1237            let tz = parse_timezone_name(zone_str)?;
1238            let naive = chrono_naive_from(epoch_day, nanos_of_day)?;
1239            let (epoch_seconds, _offset) = utc_from_local_and_named_zone(naive, tz)?;
1240            Some((
1241                epoch_seconds,
1242                (nanos_of_day % 1_000_000_000) as i32,
1243                TzId::Named(zone_str.to_string()),
1244            ))
1245        }
1246        (None, None) => None,
1247    }
1248}
1249
1250/// `d.<prop>` component access for `LocalDateTime`/`DateTime`'s
1251/// *calendar* fields (`year`, `month`, ..., `dayOfQuarter`) -- delegates
1252/// straight to `date_component` on the instant's calendar day, since
1253/// the calendar math is identical to `Date`'s.
1254pub fn date_time_calendar_component(epoch_seconds: i64, prop: &str) -> Option<i64> {
1255    let (epoch_day, _) = split_epoch_seconds(epoch_seconds);
1256    date_component(epoch_day, prop)
1257}
1258
1259/// `d.<prop>` component access for `LocalDateTime`/`DateTime`'s
1260/// *time-of-day* fields (`hour`, ..., `nanosecond`) -- delegates to
1261/// `local_time_component` on the instant's nanos-of-day, folding in the
1262/// caller-supplied sub-second `nanos` remainder (`epoch_seconds` alone
1263/// only has whole-second precision).
1264pub fn date_time_clock_component(epoch_seconds: i64, nanos: i32, prop: &str) -> Option<i64> {
1265    let (_, nanos_of_day) = split_epoch_seconds(epoch_seconds);
1266    local_time_component(nanos_of_day + nanos as i64, prop)
1267}
1268
1269pub fn epoch_seconds_and_millis(epoch_seconds: i64, nanos: i32) -> (i64, i64) {
1270    (
1271        epoch_seconds,
1272        epoch_seconds * 1000 + (nanos as i64) / 1_000_000,
1273    )
1274}
1275
1276/// `YYYY-MM-DDTHH:MM[:SS[.fraction]]` -- date half via `format_date`,
1277/// time half via the same `format_time_of_day` rule `LocalTime`/`Time`
1278/// use (seconds/fraction only shown when non-zero).
1279pub fn format_local_date_time(epoch_seconds: i64, nanos: i32) -> String {
1280    let (epoch_day, nanos_of_day) = split_epoch_seconds(epoch_seconds);
1281    format!(
1282        "{}T{}",
1283        format_date(epoch_day),
1284        format_time_of_day(nanos_of_day + nanos as i64)
1285    )
1286}
1287
1288/// `Time`/`LocalTime` + `Duration` -- wraps at the 24h boundary (`Time`/
1289/// `LocalTime` have no calendar, so there's no "next day" to carry
1290/// into). Real Cypher truncates a Duration's calendar components
1291/// (`months`/`days`) when adding it to a time-only value -- only
1292/// `seconds`/`nanos` apply -- rather than erroring, so this never fails
1293/// (`Option` elsewhere in this module means "can overflow"; wrapping
1294/// never can).
1295pub fn add_duration_to_time(nanos_of_day: i64, seconds: i64, nanos: i32, negate: bool) -> i64 {
1296    let (seconds, nanos) = if negate {
1297        (-seconds, -nanos)
1298    } else {
1299        (seconds, nanos)
1300    };
1301    let total: i128 = nanos_of_day as i128 + seconds as i128 * NANOS_PER_SEC + nanos as i128;
1302    total.rem_euclid(NANOS_PER_DAY as i128) as i64
1303}
1304
1305const NANOS_PER_DAY: i64 = SECONDS_PER_DAY * 1_000_000_000;
1306
1307/// `LocalDateTime`/`DateTime` + `Duration` -- real calendar month
1308/// arithmetic on the date part (same `checked_add_months`/
1309/// `checked_sub_months` clamping as `add_duration_to_date`), then
1310/// `days`/`seconds`/`nanos` added as one exact nanosecond count that
1311/// carries across day boundaries (unlike `Date`, which has no time-of-
1312/// day to carry *into* -- a `LocalDateTime`/`DateTime` does, so nothing
1313/// here gets truncated the way `add_duration_to_date`'s `seconds`/
1314/// `nanos` do). Operates on the *local* wall-clock reading -- `DateTime`
1315/// callers pass `epoch_seconds + offset_seconds` in and subtract
1316/// `offset_seconds` back out of the result, so month/day arithmetic
1317/// happens against the calendar the user actually wrote, not the UTC
1318/// instant (matches real Cypher: `datetime({..., timezone: '+05:00'})
1319/// + duration({months: 1})` advances the *local* month).
1320pub fn add_duration_to_local_date_time(
1321    epoch_seconds: i64,
1322    existing_nanos: i32,
1323    months: i64,
1324    days: i64,
1325    seconds: i64,
1326    nanos: i32,
1327    negate: bool,
1328) -> Option<(i64, i32)> {
1329    let (months, days, seconds, nanos) = if negate {
1330        (
1331            months.checked_neg()?,
1332            days.checked_neg()?,
1333            seconds.checked_neg()?,
1334            nanos.checked_neg()?,
1335        )
1336    } else {
1337        (months, days, seconds, nanos)
1338    };
1339    let (epoch_day, nanos_of_day) = split_epoch_seconds(epoch_seconds);
1340    let new_epoch_day = add_months_to_epoch_day(epoch_day, months)?;
1341
1342    let total_ns: i128 = nanos_of_day as i128
1343        + existing_nanos as i128
1344        + days as i128 * NANOS_PER_DAY as i128
1345        + seconds as i128 * NANOS_PER_SEC
1346        + nanos as i128;
1347    let day_ns = NANOS_PER_DAY as i128;
1348    let extra_days = total_ns.div_euclid(day_ns) as i64;
1349    let final_nanos_of_day = total_ns.rem_euclid(day_ns) as i64;
1350
1351    let final_epoch_day = new_epoch_day.checked_add(extra_days)?;
1352    let final_epoch_seconds = final_epoch_day
1353        .checked_mul(SECONDS_PER_DAY)?
1354        .checked_add(final_nanos_of_day / 1_000_000_000)?;
1355    Some((
1356        final_epoch_seconds,
1357        (final_nanos_of_day % 1_000_000_000) as i32,
1358    ))
1359}
1360
1361pub fn format_date_time(epoch_seconds: i64, nanos: i32, zone: &TzId) -> String {
1362    // The *displayed* wall-clock reading is the local (offset-adjusted)
1363    // one, not the stored UTC instant -- `DateTime` round-trips through
1364    // `toString`/reparse showing the original offset's time-of-day, per
1365    // the TCK's own examples (e.g. `datetime({..., timezone: '+01:00'})`
1366    // prints that same `+01:00` wall-clock hour back, not the UTC one).
1367    let offset_seconds = resolve_offset(zone, epoch_seconds);
1368    let local_epoch_seconds = epoch_seconds + offset_seconds as i64;
1369    let zone_suffix = match zone {
1370        TzId::Offset(_) => String::new(),
1371        // Real Cypher's `toString()` round-trips the zone name alongside
1372        // its resolved offset (`+02:00[Europe/Stockholm]`), not just the
1373        // offset alone -- TCK's Temporal1 [10].
1374        TzId::Named(name) => format!("[{name}]"),
1375    };
1376    format!(
1377        "{}{}{}",
1378        format_local_date_time(local_epoch_seconds, nanos),
1379        format_offset(offset_seconds),
1380        zone_suffix
1381    )
1382}
1383
1384/// Resolves a `TzId`'s real UTC offset (seconds east of UTC) at a given
1385/// UTC instant -- `Offset`'s value directly, or a `Named` zone's real,
1386/// DST-aware offset via `chrono-tz`'s embedded IANA database (the same
1387/// zone name resolves to a *different* offset depending on which instant
1388/// this is called with -- there's no single fixed "the" offset for a
1389/// named zone, e.g. TCK's Temporal1 [10] resolves `Europe/Stockholm` to
1390/// `+01:00` in October and `+02:00` in July). Falls back to UTC (`0`)
1391/// for a zone name that fails to parse -- should never happen for a
1392/// value MarsDB itself constructed (every `Named` zone is validated via
1393/// `parse_timezone_name` before being stored), but this function can't
1394/// return an error, so degrade gracefully rather than panic on a
1395/// hypothetical corrupt/foreign-written value.
1396pub fn resolve_offset(zone: &TzId, epoch_seconds: i64) -> i32 {
1397    match zone {
1398        TzId::Offset(o) => *o,
1399        TzId::Named(name) => {
1400            let tz = parse_timezone_name(name).unwrap_or(chrono_tz::Tz::UTC);
1401            let utc = chrono::DateTime::<chrono::Utc>::from_timestamp(epoch_seconds, 0)
1402                .unwrap_or_default();
1403            utc.with_timezone(&tz).offset().fix().local_minus_utc()
1404        }
1405    }
1406}
1407
1408/// Parses an IANA timezone name (`'Europe/Stockholm'`) -- `None` if `s`
1409/// isn't a zone `chrono-tz`'s embedded database recognizes.
1410pub fn parse_timezone_name(s: &str) -> Option<chrono_tz::Tz> {
1411    s.parse().ok()
1412}
1413
1414/// Given a *local* (wall-clock) naive date-time and a named zone,
1415/// resolves the true UTC `(epoch_seconds, offset_seconds)` -- the
1416/// overwhelming common case is `LocalResult::Single`; a DST fall-back
1417/// repeated hour (`Ambiguous`) takes the earlier instant, a DST
1418/// spring-forward gap (`None`, the local time never occurred) has no
1419/// valid mapping and fails -- real Cypher doesn't define a specific
1420/// tie-break for either, and no TCK scenario lands in one.
1421fn utc_from_local_and_named_zone(naive: NaiveDateTime, tz: chrono_tz::Tz) -> Option<(i64, i32)> {
1422    let dt = match tz.from_local_datetime(&naive) {
1423        LocalResult::Single(dt) => dt,
1424        LocalResult::Ambiguous(earlier, _later) => earlier,
1425        LocalResult::None => return None,
1426    };
1427    let offset = dt.offset().fix().local_minus_utc();
1428    Some((dt.timestamp(), offset))
1429}
1430
1431// ---------------------------------------------------------------------
1432// duration.between / .inMonths / .inDays / .inSeconds
1433// ---------------------------------------------------------------------
1434
1435const NANOS_PER_DAY_I64: i64 = SECONDS_PER_DAY * 1_000_000_000;
1436
1437/// A civil (zone-less) date-time as this module's own pair -- replaces
1438/// chrono's `NaiveDateTime` in the `duration.between` core so the full
1439/// ±999_999_999-year range works (see the civil-core section's docs).
1440#[derive(Clone, Copy)]
1441struct CivilDateTime {
1442    epoch_day: i64,
1443    nanos_of_day: i64,
1444}
1445
1446/// Total nanoseconds since the epoch -- i128 because the full year
1447/// range's span (~6.3e25 ns) far exceeds i64.
1448fn civil_total_ns(dt: CivilDateTime) -> i128 {
1449    dt.epoch_day as i128 * NANOS_PER_DAY_I64 as i128 + dt.nanos_of_day as i128
1450}
1451
1452/// `NaiveDateTime` for the chrono-backed named-zone paths only -- `None`
1453/// outside chrono's own ±262k-year range (a named IANA zone has no
1454/// meaningful data at such years; fixed offsets never come through
1455/// here).
1456fn chrono_naive_from(epoch_day: i64, nanos_of_day: i64) -> Option<NaiveDateTime> {
1457    let secs = epoch_day.checked_mul(SECONDS_PER_DAY)? + nanos_of_day / 1_000_000_000;
1458    chrono::DateTime::<chrono::Utc>::from_timestamp(secs, (nanos_of_day % 1_000_000_000) as u32)
1459        .map(|utc| utc.naive_utc())
1460}
1461
1462/// `java.time`'s `LocalDate`-difference-in-whole-months primitive
1463/// (`ChronoUnit.MONTHS.between`, which Neo4j's own `duration.between`
1464/// mirrors exactly): pack each date into a single sortable
1465/// `proleptic_month * 32 + day_of_month` value (32 safely exceeds any
1466/// month's real day count) so one integer division gives the exact
1467/// whole-month count, day-of-month aware, without a real calendar walk.
1468fn packed_proleptic(epoch_day: i64) -> i64 {
1469    let (y, m, d) = civil_from_days(epoch_day);
1470    (y * 12 + m as i64 - 1) * 32 + d as i64
1471}
1472
1473fn months_between_days(a: i64, b: i64) -> i64 {
1474    (packed_proleptic(b) - packed_proleptic(a)) / 32
1475}
1476
1477/// Adds `months` to `dt`'s *date* only (real calendar month arithmetic,
1478/// clamping to the shorter month's last day, same as
1479/// `add_duration_to_date`), keeping the time-of-day unchanged.
1480fn shift_months(dt: CivilDateTime, months: i64) -> CivilDateTime {
1481    let shifted = add_months_to_epoch_day(dt.epoch_day, months)
1482        .expect("months_between_days never shifts past the endpoint it was computed from");
1483    CivilDateTime {
1484        epoch_day: shifted,
1485        nanos_of_day: dt.nanos_of_day,
1486    }
1487}
1488
1489/// Shared core of `duration.between`/`.inMonths`/`.inDays`/
1490/// `.inSeconds`: `(months, shifted_remaining_ns, raw_total_ns)`.
1491///
1492/// If *either* operand has no calendar date (`a_date`/`b_date` is
1493/// `None` -- a bare `LocalTime`/`Time`), both operands' dates are
1494/// disregarded entirely (not even treated as a shared reference day --
1495/// verified against the TCK's own `date(...)` vs `localtime(...)`
1496/// examples, which produce a plain small time-of-day difference, never
1497/// a huge multi-year value derived from the date side's real calendar
1498/// date) -- `months` is always `0` in that case, and both the "raw" and
1499/// "month-shifted" totals collapse to the same plain time-of-day delta.
1500///
1501/// Otherwise: `months` is the real calendar month count between the two
1502/// full date-times (`months_between_datetimes_offset_aware`); `shifted_remaining_ns`
1503/// is the exact elapsed time between `from` *shifted forward by that
1504/// many months* and `to` (what `duration.between` bucket-splits into
1505/// days/seconds/nanos on top of `months` -- verified against the TCK to
1506/// NOT be a further calendar-date subtraction, just total elapsed time
1507/// re-divided by a day's worth of nanoseconds); `raw_total_ns` is the
1508/// plain, unshifted elapsed time between the two original instants
1509/// (what `.inDays`/`.inSeconds` use instead, discarding the month
1510/// optimization entirely -- confirmed by the TCK: `.inDays` on a
1511/// date+time target still reports a bare whole-day count with the
1512/// sub-day remainder silently truncated away, not carried as a
1513/// remaining `T...` component).
1514fn to_utc_instant_ns(dt: CivilDateTime, zone: &TzId) -> i128 {
1515    match zone {
1516        TzId::Offset(o) => civil_total_ns(dt) - *o as i128 * NANOS_PER_SEC,
1517        TzId::Named(name) => {
1518            if let (Some(tz), Some(naive)) = (
1519                parse_timezone_name(name),
1520                chrono_naive_from(dt.epoch_day, dt.nanos_of_day),
1521            ) {
1522                if let Some((epoch_seconds, _)) = utc_from_local_and_named_zone(naive, tz) {
1523                    return epoch_seconds as i128 * NANOS_PER_SEC
1524                        + (dt.nanos_of_day % 1_000_000_000) as i128;
1525                }
1526            }
1527            civil_total_ns(dt)
1528        }
1529    }
1530}
1531
1532fn elapsed_ns(
1533    from: CivilDateTime,
1534    from_zone: Option<&TzId>,
1535    to: CivilDateTime,
1536    to_zone: Option<&TzId>,
1537) -> i128 {
1538    match (from_zone, to_zone) {
1539        (Some(fz), Some(tz)) => to_utc_instant_ns(to, tz) - to_utc_instant_ns(from, fz),
1540        (Some(fz), None) => to_utc_instant_ns(to, fz) - to_utc_instant_ns(from, fz),
1541        (None, Some(tz)) => to_utc_instant_ns(to, tz) - to_utc_instant_ns(from, tz),
1542        (None, None) => civil_total_ns(to) - civil_total_ns(from),
1543    }
1544}
1545
1546fn months_between_datetimes_offset_aware(
1547    from: CivilDateTime,
1548    from_zone: Option<&TzId>,
1549    to: CivilDateTime,
1550    to_zone: Option<&TzId>,
1551) -> i64 {
1552    let mut months = months_between_days(from.epoch_day, to.epoch_day);
1553    let shifted = shift_months(from, months);
1554    let delta = elapsed_ns(shifted, from_zone, to, to_zone);
1555    if months > 0 && delta < 0 {
1556        months -= 1;
1557    } else if months < 0 && delta > 0 {
1558        months += 1;
1559    }
1560    months
1561}
1562
1563fn time_to_utc_nanos(nanos_of_day: i64, zone: &TzId, ref_date: Option<i64>) -> i128 {
1564    let dt = CivilDateTime {
1565        epoch_day: ref_date.unwrap_or(0),
1566        nanos_of_day,
1567    };
1568    to_utc_instant_ns(dt, zone)
1569}
1570
1571fn between_components(
1572    a_date: Option<i64>,
1573    a_time: Option<i64>,
1574    a_zone: Option<&TzId>,
1575    b_date: Option<i64>,
1576    b_time: Option<i64>,
1577    b_zone: Option<&TzId>,
1578) -> (i64, i128, i128) {
1579    match (a_date, b_date) {
1580        (Some(ad), Some(bd)) => {
1581            let from = CivilDateTime {
1582                epoch_day: ad,
1583                nanos_of_day: a_time.unwrap_or(0),
1584            };
1585            let to = CivilDateTime {
1586                epoch_day: bd,
1587                nanos_of_day: b_time.unwrap_or(0),
1588            };
1589            let months = months_between_datetimes_offset_aware(from, a_zone, to, b_zone);
1590            let shifted = shift_months(from, months);
1591            let shifted_remaining_ns = elapsed_ns(shifted, a_zone, to, b_zone);
1592            let raw_total_ns = elapsed_ns(from, a_zone, to, b_zone);
1593            (months, shifted_remaining_ns, raw_total_ns)
1594        }
1595        _ => {
1596            let diff = match (a_zone, b_zone) {
1597                (Some(az), Some(bz)) => {
1598                    // Both sides resolved against the *same* reference
1599                    // date -- "time-only mode" means the date each
1600                    // operand happens to carry is disregarded (see this
1601                    // function's module docs), so `a`/`b` must not each
1602                    // pull in their own, potentially wildly different,
1603                    // real date (that only cancels out in `bt - at` when
1604                    // it's identical on both sides; a real, previously-
1605                    // caught regression when this used `a_date`/`b_date`
1606                    // independently). Only matters for resolving a
1607                    // `Named` zone's DST-dependent offset -- a fixed
1608                    // `Offset` doesn't care what date it's given at all.
1609                    let ref_date = a_date.or(b_date);
1610                    let at = time_to_utc_nanos(a_time.unwrap_or(0), az, ref_date);
1611                    let bt = time_to_utc_nanos(b_time.unwrap_or(0), bz, ref_date);
1612                    bt - at
1613                }
1614                (Some(az), None) => {
1615                    let at = time_to_utc_nanos(a_time.unwrap_or(0), az, a_date);
1616                    let bt = time_to_utc_nanos(b_time.unwrap_or(0), az, a_date);
1617                    bt - at
1618                }
1619                (None, Some(bz)) => {
1620                    let at = time_to_utc_nanos(a_time.unwrap_or(0), bz, b_date);
1621                    let bt = time_to_utc_nanos(b_time.unwrap_or(0), bz, b_date);
1622                    bt - at
1623                }
1624                (None, None) => (b_time.unwrap_or(0) - a_time.unwrap_or(0)) as i128,
1625            };
1626            (0, diff, diff)
1627        }
1628    }
1629}
1630
1631pub fn duration_between(
1632    a_date: Option<i64>,
1633    a_time: Option<i64>,
1634    a_zone: Option<&TzId>,
1635    b_date: Option<i64>,
1636    b_time: Option<i64>,
1637    b_zone: Option<&TzId>,
1638) -> DurationParts {
1639    let (months, shifted_ns, _) =
1640        between_components(a_date, a_time, a_zone, b_date, b_time, b_zone);
1641    // After the month shift the remainder spans at most one month of
1642    // calendar distance -- days always fit i64; the sub-day remainder
1643    // always fits i64 seconds.
1644    let days = (shifted_ns / NANOS_PER_DAY_I64 as i128) as i64;
1645    let rem = shifted_ns % NANOS_PER_DAY_I64 as i128;
1646    let seconds = rem.div_euclid(NANOS_PER_SEC) as i64;
1647    let nanos = rem.rem_euclid(NANOS_PER_SEC) as i32;
1648    (months, days, seconds, nanos)
1649}
1650
1651pub fn duration_in_months(
1652    a_date: Option<i64>,
1653    a_time: Option<i64>,
1654    a_zone: Option<&TzId>,
1655    b_date: Option<i64>,
1656    b_time: Option<i64>,
1657    b_zone: Option<&TzId>,
1658) -> DurationParts {
1659    let (months, _, _) = between_components(a_date, a_time, a_zone, b_date, b_time, b_zone);
1660    (months, 0, 0, 0)
1661}
1662
1663pub fn duration_in_days(
1664    a_date: Option<i64>,
1665    a_time: Option<i64>,
1666    a_zone: Option<&TzId>,
1667    b_date: Option<i64>,
1668    b_time: Option<i64>,
1669    b_zone: Option<&TzId>,
1670) -> DurationParts {
1671    let (_, _, raw) = between_components(a_date, a_time, a_zone, b_date, b_time, b_zone);
1672    (0, (raw / NANOS_PER_DAY_I64 as i128) as i64, 0, 0)
1673}
1674
1675pub fn duration_in_seconds(
1676    a_date: Option<i64>,
1677    a_time: Option<i64>,
1678    a_zone: Option<&TzId>,
1679    b_date: Option<i64>,
1680    b_time: Option<i64>,
1681    b_zone: Option<&TzId>,
1682) -> DurationParts {
1683    let (_, _, raw) = between_components(a_date, a_time, a_zone, b_date, b_time, b_zone);
1684    (
1685        0,
1686        0,
1687        // The full-year-range span (~6.3e16 s) fits i64 seconds even
1688        // though its nanosecond total doesn't.
1689        raw.div_euclid(NANOS_PER_SEC) as i64,
1690        raw.rem_euclid(NANOS_PER_SEC) as i32,
1691    )
1692}
1693
1694// ---------------------------------------------------------------------
1695// <type>.truncate(unit, value, map)
1696// ---------------------------------------------------------------------
1697
1698/// Truncates a calendar date down to the start of `unit` -- `None` for
1699/// any unit that isn't a calendar-scale one (`hour`/`minute`/... apply
1700/// to the *time* half, see `truncate_time_unit`). `millennium`/
1701/// `century`/`decade` floor the year to the nearest boundary below it
1702/// (`2017 -> 2000`, `1984 -> 1900`/`1980`) -- plain `year -
1703/// year.rem_euclid(N)`, correct for negative years too since
1704/// `rem_euclid` is always non-negative. `week`/`weekYear` use the same
1705/// ISO week-date math as `.week`/`.weekYear` component access
1706/// (`date_component`) -- the Monday of that ISO week/week-year.
1707pub fn truncate_date_unit(epoch_day: i64, unit: &str) -> Option<i64> {
1708    let (y, m, _) = civil_from_days(epoch_day);
1709    match unit {
1710        "millennium" => epoch_day_from_ymd(y - y.rem_euclid(1000), 1, 1),
1711        "century" => epoch_day_from_ymd(y - y.rem_euclid(100), 1, 1),
1712        "decade" => epoch_day_from_ymd(y - y.rem_euclid(10), 1, 1),
1713        "year" => epoch_day_from_ymd(y, 1, 1),
1714        "quarter" => epoch_day_from_ymd(y, (m - 1) / 3 * 3 + 1, 1),
1715        "month" => epoch_day_from_ymd(y, m, 1),
1716        "week" => {
1717            let (week_year, week) = iso_week_of(epoch_day);
1718            Some(iso_week1_monday(week_year) + (week - 1) * 7)
1719        }
1720        "weekYear" => {
1721            let (week_year, _) = iso_week_of(epoch_day);
1722            Some(iso_week1_monday(week_year))
1723        }
1724        "day" => Some(epoch_day),
1725        _ => None,
1726    }
1727}
1728
1729/// Moves `epoch_day` to the given ISO weekday (`1`=Monday..`7`=Sunday)
1730/// *within its own ISO week* -- the `dayOfWeek` override key on a
1731/// `.truncate('week', ...)` result (`date.truncate('week', d,
1732/// {dayOfWeek: 2})` is "the Tuesday of `d`'s week"), not general
1733/// week-date construction from a `{year, week, dayOfWeek}` triple with
1734/// no existing anchor date (that's `epoch_day_from_week_fields`).
1735/// `None` for an out-of-range `day_of_week`.
1736pub fn set_iso_weekday(epoch_day: i64, day_of_week: i64) -> Option<i64> {
1737    if !(1..=7).contains(&day_of_week) {
1738        return None;
1739    }
1740    let (week_year, week) = iso_week_of(epoch_day);
1741    let monday = iso_week1_monday(week_year) + (week - 1) * 7;
1742    Some(monday + (day_of_week - 1))
1743}
1744
1745/// Truncates a time-of-day down to the start of `unit` -- `None` for
1746/// any unit that isn't a clock-scale one. `day` truncates to midnight
1747/// (`0`), the shared boundary between the date and time halves.
1748pub fn truncate_time_unit(nanos_of_day: i64, unit: &str) -> Option<i64> {
1749    let floor = |n: i64| (nanos_of_day / n) * n;
1750    match unit {
1751        "hour" => Some(floor(3_600_000_000_000)),
1752        "minute" => Some(floor(60_000_000_000)),
1753        "second" => Some(floor(1_000_000_000)),
1754        "millisecond" => Some(floor(1_000_000)),
1755        "microsecond" => Some(floor(1_000)),
1756        "day" => Some(0),
1757        _ => None,
1758    }
1759}
1760
1761#[cfg(test)]
1762mod tests {
1763    use super::*;
1764
1765    fn du(months: f64, days: f64, hours: f64, minutes: f64, seconds: f64) -> DurationParts {
1766        normalize_duration(DurationFields {
1767            months,
1768            days,
1769            hours,
1770            minutes,
1771            seconds,
1772            ..Default::default()
1773        })
1774    }
1775
1776    #[test]
1777    fn construct_basic() {
1778        assert_eq!(
1779            format_duration_parts(du(0.0, 14.0, 16.0, 12.0, 0.0)),
1780            "P14DT16H12M"
1781        );
1782    }
1783
1784    #[test]
1785    fn construct_fractional_months() {
1786        let d = normalize_duration(DurationFields {
1787            months: 0.75,
1788            ..Default::default()
1789        });
1790        assert_eq!(format_duration_parts(d), "P22DT19H51M49.5S");
1791    }
1792
1793    #[test]
1794    fn construct_fractional_weeks() {
1795        let d = normalize_duration(DurationFields {
1796            weeks: 2.5,
1797            ..Default::default()
1798        });
1799        assert_eq!(format_duration_parts(d), "P17DT12H");
1800    }
1801
1802    #[test]
1803    fn construct_years_months_days_seconds_overflow() {
1804        let d = normalize_duration(DurationFields {
1805            years: 12.0,
1806            months: 5.0,
1807            days: 14.0,
1808            hours: 16.0,
1809            minutes: 12.0,
1810            seconds: 70.0,
1811            ..Default::default()
1812        });
1813        assert_eq!(format_duration_parts(d), "P12Y5M14DT16H13M10S");
1814    }
1815
1816    #[test]
1817    fn construct_sub_second() {
1818        let d = normalize_duration(DurationFields {
1819            days: 14.0,
1820            seconds: 70.0,
1821            milliseconds: 1.0,
1822            ..Default::default()
1823        });
1824        assert_eq!(format_duration_parts(d), "P14DT1M10.001S");
1825    }
1826
1827    #[test]
1828    fn construct_minutes_fraction() {
1829        let d = normalize_duration(DurationFields {
1830            minutes: 1.5,
1831            seconds: 1.0,
1832            ..Default::default()
1833        });
1834        assert_eq!(format_duration_parts(d), "PT1M31S");
1835    }
1836
1837    #[test]
1838    fn parse_string_p14dt16h12m() {
1839        assert_eq!(
1840            format_duration_parts(parse_duration("P14DT16H12M").unwrap()),
1841            "P14DT16H12M"
1842        );
1843    }
1844
1845    #[test]
1846    fn parse_string_p0_75m() {
1847        assert_eq!(
1848            format_duration_parts(parse_duration("P0.75M").unwrap()),
1849            "P22DT19H51M49.5S"
1850        );
1851    }
1852
1853    #[test]
1854    fn parse_string_pt0_75m() {
1855        assert_eq!(
1856            format_duration_parts(parse_duration("PT0.75M").unwrap()),
1857            "PT45S"
1858        );
1859    }
1860
1861    #[test]
1862    fn malformed_temporal_strings_are_rejected_without_panicking() {
1863        assert_eq!(parse_date("123é4"), None);
1864        for malformed in ["P", "PT", "Pgarbage", "P1Ygarbage", "P1Y2", "P1.2.3Y"] {
1865            assert_eq!(
1866                parse_duration(malformed),
1867                None,
1868                "{malformed} must be rejected"
1869            );
1870        }
1871    }
1872
1873    #[test]
1874    fn add_durations() {
1875        let a = du(149.0, 14.0, 16.0, 12.0, 70.0);
1876        let a = (a.0, a.1, a.2, 1);
1877        let sum = add_duration(a, a).unwrap();
1878        assert_eq!(format_duration_parts(sum), "P24Y10M28DT32H26M20.000000002S");
1879    }
1880
1881    #[test]
1882    fn scale_duration_by_half() {
1883        let base = (149, 14, 58390, 1);
1884        assert_eq!(
1885            format_duration_parts(scale_duration(base, 0.5)),
1886            "P6Y2M22DT13H21M8S"
1887        );
1888        assert_eq!(
1889            format_duration_parts(scale_duration(base, 2.0)),
1890            "P24Y10M28DT32H26M20.000000002S"
1891        );
1892    }
1893
1894    #[test]
1895    fn negative_seconds_fraction() {
1896        let d = normalize_duration(DurationFields {
1897            seconds: 2.0,
1898            milliseconds: -1.0,
1899            ..Default::default()
1900        });
1901        assert_eq!(format_duration_parts(d), "PT1.999S");
1902        let d = normalize_duration(DurationFields {
1903            seconds: -2.0,
1904            milliseconds: 1.0,
1905            ..Default::default()
1906        });
1907        assert_eq!(format_duration_parts(d), "PT-1.999S");
1908        let d = normalize_duration(DurationFields {
1909            seconds: -2.0,
1910            milliseconds: -1.0,
1911            ..Default::default()
1912        });
1913        assert_eq!(format_duration_parts(d), "PT-2.001S");
1914        let d = normalize_duration(DurationFields {
1915            seconds: 60.0,
1916            milliseconds: -1.0,
1917            ..Default::default()
1918        });
1919        assert_eq!(format_duration_parts(d), "PT59.999S");
1920        let d = normalize_duration(DurationFields {
1921            minutes: 12.0,
1922            seconds: -60.0,
1923            ..Default::default()
1924        });
1925        assert_eq!(format_duration_parts(d), "PT11M");
1926    }
1927
1928    #[test]
1929    fn date_roundtrip() {
1930        let d = epoch_day_from_ymd(1984, 10, 11).unwrap();
1931        assert_eq!(format_date(d), "1984-10-11");
1932        assert_eq!(parse_date("1984-10-11"), Some(d));
1933        assert_eq!(parse_date("19841011"), Some(d));
1934    }
1935
1936    #[test]
1937    fn date_components() {
1938        let d = epoch_day_from_ymd(1984, 10, 11).unwrap();
1939        assert_eq!(date_component(d, "year"), Some(1984));
1940        assert_eq!(date_component(d, "quarter"), Some(4));
1941        assert_eq!(date_component(d, "month"), Some(10));
1942        assert_eq!(date_component(d, "week"), Some(41));
1943        assert_eq!(date_component(d, "weekYear"), Some(1984));
1944        assert_eq!(date_component(d, "day"), Some(11));
1945        assert_eq!(date_component(d, "ordinalDay"), Some(285));
1946        assert_eq!(date_component(d, "weekDay"), Some(4));
1947        assert_eq!(date_component(d, "dayOfQuarter"), Some(11));
1948    }
1949
1950    #[test]
1951    fn date_plus_duration() {
1952        let x = epoch_day_from_ymd(1984, 10, 11).unwrap();
1953        let d = du(149.0, 14.0, 16.0, 12.0, 70.0);
1954        let sum = add_duration_to_date(x, d.0, d.1, d.2, d.3, false).unwrap();
1955        assert_eq!(format_date(sum), "1997-03-25");
1956        let diff = add_duration_to_date(x, d.0, d.1, d.2, d.3, true).unwrap();
1957        assert_eq!(format_date(diff), "1972-04-27");
1958    }
1959
1960    /// The fractional-duration case that exposed `add_duration_to_date`
1961    /// dropping `seconds`/`nanos` outright instead of folding whole extra
1962    /// days out of them -- see that function's doc comment.
1963    #[test]
1964    fn date_plus_fractional_duration_carries_extra_day_from_seconds() {
1965        let x = epoch_day_from_ymd(1984, 10, 11).unwrap();
1966        let d = normalize_duration(DurationFields {
1967            years: 12.5,
1968            months: 5.5,
1969            days: 14.5,
1970            hours: 16.5,
1971            minutes: 12.5,
1972            seconds: 70.5,
1973            nanoseconds: 3.0,
1974            ..Default::default()
1975        });
1976        let sum = add_duration_to_date(x, d.0, d.1, d.2, d.3, false).unwrap();
1977        assert_eq!(format_date(sum), "1997-10-11");
1978        let diff = add_duration_to_date(x, d.0, d.1, d.2, d.3, true).unwrap();
1979        assert_eq!(format_date(diff), "1971-10-12");
1980    }
1981
1982    #[test]
1983    fn duration_accessors() {
1984        let d = normalize_duration(DurationFields {
1985            years: 1.0,
1986            months: 4.0,
1987            days: 10.0,
1988            hours: 1.0,
1989            minutes: 1.0,
1990            seconds: 1.0,
1991            nanoseconds: 111_111_111.0,
1992            ..Default::default()
1993        });
1994        let get = |prop: &str| duration_component(d.0, d.1, d.2, d.3, prop).unwrap();
1995        assert_eq!(get("years"), 1);
1996        assert_eq!(get("quarters"), 5);
1997        assert_eq!(get("months"), 16);
1998        assert_eq!(get("weeks"), 1);
1999        assert_eq!(get("days"), 10);
2000        assert_eq!(get("hours"), 1);
2001        assert_eq!(get("minutes"), 61);
2002        assert_eq!(get("seconds"), 3661);
2003        assert_eq!(get("milliseconds"), 3_661_111);
2004        assert_eq!(get("microseconds"), 3_661_111_111);
2005        assert_eq!(get("nanoseconds"), 3_661_111_111_111);
2006        assert_eq!(get("quartersOfYear"), 1);
2007        assert_eq!(get("monthsOfQuarter"), 1);
2008        assert_eq!(get("monthsOfYear"), 4);
2009        assert_eq!(get("daysOfWeek"), 3);
2010        assert_eq!(get("minutesOfHour"), 1);
2011        assert_eq!(get("secondsOfMinute"), 1);
2012        assert_eq!(get("millisecondsOfSecond"), 111);
2013        assert_eq!(get("microsecondsOfSecond"), 111_111);
2014        assert_eq!(get("nanosecondsOfSecond"), 111_111_111);
2015    }
2016
2017    fn format_duration_parts(p: DurationParts) -> String {
2018        format_duration(p.0, p.1, p.2, p.3)
2019    }
2020
2021    /// The hand-rolled civil core must agree with chrono everywhere
2022    /// chrono can go -- sweeps ±~2.7 millennia of epoch days at a prime
2023    /// stride and cross-checks every derived component.
2024    #[test]
2025    fn civil_core_matches_chrono_across_its_range() {
2026        use chrono::Datelike;
2027        for epoch_day in (-1_000_000..1_000_000i64).step_by(9973) {
2028            let d = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()
2029                + chrono::Duration::days(epoch_day);
2030            let (y, m, day) = civil_from_days(epoch_day);
2031            assert_eq!((y, m, day), (d.year() as i64, d.month(), d.day()));
2032            assert_eq!(days_from_civil(y, m, day), epoch_day);
2033            assert_eq!(
2034                iso_weekday_from_days(epoch_day),
2035                d.weekday().number_from_monday() as i64
2036            );
2037            let iso = d.iso_week();
2038            assert_eq!(
2039                iso_week_of(epoch_day),
2040                (iso.year() as i64, iso.week() as i64)
2041            );
2042            assert_eq!(ordinal_day_of(y, m, day), d.ordinal() as i64);
2043        }
2044    }
2045
2046    #[test]
2047    fn expanded_year_dates_parse_and_round_trip() {
2048        let min = parse_date("-999999999-01-01").unwrap();
2049        let max = parse_date("+999999999-12-31").unwrap();
2050        assert_eq!(format_date(min), "-999999999-01-01");
2051        assert_eq!(format_date(max), "+999999999-12-31");
2052        assert_eq!(date_component(min, "year"), Some(-999_999_999));
2053        assert_eq!(date_component(max, "year"), Some(999_999_999));
2054        // Normal-range years stay unsigned/4-digit-padded.
2055        assert_eq!(format_date(parse_date("2020-01-10").unwrap()), "2020-01-10");
2056        assert_eq!(format_date(parse_date("0033-06-01").unwrap()), "0033-06-01");
2057    }
2058
2059    #[test]
2060    fn year_range_and_calendar_validity_are_enforced() {
2061        assert!(parse_date("+1000000000-01-01").is_none());
2062        assert!(parse_date("-1000000000-01-01").is_none());
2063        assert!(epoch_day_from_ymd(2020, 13, 1).is_none());
2064        assert!(epoch_day_from_ymd(2020, 2, 30).is_none());
2065        // Century leap rules: 1900 isn't a leap year, 2000 is.
2066        assert!(epoch_day_from_ymd(1900, 2, 29).is_none());
2067        assert!(epoch_day_from_ymd(2000, 2, 29).is_some());
2068    }
2069
2070    /// TCK Temporal10 [9]: the full-range duration.between.
2071    #[test]
2072    fn duration_between_spans_the_full_year_range() {
2073        let a = parse_date("-999999999-01-01").unwrap();
2074        let b = parse_date("+999999999-12-31").unwrap();
2075        let parts = duration_between(Some(a), None, None, Some(b), None, None);
2076        assert_eq!(format_duration_parts(parts), "P1999999998Y11M30D");
2077    }
2078
2079    /// TCK Temporal10 [10]: the full-range duration.inSeconds (whose
2080    /// nanosecond total overflows i64 -- the i128 core's own regression
2081    /// test).
2082    #[test]
2083    fn duration_in_seconds_spans_the_full_year_range() {
2084        let (a_secs, a_nanos) = parse_local_date_time("-999999999-01-01").unwrap();
2085        let (b_secs, b_nanos) = parse_local_date_time("+999999999-12-31T23:59:59").unwrap();
2086        let (a_day, a_nod) = split_epoch_seconds(a_secs);
2087        let (b_day, b_nod) = split_epoch_seconds(b_secs);
2088        let parts = duration_in_seconds(
2089            Some(a_day),
2090            Some(a_nod + a_nanos as i64),
2091            None,
2092            Some(b_day),
2093            Some(b_nod + b_nanos as i64),
2094            None,
2095        );
2096        assert_eq!(format_duration_parts(parts), "PT17531639991215H59M59S");
2097    }
2098}