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