Skip to main content

graphforge_rel/
temporal.rs

1//! Cypher temporal-value construction, evaluated at lowering time.
2//!
3//! The openCypher TCK constructs temporals from **literal** arguments —
4//! `date('2015-W30-2')`, `date({year: 2015, month: 7, day: 21})` — and renders
5//! the result as a quoted canonical ISO string (`'2015-07-21'`). Because the
6//! argument is a constant, we can parse and canonicalise it during lowering
7//! (`graphforge-rel`) and emit a plain `Utf8` literal, without a runtime UDF. Only the
8//! non-literal forms (rare in the corpus) fall back to DataFusion's
9//! `to_date`/`to_char`.
10//!
11//! This module currently covers `date(<string>)`. The map form
12//! (`date({year, month, day})` / ISO-week construction) and the time-bearing
13//! types (`localtime`/`time`/`localdatetime`/`datetime`) are follow-ups (#599).
14
15use chrono::{NaiveDate, NaiveTime, Timelike};
16use std::collections::HashMap;
17
18/// A temporal map-constructor field value, extracted from a literal map
19/// argument (`date({year: 1984, …})`) at lowering time. Variable references and
20/// other non-constant values can't be extracted and make construction bail to
21/// the runtime path. (#599)
22pub enum TemporalField {
23    /// An integer field (`year`, `hour`, `nanosecond`, …).
24    Int(i64),
25    /// A numeric field that may be fractional (duration `days: 1.5`).
26    Float(f64),
27    /// A string field (`timezone: '+01:00'` / `'Europe/Stockholm'`).
28    Str(String),
29    /// A nested temporal anchor (`{date: date('…'), …}`) as i64 days. (#1011)
30    Date(i64),
31}
32
33/// Lowering-time map of extracted temporal fields.
34type Fields = HashMap<String, TemporalField>;
35
36fn f_int(f: &Fields, k: &str) -> Option<i64> {
37    match f.get(k)? {
38        TemporalField::Int(n) => Some(*n),
39        _ => None,
40    }
41}
42
43fn f_num(f: &Fields, k: &str) -> Option<f64> {
44    #[allow(
45        clippy::cast_precision_loss,
46        reason = "duration counts are small; exactness beyond f64 is irrelevant"
47    )]
48    match f.get(k)? {
49        TemporalField::Int(n) => Some(*n as f64),
50        TemporalField::Float(x) => Some(*x),
51        _ => None,
52    }
53}
54
55fn f_str<'a>(f: &'a Fields, k: &str) -> Option<&'a str> {
56    match f.get(k)? {
57        TemporalField::Str(s) => Some(s),
58        _ => None,
59    }
60}
61
62fn f_date(f: &Fields, k: &str) -> Option<i64> {
63    match f.get(k)? {
64        TemporalField::Date(d) => Some(*d),
65        _ => None,
66    }
67}
68
69/// Canonical rendering of a temporal constructor called with a literal **map**
70/// argument (`date({year: 1984, month: 10, day: 11})`), or `None` if the fields
71/// don't form a valid value for `name`. (#599)
72#[must_use]
73pub fn render_temporal_map(name: &str, fields: &Fields) -> Option<String> {
74    match name {
75        "date" => resolve_date(fields).map(format_date),
76        "localtime" => {
77            if !fields.contains_key("hour") {
78                return None;
79            }
80            Some(format_time(&resolve_time(fields)?))
81        }
82        "time" => build_time_map(fields),
83        "localdatetime" => {
84            let date = resolve_date(fields)?;
85            let time = resolve_time(fields)?;
86            Some(format!("{}T{}", format_date(date), format_time(&time)))
87        }
88        "datetime" => build_date_time_map(fields),
89        "duration" => Some(format_duration(&build_duration_map(fields)?)),
90        _ => None,
91    }
92}
93
94/// Build a `date` (i64 days) from a literal map — the typed-value path
95/// (ADR 0009). `None` if the fields don't form a valid date. (#1011)
96#[must_use]
97pub fn date_from_map(fields: &Fields) -> Option<i64> {
98    resolve_date(fields)
99}
100
101/// i64 days since the Unix epoch (1970-01-01) for a chrono [`NaiveDate`]. No
102/// clamping — the full range round-trips (#1011).
103#[must_use]
104pub fn date_to_epoch_days(d: NaiveDate) -> Option<i64> {
105    let epoch = NaiveDate::from_ymd_opt(1970, 1, 1)?;
106    Some((d - epoch).num_days())
107}
108
109/// The chrono [`NaiveDate`] for an i64 days-since-epoch value — the in-range
110/// bridge for the date functions that keep chrono internals (projection,
111/// truncation, accessors, map construction). Returns `None` for a date outside
112/// chrono's ±262k-year range; such extreme dates only occur on the
113/// parse→`duration.between` path, which uses [`crate::calendar`] directly. (#1011)
114#[must_use]
115pub fn epoch_days_to_date(days: i64) -> Option<NaiveDate> {
116    NaiveDate::from_ymd_opt(1970, 1, 1)?.checked_add_signed(chrono::Duration::try_days(days)?)
117}
118
119/// The date component (i64 days) of a runtime temporal value for projection — a
120/// date is taken directly; a string is parsed as a date or the date part of a
121/// datetime (`2015-07-21T…`). (#920/#1011)
122#[must_use]
123pub fn parse_date_or_datetime_prefix(s: &str) -> Option<i64> {
124    let head = s.split_once('T').map_or(s, |(d, _)| d);
125    parse_date_string(head)
126}
127
128/// Component overrides for `date`-from-value projection (`Temporal3`). A field
129/// left `None` is taken from the base date. The active construction mode is
130/// selected by which override is present (week ▸ ordinal ▸ quarter ▸ calendar).
131#[derive(Default)]
132pub struct DateOverrides {
133    /// Calendar / ISO-week / ordinal / quarter year.
134    pub year: Option<i64>,
135    /// Calendar month (1–12).
136    pub month: Option<i64>,
137    /// Calendar day of month.
138    pub day: Option<i64>,
139    /// ISO week of year (selects ISO-week mode).
140    pub week: Option<i64>,
141    /// ISO day of week (1 = Monday … 7 = Sunday).
142    pub day_of_week: Option<i64>,
143    /// Day of year (selects ordinal mode).
144    pub ordinal_day: Option<i64>,
145    /// Quarter 1–4 (selects quarter mode).
146    pub quarter: Option<i64>,
147    /// 1-based day within the quarter.
148    pub day_of_quarter: Option<i64>,
149}
150
151/// Project a base date (i64 days) through component overrides (openCypher
152/// `date({date: base, …})` select-semantics): take `base`, replace the named
153/// components, keep the rest. Range-complete via `calendar` (#920/#1011).
154#[must_use]
155pub fn project_date(base_days: i64, o: &DateOverrides) -> Option<i64> {
156    use crate::calendar;
157    let (base_year, base_month, base_day) = calendar::civil_from_days(base_days);
158    let year = o.year.unwrap_or(base_year);
159    // ISO-week mode — selected by `week` OR `dayOfWeek`; the other defaults to
160    // the base's value.
161    if o.week.is_some() || o.day_of_week.is_some() {
162        let (base_iso_year, base_week) = calendar::iso_week(base_days);
163        let iso_year = o.year.unwrap_or(base_iso_year);
164        let week = match o.week {
165            Some(w) => u32::try_from(w).ok()?,
166            None => base_week,
167        };
168        let dow = match o.day_of_week {
169            Some(d) => u32::try_from(d).ok()?,
170            None => calendar::iso_weekday(base_days),
171        };
172        return calendar::from_iso_ywd(iso_year, week, dow);
173    }
174    if let Some(ord) = o.ordinal_day {
175        return calendar::from_ordinal(year, u32::try_from(ord).ok()?);
176    }
177    // Quarter mode — selected by `quarter` OR `dayOfQuarter`.
178    if o.quarter.is_some() || o.day_of_quarter.is_some() {
179        let quarter = match o.quarter {
180            Some(q) => q,
181            None => i64::from((base_month - 1) / 3 + 1),
182        };
183        let day_of_quarter = o
184            .day_of_quarter
185            .unwrap_or_else(|| base_day_of_quarter(base_days));
186        // A `dayOfQuarter` below 1 is invalid (matches the old unsigned cast).
187        let offset = u64::try_from(day_of_quarter - 1).ok()?;
188        let start_month = u32::try_from((quarter - 1) * 3 + 1).ok()?;
189        let start = calendar::ymd_to_days(year, start_month, 1)?;
190        let result = start + i64::try_from(offset).ok()?;
191        // Reject a `dayOfQuarter` that spills into another quarter/year.
192        let (result_year, result_month, _) = calendar::civil_from_days(result);
193        let result_quarter = i64::from((result_month - 1) / 3 + 1);
194        if result_year != year || result_quarter != quarter {
195            return None;
196        }
197        return Some(result);
198    }
199    let month = match o.month {
200        Some(m) => u32::try_from(m).ok()?,
201        None => base_month,
202    };
203    let day = match o.day {
204        Some(d) => u32::try_from(d).ok()?,
205        None => base_day,
206    };
207    calendar::ymd_to_days(year, month, day)
208}
209
210/// Whether `name` is a `date` component accessor (`d.year`, `d.weekDay`, …).
211#[must_use]
212pub fn is_date_accessor(name: &str) -> bool {
213    matches!(
214        name,
215        "year"
216            | "quarter"
217            | "month"
218            | "week"
219            | "weekYear"
220            | "day"
221            | "ordinalDay"
222            | "weekDay"
223            | "dayOfQuarter"
224    )
225}
226
227/// A `date` component value (openCypher `Temporal5`). ISO-8601 semantics for
228/// `week`/`weekYear`/`weekDay`; `weekDay` is 1 (Monday) … 7 (Sunday). `None`
229/// for an unknown accessor name.
230#[must_use]
231pub fn date_component(days: i64, name: &str) -> Option<i64> {
232    // Range-complete via `calendar` (NOT the chrono bridge): a parsed/stored
233    // extreme-year date must return its true component, not NULL. (#1011)
234    use crate::calendar;
235    let (year, month, day) = calendar::civil_from_days(days);
236    Some(match name {
237        "year" => year,
238        "quarter" => i64::from((month - 1) / 3 + 1),
239        "month" => i64::from(month),
240        "week" => i64::from(calendar::iso_week(days).1),
241        "weekYear" => calendar::iso_week(days).0,
242        "day" => i64::from(day),
243        "ordinalDay" => i64::from(calendar::ordinal(days)),
244        "weekDay" => i64::from(calendar::iso_weekday(days)),
245        "dayOfQuarter" => base_day_of_quarter(days),
246        _ => return None,
247    })
248}
249
250/// Whether `name` is a time-of-day component accessor (`d.hour`, `d.nanosecond`,
251/// …) — valid on `localtime`/`time`/`localdatetime`/`datetime`. (#920)
252#[must_use]
253pub fn is_time_accessor(name: &str) -> bool {
254    matches!(
255        name,
256        "hour" | "minute" | "second" | "millisecond" | "microsecond" | "nanosecond"
257    )
258}
259
260/// Whether `name` is a zone INT accessor (`d.offsetMinutes`/`d.offsetSeconds`) —
261/// valid on `time`/`datetime`. (#920)
262#[must_use]
263pub fn is_zone_int_accessor(name: &str) -> bool {
264    matches!(name, "offsetMinutes" | "offsetSeconds")
265}
266
267/// Whether `name` is a zone STRING accessor (`d.timezone`/`d.offset`) — valid on
268/// `time`/`datetime`. (#920)
269#[must_use]
270pub fn is_zone_str_accessor(name: &str) -> bool {
271    matches!(name, "timezone" | "offset")
272}
273
274/// Whether `name` is a `datetime` epoch accessor (`d.epochSeconds`/
275/// `d.epochMillis`). (#920)
276#[must_use]
277pub fn is_epoch_accessor(name: &str) -> bool {
278    matches!(name, "epochSeconds" | "epochMillis")
279}
280
281/// A time-of-day component value (openCypher `Temporal5`) from a nanoseconds-of-day.
282/// `millisecond`/`microsecond`/`nanosecond` are the CUMULATIVE sub-second value at
283/// that resolution (645 / 645876 / 645876123), not the digits of a single place.
284/// `None` for an unknown accessor name. (#920)
285#[must_use]
286pub fn time_component(nanos: i64, name: &str) -> Option<i64> {
287    let sub = nanos.rem_euclid(1_000_000_000);
288    Some(match name {
289        "hour" => nanos.div_euclid(3_600_000_000_000) % 24,
290        "minute" => nanos.div_euclid(60_000_000_000) % 60,
291        "second" => nanos.div_euclid(1_000_000_000) % 60,
292        "millisecond" => sub / 1_000_000,
293        "microsecond" => sub / 1_000,
294        "nanosecond" => sub,
295        _ => return None,
296    })
297}
298
299/// A zone INT component (`Temporal5`): `offsetMinutes`/`offsetSeconds` from a UTC
300/// offset in seconds. `None` for an unknown accessor name. (#920)
301#[must_use]
302pub fn zone_int_component(offset_seconds: i32, name: &str) -> Option<i64> {
303    Some(match name {
304        "offsetMinutes" => i64::from(offset_seconds) / 60,
305        "offsetSeconds" => i64::from(offset_seconds),
306        _ => return None,
307    })
308}
309
310/// A zone STRING component (`Temporal5`): `offset` (the `±HH:MM` designator) or
311/// `timezone` (the named IANA zone if present, else the offset designator).
312/// `None` for an unknown accessor name. (#920)
313#[must_use]
314pub fn zone_str_component(offset_seconds: i32, zone: Option<&str>, name: &str) -> Option<String> {
315    let offset_str = || {
316        format_offset(&Offset {
317            seconds: offset_seconds,
318            has_seconds: offset_seconds % 60 != 0,
319        })
320    };
321    Some(match name {
322        "offset" => offset_str(),
323        "timezone" => zone.map_or_else(offset_str, str::to_string),
324        _ => return None,
325    })
326}
327
328/// A `datetime` epoch component (`Temporal5`): `epochSeconds`/`epochMillis` — the
329/// UTC instant of `(date_days, nanos_of_day, offset_seconds)`. `None` for an
330/// unknown accessor name. (#920)
331#[must_use]
332pub fn epoch_component(days: i64, nanos: i64, offset_seconds: i32, name: &str) -> Option<i64> {
333    let epoch_secs = days * 86_400 + nanos.div_euclid(1_000_000_000) - i64::from(offset_seconds);
334    Some(match name {
335        "epochSeconds" => epoch_secs,
336        "epochMillis" => epoch_secs * 1_000 + nanos.rem_euclid(1_000_000_000) / 1_000_000,
337        _ => return None,
338    })
339}
340
341/// Truncate a date to the start of a unit (openCypher `date.truncate(unit, …)`):
342/// `millennium`/`century`/`decade` round the year down; `year`/`month`/`quarter`
343/// → the first day of that period; `weekYear` → the Monday of ISO week 1;
344/// `week` → the Monday of the date's week; `day` → the date itself. The optional
345/// override map is applied afterwards via [`project_date`]. `None` for an unknown
346/// unit. (#920)
347#[must_use]
348pub fn truncate_date(days: i64, unit: &str) -> Option<i64> {
349    // Range-complete via `calendar` (#1011): truncating an extreme-year date must
350    // yield the true period start, not NULL.
351    use crate::calendar;
352    let (year, month, _) = calendar::civil_from_days(days);
353    let jan1 = |y: i64| calendar::ymd_to_days(y, 1, 1);
354    match unit {
355        "millennium" => jan1(year.div_euclid(1000) * 1000),
356        "century" => jan1(year.div_euclid(100) * 100),
357        "decade" => jan1(year.div_euclid(10) * 10),
358        "year" => jan1(year),
359        "weekYear" => calendar::from_iso_ywd(calendar::iso_week(days).0, 1, 1),
360        "quarter" => calendar::ymd_to_days(year, ((month - 1) / 3) * 3 + 1, 1),
361        "month" => calendar::ymd_to_days(year, month, 1),
362        "week" => Some(days - i64::from(calendar::num_days_from_monday(days))),
363        "day" => Some(days),
364        _ => None,
365    }
366}
367
368/// Truncate a time-of-day (nanoseconds since midnight) to the start of a unit
369/// (openCypher `localtime.truncate(unit, …)` and the time component of the other
370/// `*.truncate`): `hour`/`minute`/`second`/`millisecond`/`microsecond` floor to
371/// that boundary; any unit coarser than a time-of-day (`day` and up) → midnight
372/// (`0`). `None` for an unknown unit. (#920)
373#[must_use]
374pub fn truncate_time_nanos(nanos: i64, unit: &str) -> Option<i64> {
375    let floor = |step: i64| (nanos / step) * step;
376    match unit {
377        "hour" => Some(floor(3_600_000_000_000)),
378        "minute" => Some(floor(60_000_000_000)),
379        "second" => Some(floor(1_000_000_000)),
380        "millisecond" => Some(floor(1_000_000)),
381        "microsecond" => Some(floor(1_000)),
382        "millennium" | "century" | "decade" | "year" | "weekYear" | "quarter" | "month"
383        | "week" | "day" => Some(0),
384        _ => None,
385    }
386}
387
388/// The 1-based day-of-quarter of a date (Jan 1 / Apr 1 / Jul 1 / Oct 1 → 1).
389/// Range-complete via `calendar` (the quarter-start month 1/4/7/10 is always
390/// valid, so the fallback is never taken). (#1011)
391fn base_day_of_quarter(days: i64) -> i64 {
392    let (year, month, _) = crate::calendar::civil_from_days(days);
393    let start_month = ((month - 1) / 3) * 3 + 1;
394    let start = crate::calendar::ymd_to_days(year, start_month, 1).unwrap_or(days);
395    days - start + 1
396}
397
398/// Build a date from calendar / ISO-week / ordinal / quarter fields, or from a
399/// `date` anchor with a `week` override (the `Temporal1` anchored forms).
400fn resolve_date(f: &Fields) -> Option<i64> {
401    // Range-complete via `calendar` (#1011); the anchor is a nested date() (i64
402    // days). `from_iso_ywd` validates `week`/`dayOfWeek` like the old chrono path.
403    use crate::calendar;
404    if let Some(anchor) = f_date(f, "date") {
405        if f.contains_key("week") {
406            let iso_year = match f_int(f, "year") {
407                Some(y) => y,
408                None => calendar::iso_week(anchor).0,
409            };
410            let week = u32::try_from(f_int(f, "week")?).ok()?;
411            let dow = match f_int(f, "dayOfWeek") {
412                Some(d) => u32::try_from(d).ok()?,
413                None => calendar::iso_weekday(anchor),
414            };
415            return calendar::from_iso_ywd(iso_year, week, dow);
416        }
417        return Some(anchor);
418    }
419
420    let year = f_int(f, "year")?;
421    if f.contains_key("month") || f.contains_key("day") {
422        let month = u32::try_from(f_int(f, "month").unwrap_or(1)).ok()?;
423        let day = u32::try_from(f_int(f, "day").unwrap_or(1)).ok()?;
424        calendar::ymd_to_days(year, month, day)
425    } else if f.contains_key("week") {
426        let week = u32::try_from(f_int(f, "week")?).ok()?;
427        let dow = u32::try_from(f_int(f, "dayOfWeek").unwrap_or(1)).ok()?;
428        calendar::from_iso_ywd(year, week, dow)
429    } else if f.contains_key("ordinalDay") {
430        calendar::from_ordinal(year, u32::try_from(f_int(f, "ordinalDay")?).ok()?)
431    } else if f.contains_key("quarter") {
432        let quarter = f_int(f, "quarter")?;
433        let day_of_quarter = f_int(f, "dayOfQuarter").unwrap_or(1);
434        let start_month = u32::try_from((quarter - 1) * 3 + 1).ok()?;
435        let start = calendar::ymd_to_days(year, start_month, 1)?;
436        let offset = i64::try_from(u64::try_from(day_of_quarter - 1).ok()?).ok()?;
437        Some(start + offset)
438    } else {
439        calendar::ymd_to_days(year, 1, 1)
440    }
441}
442
443/// Build a time of day from `hour`/`minute`/`second` plus additive subsecond
444/// fields (`millisecond` + `microsecond` + `nanosecond`). Absent components
445/// default to zero; the rendered precision follows the finest field present.
446fn resolve_time(f: &Fields) -> Option<TimeParts> {
447    let hour = u32::try_from(f_int(f, "hour").unwrap_or(0)).ok()?;
448    let minute = u32::try_from(f_int(f, "minute").unwrap_or(0)).ok()?;
449    let second = u32::try_from(f_int(f, "second").unwrap_or(0)).ok()?;
450    let milli = f_int(f, "millisecond").unwrap_or(0);
451    let micro = f_int(f, "microsecond").unwrap_or(0);
452    let nano = f_int(f, "nanosecond").unwrap_or(0);
453    let nanos = u32::try_from(milli * 1_000_000 + micro * 1_000 + nano).ok()?;
454
455    let precision = if f.contains_key("millisecond")
456        || f.contains_key("microsecond")
457        || f.contains_key("nanosecond")
458    {
459        TimePrecision::SubSecond
460    } else if f.contains_key("second") {
461        TimePrecision::Second
462    } else {
463        TimePrecision::Minute
464    };
465
466    NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)?;
467    Some(TimeParts {
468        hour,
469        minute,
470        second,
471        nanos,
472        precision,
473    })
474}
475
476/// Resolve a map's `timezone` field to an offset and optional named-zone label.
477/// Absent → UTC (`Z`). An offset string is parsed directly; a named zone is
478/// resolved against `date` (required) at that instant.
479fn resolve_timezone(
480    f: &Fields,
481    date: Option<i64>,
482    time: &TimeParts,
483) -> Option<(Offset, Option<String>)> {
484    match f_str(f, "timezone") {
485        None => Some((
486            Offset {
487                seconds: 0,
488                has_seconds: false,
489            },
490            None,
491        )),
492        Some(tz) if tz == "Z" || tz.starts_with('+') || tz.starts_with('-') => {
493            Some((parse_offset(tz)?, None))
494        }
495        Some(tz) => Some((resolve_zone_offset(date?, time, tz)?, Some(tz.to_string()))),
496    }
497}
498
499/// `time({…})` — a time of day with a zone offset (default `Z`).
500fn build_time_map(f: &Fields) -> Option<String> {
501    if !f.contains_key("hour") {
502        return None;
503    }
504    let time = resolve_time(f)?;
505    let (offset, _) = resolve_timezone(f, None, &time)?;
506    Some(format!("{}{}", format_time(&time), format_offset(&offset)))
507}
508
509/// `datetime({…})` — a date and time with a zone (default `Z`, offset, or named).
510fn build_date_time_map(f: &Fields) -> Option<String> {
511    use std::fmt::Write as _;
512    let date = resolve_date(f)?;
513    let time = resolve_time(f)?;
514    let (offset, zone) = resolve_timezone(f, Some(date), &time)?;
515    let mut out = format!(
516        "{}T{}{}",
517        format_date(date),
518        format_time(&time),
519        format_offset(&offset)
520    );
521    if let Some(z) = zone {
522        write!(out, "[{z}]").unwrap();
523    }
524    Some(out)
525}
526
527/// The Gregorian average month length in days (`MONTH_SECS / DAY_SECS =
528/// 30.436875`), the openCypher constant for carrying a fractional month into
529/// days (CIP2015-08-06). (#920)
530const AVG_DAYS_PER_MONTH: f64 = MONTH_SECS / DAY_SECS;
531
532/// openCypher "approximate" normalisation: carry a fractional month into days
533/// (× [`AVG_DAYS_PER_MONTH`]) and a fractional day into the sub-day seconds,
534/// truncating each level toward zero so whole months/days land in their own
535/// fields and only the genuine sub-day remainder stays in `seconds`. (#920)
536#[allow(
537    clippy::cast_possible_truncation,
538    reason = "duration component magnitudes stay within i64 for the corpus"
539)]
540fn approximate_duration(months_f: f64, days_f: f64, seconds_f: f64) -> DurationValue {
541    let months = months_f.trunc();
542    let days_f = days_f + (months_f - months) * AVG_DAYS_PER_MONTH;
543    let days = days_f.trunc();
544    let seconds_total = seconds_f + (days_f - days) * DAY_SECS;
545    // FLOOR-split the sub-day seconds into whole seconds + a NON-NEGATIVE
546    // nanos-of-second (the canonical form). Construction-scale values are exactly
547    // representable in f64, so this is lossless here.
548    let mut whole_secs = seconds_total.floor() as i64;
549    let mut sub_nanos = ((seconds_total - seconds_total.floor()) * 1e9).round() as i64;
550    // `.round()` of a fraction ≥ 0.9999999995 yields exactly 1e9; carry it into
551    // seconds so `nanos` stays in `[0, 1e9)` (#1011).
552    if sub_nanos >= 1_000_000_000 {
553        whole_secs += 1;
554        sub_nanos -= 1_000_000_000;
555    }
556    DurationValue {
557        months: months as i64,
558        days: days as i64,
559        seconds: whole_secs,
560        nanos: sub_nanos,
561    }
562}
563
564/// `duration({years, months, weeks, days, hours, minutes, seconds, …})`.
565/// Fractional larger units carry into smaller ones via [`approximate_duration`]
566/// (a fractional month → days, a fractional day → sub-day time), so whole days
567/// live in the `days` field and rendering needs no day-fold.
568fn build_duration_map(f: &Fields) -> Option<DurationValue> {
569    let (mut months_f, mut days_f, mut seconds_f) = (0.0, 0.0, 0.0);
570    let mut any = false;
571    for (key, factor_secs, into) in [
572        ("years", YEAR_SECS, Unit::Month(12)),
573        ("months", MONTH_SECS, Unit::Month(1)),
574        ("weeks", DAY_SECS * 7.0, Unit::Day(7)),
575        ("days", DAY_SECS, Unit::Day(1)),
576        ("hours", 3600.0, Unit::Sec),
577        ("minutes", 60.0, Unit::Sec),
578        ("seconds", 1.0, Unit::Sec),
579        ("milliseconds", 1e-3, Unit::Sec),
580        ("microseconds", 1e-6, Unit::Sec),
581        ("nanoseconds", 1e-9, Unit::Sec),
582    ] {
583        let Some(val) = f_num(f, key) else { continue };
584        any = true;
585        match into {
586            #[allow(clippy::cast_precision_loss, reason = "mult is 1 or 12")]
587            Unit::Month(mult) => months_f += val * mult as f64,
588            Unit::Day(mult) => days_f += val * f64::from(mult),
589            Unit::Sec => seconds_f += val * factor_secs,
590        }
591    }
592    any.then(|| approximate_duration(months_f, days_f, seconds_f))
593}
594
595/// How a duration map field folds into the (months, days, seconds) model.
596enum Unit {
597    Month(i64),
598    Day(i32),
599    Sec,
600}
601
602/// `datetime.fromepoch(seconds, nanoseconds)` — a UTC datetime from a Unix
603/// epoch offset.
604#[must_use]
605pub fn render_from_epoch(seconds: i64, nanos: i64) -> Option<String> {
606    let dt = chrono::DateTime::from_timestamp(seconds, u32::try_from(nanos).ok()?)?;
607    Some(format_epoch_datetime(dt.naive_utc()))
608}
609
610/// `datetime.fromepochmillis(milliseconds)` — a UTC datetime from Unix epoch
611/// milliseconds.
612#[must_use]
613pub fn render_from_epoch_millis(millis: i64) -> Option<String> {
614    let dt = chrono::DateTime::from_timestamp_millis(millis)?;
615    Some(format_epoch_datetime(dt.naive_utc()))
616}
617
618/// Render an epoch-derived UTC datetime as `YYYY-MM-DDTHH:MM:SS[.fff]Z`.
619fn format_epoch_datetime(naive: chrono::NaiveDateTime) -> String {
620    let nanos = naive.and_utc().timestamp_subsec_nanos();
621    let time = TimeParts {
622        hour: naive.hour(),
623        minute: naive.minute(),
624        second: naive.second(),
625        nanos,
626        precision: if nanos > 0 {
627            TimePrecision::SubSecond
628        } else {
629            TimePrecision::Second
630        },
631    };
632    // An epoch-derived datetime is always in chrono's range, so the days bridge
633    // never returns `None` here.
634    let days = date_to_epoch_days(naive.date()).unwrap_or(0);
635    format!("{}T{}Z", format_date(days), format_time(&time))
636}
637
638/// Canonical openCypher rendering of `date(<string>)`, or `None` if the string
639/// is not a recognised ISO date form.
640#[must_use]
641pub fn render_date(s: &str) -> Option<String> {
642    Some(format_date(parse_date_string(s.trim())?))
643}
644
645/// Canonical openCypher rendering of `localtime(<string>)` — a time of day with
646/// no zone — or `None`.
647#[must_use]
648pub fn render_local_time(s: &str) -> Option<String> {
649    let s = s.trim();
650    // A local time carries no offset; reject one rather than silently dropping it.
651    let (_, offset) = split_time_offset(s);
652    if offset.is_some() {
653        return None;
654    }
655    Some(format_time(&parse_time_of_day(s)?))
656}
657
658// ---------------------------------------------------------------------------
659// localtime typed value (Arrow Time64(Nanosecond), ADR 0009)
660// ---------------------------------------------------------------------------
661
662/// Nanoseconds-since-midnight for a [`TimeParts`] (its render precision is
663/// irrelevant to the numeric value).
664fn nanos_of_day(t: &TimeParts) -> i64 {
665    (i64::from(t.hour) * 3600 + i64::from(t.minute) * 60 + i64::from(t.second)) * 1_000_000_000
666        + i64::from(t.nanos)
667}
668
669/// The [`TimeParts`] for a `Time64(Nanosecond)` localtime, with render precision
670/// derived from the value (trailing-zero trim — ADR 0009): sub-second if any
671/// fraction, else second if non-zero seconds, else minute.
672fn time_parts_from_nanos(nanos: i64) -> TimeParts {
673    let nanos = nanos.rem_euclid(86_400_000_000_000);
674    let secs = nanos / 1_000_000_000;
675    let sub = u32::try_from(nanos % 1_000_000_000).unwrap_or(0);
676    let precision = if sub != 0 {
677        TimePrecision::SubSecond
678    } else if secs % 60 != 0 {
679        TimePrecision::Second
680    } else {
681        TimePrecision::Minute
682    };
683    TimeParts {
684        hour: u32::try_from(secs / 3600).unwrap_or(0),
685        minute: u32::try_from((secs % 3600) / 60).unwrap_or(0),
686        second: u32::try_from(secs % 60).unwrap_or(0),
687        nanos: sub,
688        precision,
689    }
690}
691
692/// Build a `localtime` (nanoseconds-of-day) from a literal field map. (ADR 0009)
693#[must_use]
694pub fn localtime_nanos_from_map(fields: &Fields) -> Option<i64> {
695    resolve_time(fields).as_ref().map(nanos_of_day)
696}
697
698/// Parse a strict `localtime(<string>)` to nanoseconds-of-day — a time of day
699/// with NO offset (an offset is rejected, matching [`render_local_time`]).
700#[must_use]
701pub fn localtime_nanos_from_str(s: &str) -> Option<i64> {
702    let s = s.trim();
703    let (_, offset) = split_time_offset(s);
704    if offset.is_some() {
705        return None;
706    }
707    Some(nanos_of_day(&parse_time_of_day(s)?))
708}
709
710/// Extract the time-of-day (nanoseconds-of-day) from ANY temporal string —
711/// `localtime`, `time` (offset dropped), or `localdatetime`/`datetime` (date
712/// prefix and any zone dropped) — for projecting a localtime out of another
713/// value (`localtime({time: other})`). (ADR 0009)
714#[must_use]
715pub fn time_of_day_nanos_any(s: &str) -> Option<i64> {
716    let s = s.trim();
717    let s = s.split_once('[').map_or(s, |(head, _)| head); // drop `[Zone]`
718    let s = s.split_once('T').map_or(s, |(_, time)| time); // drop `YYYY-…T` date prefix
719    let (time, _offset) = split_time_offset(s); // drop trailing offset
720    Some(nanos_of_day(&parse_time_of_day(time)?))
721}
722
723/// Canonical openCypher rendering of an Arrow `Time64(Nanosecond)` localtime
724/// (`HH:MM` / `HH:MM:SS` / `HH:MM:SS.fff…`, trailing-zero subseconds trimmed).
725#[must_use]
726pub fn render_localtime_nanos(nanos: i64) -> String {
727    format_time(&time_parts_from_nanos(nanos))
728}
729
730/// Component overrides for `localtime` projection (`localtime({time: base, …})`):
731/// a field left `None` keeps the base's value; the sub-second fields, if any are
732/// present, jointly replace the base fraction.
733#[derive(Default)]
734pub struct LocalTimeOverrides {
735    /// Hour of day (0–23).
736    pub hour: Option<i64>,
737    /// Minute (0–59).
738    pub minute: Option<i64>,
739    /// Second (0–59).
740    pub second: Option<i64>,
741    /// Milliseconds of the second (additive with micro/nano).
742    pub millisecond: Option<i64>,
743    /// Microseconds of the second (additive).
744    pub microsecond: Option<i64>,
745    /// Nanoseconds of the second (additive).
746    pub nanosecond: Option<i64>,
747}
748
749/// Project a base localtime through component overrides (select-semantics:
750/// replace the named components, keep the rest). `None` for an out-of-range
751/// component. (ADR 0009)
752#[must_use]
753pub fn project_localtime(base_nanos: i64, o: &LocalTimeOverrides) -> Option<i64> {
754    let base = time_parts_from_nanos(base_nanos);
755    let hour = match o.hour {
756        Some(h) => u32::try_from(h).ok()?,
757        None => base.hour,
758    };
759    let minute = match o.minute {
760        Some(m) => u32::try_from(m).ok()?,
761        None => base.minute,
762    };
763    let second = match o.second {
764        Some(s) => u32::try_from(s).ok()?,
765        None => base.second,
766    };
767    let nanos = if o.millisecond.is_some() || o.microsecond.is_some() || o.nanosecond.is_some() {
768        // The sub-second is three additive 3-digit groups (ms·1e6 + µs·1e3 + ns).
769        // Select-semantics: replace only the named groups, KEEP the others from
770        // the base — so `truncate('millisecond', …) {nanosecond: 2}` keeps the
771        // base's millisecond and yields `.645000002`, not `.000000002` (#920).
772        let base_milli = i64::from(base.nanos) / 1_000_000;
773        let base_micro = (i64::from(base.nanos) / 1_000) % 1_000;
774        let base_nano = i64::from(base.nanos) % 1_000;
775        let sub = o.millisecond.unwrap_or(base_milli) * 1_000_000
776            + o.microsecond.unwrap_or(base_micro) * 1_000
777            + o.nanosecond.unwrap_or(base_nano);
778        u32::try_from(sub).ok()?
779    } else {
780        base.nanos
781    };
782    let parts = TimeParts {
783        hour,
784        minute,
785        second,
786        nanos,
787        precision: TimePrecision::Minute, // unused by `nanos_of_day`
788    };
789    NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)?; // validate
790    Some(nanos_of_day(&parts))
791}
792
793// ---------------------------------------------------------------------------
794// localdatetime typed value (Arrow Struct{date: Date32, time: Time64(ns)}, ADR
795// 0009). A two-field value (not a single nanosecond timestamp) so it spans the
796// full openCypher year range (1…9999+) at nanosecond precision — an `i64`
797// epoch-nanosecond representation overflows around year 2262. The `date`-first
798// field order makes DataFusion's row-format sort chronological.
799// ---------------------------------------------------------------------------
800
801/// Build a `localdatetime` (date + nanoseconds-of-day) from a literal field
802/// map. (ADR 0009)
803#[must_use]
804pub fn localdatetime_parts_from_map(fields: &Fields) -> Option<(i64, i64)> {
805    let date = resolve_date(fields)?;
806    let time = resolve_time(fields)?;
807    Some((date, nanos_of_day(&time)))
808}
809
810/// Parse a `localdatetime(<string>)` (`YYYY-…T HH:MM…`) to (date-days, nanoseconds-
811/// of-day) — a date and time with NO offset (an offset is rejected, matching
812/// [`render_local_date_time`]).
813#[must_use]
814pub fn localdatetime_parts_from_str(s: &str) -> Option<(i64, i64)> {
815    let s = s.trim();
816    // A date-only string is midnight (`localdatetime('2015-07-21')` →
817    // `2015-07-21T00:00`, Temporal10 [10]); otherwise split date and time on `T`.
818    let Some((date_str, time_str)) = s.split_once('T') else {
819        return Some((parse_date_string(s)?, 0));
820    };
821    let date = parse_date_string(date_str)?;
822    let (_, offset) = split_time_offset(time_str);
823    if offset.is_some() {
824        return None;
825    }
826    Some((date, nanos_of_day(&parse_time_of_day(time_str)?)))
827}
828
829/// Canonical openCypher rendering of a `localdatetime` (date-days + nanoseconds-
830/// of-day): `YYYY-MM-DDTHH:MM[:SS[.fff…]]`, time precision derived from the value.
831#[must_use]
832pub fn render_localdatetime(date: i64, nanos_of_day: i64) -> String {
833    format!(
834        "{}T{}",
835        format_date(date),
836        render_localtime_nanos(nanos_of_day)
837    )
838}
839
840/// Canonical openCypher rendering of `time(<string>)` — a time of day with a
841/// zone offset — or `None`. The offset is required.
842#[must_use]
843pub fn render_time(s: &str) -> Option<String> {
844    let (time, offset) = split_time_offset(s.trim());
845    let time = parse_time_of_day(time)?;
846    let offset = parse_offset(offset?)?;
847    Some(format!("{}{}", format_time(&time), format_offset(&offset)))
848}
849
850// ---------------------------------------------------------------------------
851// time typed value (Arrow Struct{time: Time64(ns), offset: Int32 seconds}, ADR
852// 0009). A time of day WITH a zone offset. Stored as nanoseconds-of-day plus the
853// offset in seconds (always whole minutes for `time()`).
854// ---------------------------------------------------------------------------
855
856/// Build a `time` (nanoseconds-of-day, offset-seconds) from a literal field map
857/// (`time({hour, …, timezone})`; default zone is UTC). (ADR 0009)
858#[must_use]
859pub fn time_value_from_map(fields: &Fields) -> Option<(i64, i32)> {
860    if !fields.contains_key("hour") {
861        return None;
862    }
863    let time = resolve_time(fields)?;
864    let (offset, _) = resolve_timezone(fields, None, &time)?;
865    Some((nanos_of_day(&time), offset.seconds))
866}
867
868/// Parse a `time(<string>)` to (nanoseconds-of-day, offset-seconds). The offset
869/// is REQUIRED (a `time` carries a zone). (ADR 0009)
870#[must_use]
871pub fn time_value_from_str(s: &str) -> Option<(i64, i32)> {
872    let (time, offset) = split_time_offset(s.trim());
873    // A bare `time('14:30')` (no offset) defaults to UTC (`Z`, offset 0) — the
874    // openCypher default zone; an explicit offset is parsed as given. (#920)
875    let offset_secs = match offset {
876        Some(o) => parse_offset(o)?.seconds,
877        None => 0,
878    };
879    Some((nanos_of_day(&parse_time_of_day(time)?), offset_secs))
880}
881
882/// Extract a time-of-day and (optional) offset from ANY temporal string for
883/// `time` projection: `localtime`/`localdatetime` → offset `None` (the new zone
884/// is *attached*); `time`/`datetime` → `Some(offset)` (a new zone *shifts* the
885/// instant). Drops a named-zone suffix and a date prefix. (ADR 0009)
886#[must_use]
887pub fn time_of_day_with_offset(s: &str) -> Option<(i64, Option<i32>)> {
888    let s = s.trim();
889    let s = s.split_once('[').map_or(s, |(head, _)| head); // drop `[Zone]`
890    let s = s.split_once('T').map_or(s, |(_, t)| t); // drop date prefix
891    let (time, offset) = split_time_offset(s);
892    let nanos = nanos_of_day(&parse_time_of_day(time)?);
893    let offset = match offset {
894        Some(o) => Some(parse_offset(o)?.seconds),
895        None => None,
896    };
897    Some((nanos, offset))
898}
899
900/// Apply a `time` projection's zone semantics. With a new offset: if the base
901/// already had one (`time`/`datetime`), the wall-clock time SHIFTS to preserve
902/// the instant; otherwise (`localtime`/`localdatetime`) the offset is simply
903/// ATTACHED. Without a new offset, the base offset (or UTC) is kept. (ADR 0009)
904#[must_use]
905pub fn project_time(
906    base_nanos: i64,
907    base_offset: Option<i32>,
908    new_offset: Option<i32>,
909) -> (i64, i32) {
910    match (new_offset, base_offset) {
911        (Some(new), Some(old)) => {
912            let shifted =
913                (base_nanos + i64::from(new - old) * 1_000_000_000).rem_euclid(86_400_000_000_000);
914            (shifted, new)
915        }
916        (Some(new), None) => (base_nanos, new),
917        (None, Some(old)) => (base_nanos, old),
918        (None, None) => (base_nanos, 0),
919    }
920}
921
922/// Canonical openCypher rendering of a `time` value (nanoseconds-of-day +
923/// offset-seconds): time-of-day + offset (`Z` for UTC). (ADR 0009)
924#[must_use]
925pub fn render_time_value(nanos: i64, offset_seconds: i32) -> String {
926    let offset = Offset {
927        seconds: offset_seconds,
928        has_seconds: offset_seconds % 60 != 0,
929    };
930    format!(
931        "{}{}",
932        render_localtime_nanos(nanos),
933        format_offset(&offset)
934    )
935}
936
937// ---------------------------------------------------------------------------
938// datetime typed value (Arrow Struct{date: Date32, time: Time64(ns), offset:
939// Int32, zone: Utf8?}, ADR 0009). A date + time-of-day + zone, where the zone
940// is the resolved numeric offset plus an optional named-IANA-zone label (which
941// renders as the trailing `[Zone]`).
942// ---------------------------------------------------------------------------
943
944/// The four-tuple representation of a `datetime`: date-days, nanoseconds-of-day,
945/// resolved offset-seconds, and optional named-zone label.
946type DateTimeParts = (i64, i64, i32, Option<String>);
947
948/// Build a `datetime` from a literal field map (`datetime({…, timezone})`).
949#[must_use]
950pub fn datetime_value_from_map(f: &Fields) -> Option<DateTimeParts> {
951    let date = resolve_date(f)?;
952    let time = resolve_time(f)?;
953    let (offset, zone) = resolve_timezone(f, Some(date), &time)?;
954    Some((date, nanos_of_day(&time), offset.seconds, zone))
955}
956
957/// Parse a `datetime(<string>)` — offset form (`…T…+01:00`) or named-zone form
958/// (`…T…[Europe/London]`, offset resolved at that instant). (ADR 0009)
959#[must_use]
960pub fn datetime_value_from_str(s: &str) -> Option<DateTimeParts> {
961    let s = s.trim();
962    if let Some(bracket) = s.find('[') {
963        let zone = s.get(bracket + 1..)?.strip_suffix(']')?;
964        let (date_str, time_offset) = s.get(..bracket)?.split_once('T')?;
965        let date = parse_date_string(date_str)?;
966        let (time_str, offset_str) = split_time_offset(time_offset);
967        let time = parse_time_of_day(time_str)?;
968        let offset = match offset_str {
969            Some(o) => parse_offset(o)?,
970            None => resolve_zone_offset(date, &time, zone)?,
971        };
972        return Some((
973            date,
974            nanos_of_day(&time),
975            offset.seconds,
976            Some(zone.to_string()),
977        ));
978    }
979    let (date_str, time_offset) = s.split_once('T')?;
980    let date = parse_date_string(date_str)?;
981    let (time_str, offset_str) = split_time_offset(time_offset);
982    let time = parse_time_of_day(time_str)?;
983    Some((
984        date,
985        nanos_of_day(&time),
986        parse_offset(offset_str?)?.seconds,
987        None,
988    ))
989}
990
991/// Extract `(nanoseconds-of-day, optional offset-seconds, optional zone label)`
992/// from ANY temporal string — `localtime`/`localdatetime` → offset/zone `None`;
993/// `time` → `Some(offset)`, no zone; `datetime` → offset + optional zone. Used
994/// when projecting a `datetime` from another value. (ADR 0009)
995#[must_use]
996pub fn time_offset_zone(s: &str) -> Option<(i64, Option<i32>, Option<String>)> {
997    let s = s.trim();
998    let (head, zone) = match s.split_once('[') {
999        Some((h, z)) => (h, z.strip_suffix(']').map(str::to_string)),
1000        None => (s, None),
1001    };
1002    let body = head.split_once('T').map_or(head, |(_, t)| t);
1003    let (time, offset) = split_time_offset(body);
1004    let nanos = nanos_of_day(&parse_time_of_day(time)?);
1005    let offset = match offset {
1006        Some(o) => Some(parse_offset(o)?.seconds),
1007        None => None,
1008    };
1009    Some((nanos, offset, zone))
1010}
1011
1012/// Canonical openCypher rendering of a `datetime`: `YYYY-MM-DDTHH:MM…±HH:MM`
1013/// (`Z` for UTC), plus a trailing `[Zone]` for a named zone. (ADR 0009)
1014#[must_use]
1015pub fn render_datetime_value(
1016    date: i64,
1017    nanos: i64,
1018    offset_seconds: i32,
1019    zone: Option<&str>,
1020) -> String {
1021    use std::fmt::Write as _;
1022    let offset = Offset {
1023        seconds: offset_seconds,
1024        has_seconds: offset_seconds % 60 != 0,
1025    };
1026    let mut out = format!(
1027        "{}T{}{}",
1028        format_date(date),
1029        render_localtime_nanos(nanos),
1030        format_offset(&offset)
1031    );
1032    if let Some(z) = zone {
1033        write!(out, "[{z}]").unwrap();
1034    }
1035    out
1036}
1037
1038/// Project a `datetime` (`Temporal3` [8]-[11]): apply the source's zone to the
1039/// local `(date, nanos)` after component overrides. With a new `timezone`: a
1040/// numeric offset or named zone SHIFTS the instant when the source already had
1041/// an offset (`time`/`datetime`), else ATTACHES (interpreting the local time as
1042/// being in that zone). Without one, the source offset/zone (or UTC) is kept.
1043/// (ADR 0009)
1044#[must_use]
1045pub fn project_datetime(
1046    date: i64,
1047    nanos: i64,
1048    src_offset: Option<i32>,
1049    src_zone: Option<&str>,
1050    new_tz: Option<&str>,
1051) -> Option<DateTimeParts> {
1052    use chrono::offset::{Offset as _, TimeZone};
1053    // Projection is never on the extreme-year path (Temporal3 operands are
1054    // ordinary dates), so the chrono bridge holds. (#1011)
1055    let base = epoch_days_to_date(date)?;
1056    let local = base.and_time(nanos_to_naive_time(nanos)?);
1057    // For a NAMED-zone source, `src_offset` was resolved at the source instant;
1058    // component overrides may have moved the local wall-clock onto a date with a
1059    // different DST offset, so re-resolve the source offset at the new local time
1060    // before applying zone semantics (#1008, Temporal3 [10]).
1061    let src_offset = match (src_offset, src_zone) {
1062        (Some(o), Some(z)) => Some(
1063            resolve_zone_offset(date, &time_parts_from_nanos(nanos), z)
1064                .map_or(o, |off| off.seconds),
1065        ),
1066        (o, _) => o,
1067    };
1068    let Some(tz) = new_tz else {
1069        // No new zone: keep the source's offset (and named zone), else UTC.
1070        return Some(match src_offset {
1071            Some(o) => (date, nanos, o, src_zone.map(str::to_string)),
1072            None => (date, nanos, 0, None),
1073        });
1074    };
1075    // A numeric offset designator?
1076    if let Some(new_off) = parse_offset_seconds(tz) {
1077        return Some(match src_offset {
1078            // Shift to preserve the instant.
1079            Some(src) => {
1080                let shifted = local + chrono::Duration::seconds(i64::from(new_off - src));
1081                (
1082                    date_to_epoch_days(shifted.date())?,
1083                    naive_time_nanos(shifted.time()),
1084                    new_off,
1085                    None,
1086                )
1087            }
1088            // Attach (the local time is already in the target offset).
1089            None => (date, nanos, new_off, None),
1090        });
1091    }
1092    // A named IANA zone.
1093    let zone: chrono_tz::Tz = tz.parse().ok()?;
1094    if let Some(src) = src_offset {
1095        // Shift: convert the instant to the named zone's local time.
1096        let utc = local - chrono::Duration::seconds(i64::from(src));
1097        let zoned = zone.from_utc_datetime(&utc);
1098        let off = zoned.offset().fix().local_minus_utc();
1099        let l = zoned.naive_local();
1100        Some((
1101            date_to_epoch_days(l.date())?,
1102            naive_time_nanos(l.time()),
1103            off,
1104            Some(tz.to_string()),
1105        ))
1106    } else {
1107        // Attach: interpret the local time as being in the named zone.
1108        let off = resolve_zone_offset(date, &time_parts_from_nanos(nanos), tz)?;
1109        Some((date, nanos, off.seconds, Some(tz.to_string())))
1110    }
1111}
1112
1113/// A [`NaiveTime`] from nanoseconds-of-day.
1114fn nanos_to_naive_time(nanos: i64) -> Option<NaiveTime> {
1115    let tp = time_parts_from_nanos(nanos);
1116    NaiveTime::from_hms_nano_opt(tp.hour, tp.minute, tp.second, tp.nanos)
1117}
1118
1119/// Nanoseconds-of-day for a [`NaiveTime`].
1120fn naive_time_nanos(t: NaiveTime) -> i64 {
1121    i64::from(t.num_seconds_from_midnight()) * 1_000_000_000 + i64::from(t.nanosecond())
1122}
1123
1124/// Canonical openCypher rendering of `localdatetime(<string>)` — a date and
1125/// time with no zone — or `None`.
1126#[must_use]
1127pub fn render_local_date_time(s: &str) -> Option<String> {
1128    let (date, time) = s.trim().split_once('T')?;
1129    let date = parse_date_string(date)?;
1130    let (_, offset) = split_time_offset(time);
1131    if offset.is_some() {
1132        return None;
1133    }
1134    let time = parse_time_of_day(time)?;
1135    Some(format!("{}T{}", format_date(date), format_time(&time)))
1136}
1137
1138/// Canonical openCypher rendering of `datetime(<string>)` — a date and time
1139/// with a zone — or `None`. Handles both the offset form
1140/// (`…T21:40:32+01:00`) and the named-zone form (`…T21:40:32[Europe/London]`,
1141/// whose offset is resolved from the IANA tz database at that instant,
1142/// including historical LMT offsets like `+00:53:28`).
1143#[must_use]
1144pub fn render_date_time(s: &str) -> Option<String> {
1145    let s = s.trim();
1146    if let Some(bracket) = s.find('[') {
1147        return render_date_time_named(s, bracket);
1148    }
1149    let (date, time_offset) = s.split_once('T')?;
1150    let date = parse_date_string(date)?;
1151    let (time, offset) = split_time_offset(time_offset);
1152    let time = parse_time_of_day(time)?;
1153    let offset = parse_offset(offset?)?;
1154    Some(format!(
1155        "{}T{}{}",
1156        format_date(date),
1157        format_time(&time),
1158        format_offset(&offset)
1159    ))
1160}
1161
1162/// Render the named-zone `datetime` form `…[Zone]`. An explicit offset before
1163/// the bracket is echoed (reformatted); otherwise the offset is resolved from
1164/// the named zone at that local datetime.
1165fn render_date_time_named(s: &str, bracket: usize) -> Option<String> {
1166    let zone = s.get(bracket + 1..)?.strip_suffix(']')?;
1167    let head = s.get(..bracket)?;
1168    let (date_str, time_offset) = head.split_once('T')?;
1169    let date = parse_date_string(date_str)?;
1170    let (time_str, offset_str) = split_time_offset(time_offset);
1171    let time = parse_time_of_day(time_str)?;
1172
1173    let offset = match offset_str {
1174        Some(o) => parse_offset(o)?,
1175        None => resolve_zone_offset(date, &time, zone)?,
1176    };
1177    Some(format!(
1178        "{}T{}{}[{}]",
1179        format_date(date),
1180        format_time(&time),
1181        format_offset(&offset),
1182        zone
1183    ))
1184}
1185
1186/// Resolve the UTC offset a named IANA zone was at on a given local datetime
1187/// (e.g. `Europe/London` in July → `+01:00`; `Europe/Stockholm` in 1818 →
1188/// the `+00:53:28` LMT). Returns `None` for an unknown zone or a local time
1189/// that doesn't exist in it.
1190fn resolve_zone_offset(days: i64, time: &TimeParts, zone: &str) -> Option<Offset> {
1191    use chrono::offset::{LocalResult, Offset as _, TimeZone};
1192    let tz: chrono_tz::Tz = zone.parse().ok()?;
1193    // chrono_tz needs a `NaiveDate`; a named zone only resolves for in-range years
1194    // (the corpus never pairs a named zone with an extreme year — those are
1195    // offset/unzoned). (#1011)
1196    let date = epoch_days_to_date(days)?;
1197    let naive = date.and_time(NaiveTime::from_hms_nano_opt(
1198        time.hour,
1199        time.minute,
1200        time.second,
1201        time.nanos,
1202    )?);
1203    let resolved = match tz.from_local_datetime(&naive) {
1204        LocalResult::Single(dt) | LocalResult::Ambiguous(dt, _) => dt,
1205        LocalResult::None => return None,
1206    };
1207    Some(Offset {
1208        seconds: resolved.offset().fix().local_minus_utc(),
1209        has_seconds: true,
1210    })
1211}
1212
1213/// The finest unit named in a parsed time-of-day, which fixes how it renders:
1214/// always `HH:MM`, plus `:SS` for [`Second`](TimePrecision::Second) and
1215/// `.fff` for [`SubSecond`](TimePrecision::SubSecond). Two equal time *values*
1216/// can render differently (`21:40` vs `21:40:00`), so precision is tracked from
1217/// the input rather than derived from the value.
1218#[derive(Clone, Copy, PartialEq, Eq)]
1219enum TimePrecision {
1220    Minute,
1221    Second,
1222    SubSecond,
1223}
1224
1225/// A parsed time of day plus the precision at which to render it.
1226struct TimeParts {
1227    hour: u32,
1228    minute: u32,
1229    second: u32,
1230    nanos: u32,
1231    precision: TimePrecision,
1232}
1233
1234/// A parsed UTC offset, in signed seconds, plus whether it carries a seconds
1235/// component (only the historical-LMT named-zone forms do).
1236struct Offset {
1237    seconds: i32,
1238    has_seconds: bool,
1239}
1240
1241/// Parse a time of day in any ISO form the TCK uses — extended (`21:40:32.142`)
1242/// or basic (`214032.142`), down to hour-only (`21`). Returns `None` for an
1243/// out-of-range or malformed time.
1244fn parse_time_of_day(s: &str) -> Option<TimeParts> {
1245    let (main, frac) = match s.split_once('.') {
1246        Some((m, f)) => (m, Some(f)),
1247        None => (s, None),
1248    };
1249
1250    let (hour, minute, second, base) = if main.contains(':') {
1251        let parts: Vec<&str> = main.split(':').collect();
1252        match parts.as_slice() {
1253            [h, m] => (h.parse().ok()?, m.parse().ok()?, 0, TimePrecision::Minute),
1254            [h, m, sec] => (
1255                h.parse().ok()?,
1256                m.parse().ok()?,
1257                sec.parse().ok()?,
1258                TimePrecision::Second,
1259            ),
1260            _ => return None,
1261        }
1262    } else {
1263        if !main.bytes().all(|b| b.is_ascii_digit()) {
1264            return None;
1265        }
1266        match main.len() {
1267            2 => (main.parse().ok()?, 0, 0, TimePrecision::Minute),
1268            4 => (
1269                main[..2].parse().ok()?,
1270                main[2..4].parse().ok()?,
1271                0,
1272                TimePrecision::Minute,
1273            ),
1274            6 => (
1275                main[..2].parse().ok()?,
1276                main[2..4].parse().ok()?,
1277                main[4..6].parse().ok()?,
1278                TimePrecision::Second,
1279            ),
1280            _ => return None,
1281        }
1282    };
1283
1284    let (nanos, precision) = match frac {
1285        // Nanosecond is the finest representable precision; reject (rather than
1286        // silently truncate) a sub-nanosecond fraction of more than 9 digits.
1287        Some(f) if !f.is_empty() && f.len() <= 9 && f.bytes().all(|b| b.is_ascii_digit()) => {
1288            let mut digits = f.to_string();
1289            while digits.len() < 9 {
1290                digits.push('0');
1291            }
1292            (digits.parse().ok()?, TimePrecision::SubSecond)
1293        }
1294        Some(_) => return None,
1295        None => (0, base),
1296    };
1297
1298    // Validate the (h, m, s, ns) tuple — rejects 25:00, 21:60, etc.
1299    NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)?;
1300    Some(TimeParts {
1301        hour,
1302        minute,
1303        second,
1304        nanos,
1305        precision,
1306    })
1307}
1308
1309/// Render a [`TimeParts`] at its tracked precision (`HH:MM`, `HH:MM:SS`, or
1310/// `HH:MM:SS.fff` with trailing-zero subseconds trimmed).
1311fn format_time(t: &TimeParts) -> String {
1312    match t.precision {
1313        TimePrecision::Minute => format!("{:02}:{:02}", t.hour, t.minute),
1314        TimePrecision::Second => format!("{:02}:{:02}:{:02}", t.hour, t.minute, t.second),
1315        TimePrecision::SubSecond => {
1316            let frac = format!("{:09}", t.nanos);
1317            let frac = frac.trim_end_matches('0');
1318            if frac.is_empty() {
1319                format!("{:02}:{:02}:{:02}", t.hour, t.minute, t.second)
1320            } else {
1321                format!("{:02}:{:02}:{:02}.{frac}", t.hour, t.minute, t.second)
1322            }
1323        }
1324    }
1325}
1326
1327/// Split a `time[offset]` string into the time-of-day and the (optional) offset
1328/// designator. The offset begins at the first `Z`, `+`, or `-` after position 0
1329/// — none of which occur within a local time of day.
1330fn split_time_offset(s: &str) -> (&str, Option<&str>) {
1331    for (i, c) in s.char_indices() {
1332        if i > 0 && matches!(c, 'Z' | '+' | '-') {
1333            return (&s[..i], Some(&s[i..]));
1334        }
1335    }
1336    (s, None)
1337}
1338
1339/// Parse a UTC offset designator (`Z`, `±HH`, `±HH:MM`, …) to signed seconds —
1340/// the public entry point for a `time`/`datetime` `timezone` override. (ADR 0009)
1341#[must_use]
1342pub fn parse_offset_seconds(s: &str) -> Option<i32> {
1343    parse_offset(s).map(|o| o.seconds)
1344}
1345
1346/// Parse a UTC offset designator: `Z`, `±HH`, `±HHMM`, `±HH:MM`, or `±HH:MM:SS`.
1347fn parse_offset(s: &str) -> Option<Offset> {
1348    if s == "Z" {
1349        return Some(Offset {
1350            seconds: 0,
1351            has_seconds: false,
1352        });
1353    }
1354    let (sign, rest) = match s.strip_prefix('+') {
1355        Some(rest) => (1, rest),
1356        None => (-1, s.strip_prefix('-')?),
1357    };
1358    let (hours, minutes, secs, has_seconds): (i32, i32, i32, bool) = if rest.contains(':') {
1359        let parts: Vec<&str> = rest.split(':').collect();
1360        // Each component must be unsigned digits — reject e.g. `+01:-30`.
1361        if !parts
1362            .iter()
1363            .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
1364        {
1365            return None;
1366        }
1367        match parts.as_slice() {
1368            [h, m] => (h.parse().ok()?, m.parse().ok()?, 0, false),
1369            [h, m, sec] => (h.parse().ok()?, m.parse().ok()?, sec.parse().ok()?, true),
1370            _ => return None,
1371        }
1372    } else {
1373        if !rest.bytes().all(|b| b.is_ascii_digit()) {
1374            return None;
1375        }
1376        match rest.len() {
1377            2 => (rest.parse().ok()?, 0, 0, false),
1378            4 => (rest[..2].parse().ok()?, rest[2..4].parse().ok()?, 0, false),
1379            6 => (
1380                rest[..2].parse().ok()?,
1381                rest[2..4].parse().ok()?,
1382                rest[4..6].parse().ok()?,
1383                true,
1384            ),
1385            _ => return None,
1386        }
1387    };
1388    if hours >= 24 || minutes >= 60 || secs >= 60 {
1389        return None;
1390    }
1391    Some(Offset {
1392        seconds: sign * (hours * 3600 + minutes * 60 + secs),
1393        has_seconds,
1394    })
1395}
1396
1397/// Render an [`Offset`]: a zero offset is `Z`; otherwise `±HH:MM` (plus `:SS`
1398/// for the historical second-bearing forms).
1399fn format_offset(o: &Offset) -> String {
1400    if o.seconds == 0 {
1401        return "Z".to_string();
1402    }
1403    let sign = if o.seconds < 0 { '-' } else { '+' };
1404    let abs = o.seconds.abs();
1405    let (hours, minutes, secs) = (abs / 3600, (abs % 3600) / 60, abs % 60);
1406    if o.has_seconds && secs != 0 {
1407        format!("{sign}{hours:02}:{minutes:02}:{secs:02}")
1408    } else {
1409        format!("{sign}{hours:02}:{minutes:02}")
1410    }
1411}
1412
1413/// Seconds in a day.
1414const DAY_SECS: f64 = 86_400.0;
1415/// Seconds in an average Gregorian month (`365.2425 / 12` days) — openCypher's
1416/// definition, used when a fractional month/year spills into the seconds field.
1417const MONTH_SECS: f64 = 2_629_746.0;
1418/// Seconds in an average Gregorian year (`12 * MONTH_SECS`).
1419const YEAR_SECS: f64 = 31_556_952.0;
1420
1421/// A Cypher duration: months and days are kept distinct (a month is not a fixed
1422/// number of days), with everything finer than a day carried in `nanos`
1423/// (integer nanoseconds — exact even for very large sub-day spans, unlike an
1424/// `f64` seconds count whose mantissa drops sub-second precision past ~1e15ns).
1425/// A typed Cypher duration (ADR 0009 / #1011): signed `months` / `days` kept
1426/// distinct (a month is not a fixed number of days), and the sub-day time split
1427/// into whole `seconds` plus `nanos`-of-second. Splitting seconds from nanos lets
1428/// a billion-year `duration.inSeconds` (~6.3e16 s) fit `i64`, where a single
1429/// total-nanos field would overflow (~6.3e25 ns); `months: i64` likewise holds
1430/// the ~24e9-month spans `duration.between` can produce. `nanos` is in
1431/// `(-1e9, 1e9)` and shares the sign of `seconds` (truncating split).
1432#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1433pub struct DurationValue {
1434    /// Signed whole months.
1435    pub months: i64,
1436    /// Signed whole days.
1437    pub days: i64,
1438    /// Signed whole sub-day seconds (carries the sub-day sign).
1439    pub seconds: i64,
1440    /// Nanoseconds-of-second, always `[0, 1e9)` (the Neo4j/openCypher canonical
1441    /// form — `seconds` carries the sign; `d.nanosecondsOfSecond` is non-negative).
1442    pub nanos: i64,
1443}
1444
1445impl DurationValue {
1446    /// Build from `months`/`days` plus a total sub-day nanoseconds count,
1447    /// FLOOR-splitting it into `seconds` + non-negative `nanos`-of-second (the
1448    /// canonical form: `seconds` carries the sign, `nanos` is `[0, 1e9)`). Used
1449    /// where a sub-day span is already a bounded total-nanos value (construction,
1450    /// time-only `between`, native Arrow durations).
1451    #[must_use]
1452    pub fn from_total_nanos(months: i64, days: i64, total_nanos: i64) -> Self {
1453        Self {
1454            months,
1455            days,
1456            seconds: total_nanos.div_euclid(1_000_000_000),
1457            nanos: total_nanos.rem_euclid(1_000_000_000),
1458        }
1459    }
1460}
1461
1462/// Canonical openCypher rendering of `duration(<string>)`, or `None` if the
1463/// string is not a recognised ISO-8601 duration. Handles the designator form
1464/// (`P14DT16H12M`, with decimal components like `P0.75M`/`P2.5W` spilling into
1465/// smaller units) and the alternative date-time form
1466/// (`P2012-02-02T14:37:21.545`).
1467#[must_use]
1468pub fn render_duration(s: &str) -> Option<String> {
1469    Some(format_duration(&parse_duration(s.trim())?))
1470}
1471
1472/// Parse `duration(<string>)` to a typed [`DurationValue`]. (#920/#1011)
1473#[must_use]
1474pub fn duration_value_from_str(s: &str) -> Option<DurationValue> {
1475    parse_duration(s.trim())
1476}
1477
1478/// Build a `duration({…})` [`DurationValue`] from a literal map. (#920/#1011)
1479#[must_use]
1480pub fn duration_value_from_map(fields: &Fields) -> Option<DurationValue> {
1481    build_duration_map(fields)
1482}
1483
1484/// Canonical openCypher rendering of a typed [`DurationValue`] (reuses the same
1485/// designator formatter as the string path; the integer `seconds`/`nanos` are
1486/// preserved exactly, so a very large span renders without f64 precision loss). (#920)
1487#[must_use]
1488pub fn render_duration_value(dur: &DurationValue) -> String {
1489    format_duration(dur)
1490}
1491
1492/// A duration component accessor (`d.years`, `d.monthsOfQuarter`,
1493/// `d.secondsOfMinute`, `d.nanosecondsOfSecond`, …) over a typed duration. The
1494/// `*Of*` forms give the component within the next-larger unit; the plain forms
1495/// give the total in that unit (truncated toward zero). `None` for an unknown
1496/// name. (#920)
1497#[must_use]
1498pub fn duration_component(dur: &DurationValue, name: &str) -> Option<i64> {
1499    let (months, days, secs) = (dur.months, dur.days, dur.seconds);
1500    let sub = dur.nanos; // nanoseconds-of-second, same sign as `secs`
1501    // Cumulative sub-second totals in i128, then narrow: a >~292-year `seconds`
1502    // (which `duration.inSeconds` over an extreme span produces) makes
1503    // `seconds * 1e9` overflow i64 — that would panic in debug and silently wrap
1504    // in release, re-introducing the very overflow the seconds/nanos split
1505    // avoids. For a span whose total-nanoseconds genuinely exceeds i64 the
1506    // accessor is unrepresentable, so return NULL rather than a wrong number.
1507    // (#1011)
1508    let total_nanos = || i128::from(secs) * 1_000_000_000 + i128::from(sub);
1509    let v = match name {
1510        "years" => months / 12,
1511        "quarters" => months / 3,
1512        "months" => months,
1513        "monthsOfYear" => months % 12,
1514        "monthsOfQuarter" => months % 3,
1515        "quartersOfYear" => (months / 3) % 4,
1516        "weeks" => days / 7,
1517        "days" => days,
1518        "daysOfWeek" => days % 7,
1519        "hours" => secs / 3600,
1520        "minutes" => secs / 60,
1521        "seconds" => secs,
1522        "minutesOfHour" => (secs / 60) % 60,
1523        "secondsOfMinute" => secs % 60,
1524        "milliseconds" => return i64::try_from(total_nanos() / 1_000_000).ok(),
1525        "microseconds" => return i64::try_from(total_nanos() / 1_000).ok(),
1526        "nanoseconds" => return i64::try_from(total_nanos()).ok(),
1527        "millisecondsOfSecond" => sub / 1_000_000,
1528        "microsecondsOfSecond" => sub / 1_000,
1529        "nanosecondsOfSecond" => sub,
1530        _ => return None,
1531    };
1532    Some(v)
1533}
1534
1535/// Whether `name` is a duration component accessor (see [`duration_component`]).
1536#[must_use]
1537pub fn is_duration_accessor(name: &str) -> bool {
1538    duration_component(
1539        &DurationValue {
1540            months: 0,
1541            days: 0,
1542            seconds: 0,
1543            nanos: 0,
1544        },
1545        name,
1546    )
1547    .is_some()
1548}
1549
1550/// Which `duration.between`-family function: the full split, or a single-unit total.
1551#[derive(Clone, Copy)]
1552pub enum BetweenMode {
1553    /// `duration.between` — months + days + nanos, calendar-aware.
1554    Between,
1555    /// `duration.inMonths` — whole months only.
1556    Months,
1557    /// `duration.inDays` — whole days only.
1558    Days,
1559    /// `duration.inSeconds` — total seconds (as nanos) only.
1560    Seconds,
1561}
1562
1563/// A reduced temporal operand for [`duration_between`]: an optional date (`None`
1564/// for a time-only `localtime`/`time`), a time-of-day in nanoseconds, an optional
1565/// zone offset in seconds (`None` for an unzoned value), and an optional named
1566/// IANA zone (`Some` only for a `datetime` constructed with a zone name — needed
1567/// to re-resolve the offset across a DST transition). (#920/#1007)
1568pub type BetweenOperand = (Option<i64>, i64, Option<i32>, Option<String>);
1569
1570const DAY_NANOS: i64 = 86_400_000_000_000;
1571
1572/// A wall-clock instant for duration arithmetic: i64 days-since-epoch plus
1573/// nanoseconds-of-day in `[0, DAY_NANOS)`. Replaces `NaiveDateTime` so a
1574/// billion-year span (#1011) stays representable; being normalised, it orders
1575/// lexicographically as a tuple.
1576type Instant = (i64, i64);
1577
1578/// Normalise `(days, nanos)` — where `nanos` may fall outside `[0, DAY_NANOS)`
1579/// after an offset shift — into a canonical [`Instant`], carrying the overflow
1580/// into days. (Replaces the old `datetime_from`.)
1581fn instant_from(days: i64, nanos: i64) -> Instant {
1582    (
1583        days + nanos.div_euclid(DAY_NANOS),
1584        nanos.rem_euclid(DAY_NANOS),
1585    )
1586}
1587
1588/// Add a signed number of calendar months to an instant, day-clamped, keeping the
1589/// time-of-day. Uses [`crate::calendar`] (range-complete) rather than chrono's
1590/// unsigned `Months`. (#1011)
1591fn add_signed_months(dt: Instant, m: i64) -> Instant {
1592    (crate::calendar::add_months_to_days(dt.0, m), dt.1)
1593}
1594
1595/// Whole days from `dt1` to `dt2`, truncated toward zero (chrono `num_days`
1596/// semantics), computed in i128 so a billion-year span never overflows. (#1011)
1597#[allow(
1598    clippy::cast_possible_truncation,
1599    reason = "the day quotient fits i64 across the full year range (±~7.3e11 days); \
1600              i128 only guards the nanosecond intermediate"
1601)]
1602fn instant_num_days(dt1: Instant, dt2: Instant) -> i64 {
1603    let total = i128::from(dt2.0 - dt1.0) * i128::from(DAY_NANOS) + i128::from(dt2.1 - dt1.1);
1604    (total / i128::from(DAY_NANOS)) as i64
1605}
1606
1607/// The sub-span nanoseconds from `dt1` to `dt2`, for a span already known to be
1608/// small (the sub-month `between` remainder — always < ~1 month, so it fits i64).
1609fn instant_sub_nanos(dt1: Instant, dt2: Instant) -> i64 {
1610    (dt2.0 - dt1.0) * DAY_NANOS + (dt2.1 - dt1.1)
1611}
1612
1613/// Elapsed whole `seconds` + non-negative sub-second `nanos` from `dt1` to `dt2`
1614/// as a sub-day-only [`DurationValue`] (`duration.inSeconds`). Seconds are formed
1615/// from the day span directly (× 86 400) so a billion-year span never builds a
1616/// total-nanos value that overflows i64 (#1011); the sub-day nanos difference is
1617/// FLOOR-split into the canonical (sign-on-seconds, non-negative nanos) form.
1618fn elapsed_seconds_between(dt1: Instant, dt2: Instant) -> DurationValue {
1619    let day_secs = (dt2.0 - dt1.0) * 86_400;
1620    let nanos_diff = dt2.1 - dt1.1; // in (-DAY_NANOS, DAY_NANOS)
1621    DurationValue {
1622        months: 0,
1623        days: 0,
1624        seconds: day_secs + nanos_diff.div_euclid(1_000_000_000),
1625        nanos: nanos_diff.rem_euclid(1_000_000_000),
1626    }
1627}
1628
1629/// Resolve a [`BetweenOperand`] to a real UTC [`Instant`] for elapsed-seconds
1630/// maths (#1007). The local wall-clock is `op`'s own date (or, for a time-only
1631/// operand, the `partner`'s date). The UTC offset is `op`'s own when it carries
1632/// one, else — for an unzoned operand — the `partner`'s named zone resolved AT
1633/// that local time (DST-aware), or the partner's numeric offset, or `0` if
1634/// neither is zoned.
1635fn between_instant(op: &BetweenOperand, partner: &BetweenOperand) -> Option<Instant> {
1636    let (date, nanos, offset, _) = op;
1637    let (p_date, _, p_offset, p_zone) = partner;
1638    let date = date.or(*p_date)?;
1639    let off = match offset {
1640        Some(o) => *o,
1641        None => match p_zone.as_deref() {
1642            Some(z) => resolve_zone_offset(date, &time_parts_from_nanos(*nanos), z)?.seconds,
1643            None => p_offset.unwrap_or(0),
1644        },
1645    };
1646    Some(instant_from(date, *nanos - i64::from(off) * 1_000_000_000))
1647}
1648
1649/// The whole calendar months from `dt1` to `dt2`, truncated toward zero: the
1650/// count closest to zero whose addition to `dt1` does not pass `dt2`.
1651///
1652/// The rounding direction follows the SPAN direction (`dt2` vs `dt1`), NOT the
1653/// sign of the raw calendar-month difference. They can disagree when the span is
1654/// under a month but crosses into an earlier day-of-month — e.g. from
1655/// `Jan 2 10:00` back to `Jan 1 12:00` the calendar diff is 0 yet the span is
1656/// negative; keying off `m >= 0` there wrongly decremented to -1 and spilled a
1657/// spurious `-1M30D` into `duration.between` (#920).
1658fn whole_months(dt1: Instant, dt2: Instant) -> i64 {
1659    let (y1, m1, _) = crate::calendar::civil_from_days(dt1.0);
1660    let (y2, m2, _) = crate::calendar::civil_from_days(dt2.0);
1661    let mut m = (y2 - y1) * 12 + (i64::from(m2) - i64::from(m1));
1662    let cand = add_signed_months(dt1, m);
1663    if dt2 >= dt1 {
1664        // Forward span: don't overshoot past dt2.
1665        if cand > dt2 {
1666            m -= 1;
1667        }
1668    } else if cand < dt2 {
1669        // Backward span: don't overshoot before dt2.
1670        m += 1;
1671    }
1672    m
1673}
1674
1675/// Compute `duration.between`/`inMonths`/`inDays`/`inSeconds` from `a` to `b` as
1676/// a typed [`DurationValue`] (#920/#1011). Both operands dated → a calendar-aware
1677/// month/day/time split (shifted to UTC instants only when both carry a zone
1678/// offset); either operand time-only → just the time-of-day difference (no
1679/// month/day span), offset-adjusted only when both are zoned.
1680#[allow(
1681    clippy::single_match_else,
1682    reason = "the both-dated arm is the substantive calendar path; the time-only \
1683              else is the fallthrough — a match reads clearer than nested if-let"
1684)]
1685#[must_use]
1686pub fn duration_between(
1687    a: &BetweenOperand,
1688    b: &BetweenOperand,
1689    mode: BetweenMode,
1690) -> Option<DurationValue> {
1691    let (d1, n1, o1, _) = a;
1692    let (d2, n2, o2, _) = b;
1693    let (d1, n1, o1) = (*d1, *n1, *o1);
1694    let (d2, n2, o2) = (*d2, *n2, *o2);
1695    let both_off = o1.zip(o2);
1696    let shift = |n: i64, o: i32| n - i64::from(o) * 1_000_000_000;
1697    let zero = DurationValue {
1698        months: 0,
1699        days: 0,
1700        seconds: 0,
1701        nanos: 0,
1702    };
1703    // Elapsed SECONDS with at least one dated operand: resolve both to real UTC
1704    // instants so a named-zone DST transition is honoured (the day Stockholm
1705    // falls back has 25 wall-clock hours). An unzoned operand is interpreted in
1706    // the other's named zone; a time-only one borrows the dated one's date.
1707    // (#1007, Temporal10 [8]) Computed via `num_seconds` + `subsec_nanos` so a
1708    // billion-year span fits `i64` (#1011, Temporal10 [10]). Calendar modes keep
1709    // the wall-clock path below.
1710    if matches!(mode, BetweenMode::Seconds) && (d1.is_some() || d2.is_some()) {
1711        let dt1 = between_instant(a, b)?;
1712        let dt2 = between_instant(b, a)?;
1713        return Some(elapsed_seconds_between(dt1, dt2));
1714    }
1715    match (d1, d2) {
1716        (Some(da), Some(db)) => {
1717            let (dt1, dt2) = if let Some((oa, ob)) = both_off {
1718                (
1719                    instant_from(da, shift(n1, oa)),
1720                    instant_from(db, shift(n2, ob)),
1721                )
1722            } else {
1723                (instant_from(da, n1), instant_from(db, n2))
1724            };
1725            match mode {
1726                // i64 months/days — no narrowing, so billion-year spans survive
1727                // (#1011, Temporal10 [9]).
1728                BetweenMode::Months => Some(DurationValue {
1729                    months: whole_months(dt1, dt2),
1730                    ..zero
1731                }),
1732                BetweenMode::Days => Some(DurationValue {
1733                    days: instant_num_days(dt1, dt2),
1734                    ..zero
1735                }),
1736                BetweenMode::Seconds => Some(elapsed_seconds_between(dt1, dt2)),
1737                BetweenMode::Between => {
1738                    let m = whole_months(dt1, dt2);
1739                    // The sub-month remainder is always < ~1 month, so its total
1740                    // nanoseconds fit i64; split days then seconds/nanos,
1741                    // truncating toward zero so every field shares the sign.
1742                    let rem = instant_sub_nanos(add_signed_months(dt1, m), dt2);
1743                    Some(DurationValue::from_total_nanos(
1744                        m,
1745                        rem / DAY_NANOS,
1746                        rem % DAY_NANOS,
1747                    ))
1748                }
1749            }
1750        }
1751        // At least one operand is time-only: no month/day span, just the bounded
1752        // sub-day time-of-day difference.
1753        _ => {
1754            let diff = match (mode, both_off) {
1755                (BetweenMode::Months | BetweenMode::Days, _) => 0,
1756                (_, Some((oa, ob))) => shift(n2, ob) - shift(n1, oa),
1757                (_, None) => n2 - n1,
1758            };
1759            Some(DurationValue::from_total_nanos(0, 0, diff))
1760        }
1761    }
1762}
1763
1764/// `duration * factor` / `duration / factor` (#920 Temporal8 [7]). Scales each
1765/// component by `factor`, then normalises with the openCypher "approximate"
1766/// rule: a fractional month overflows into days (× the Gregorian average month
1767/// length, `MONTH_SECS / DAY_SECS = 30.436875` days), a fractional day overflows
1768/// into the sub-day time, each level truncated toward zero. `factor` is the
1769/// multiplier (`* n`) or `1/n` is applied by the caller for division — here we
1770/// take the already-resolved factor as `num` with `divide` selecting `1/num`
1771/// component-wise to preserve precision.
1772#[must_use]
1773#[allow(
1774    clippy::cast_precision_loss,
1775    reason = "scaling is inherently f64; the sub-day total is computed in f64 \
1776              (not i64 seconds*1e9) to avoid an i64 overflow for large durations, \
1777              and corpus durations are small whole counts"
1778)]
1779pub fn scale_duration(dur: &DurationValue, num: f64, divide: bool) -> DurationValue {
1780    const DAY_NANOS_F: f64 = DAY_SECS * 1e9;
1781    let op = |x: f64| if divide { x / num } else { x * num };
1782    let m = op(dur.months as f64);
1783    let d = op(dur.days as f64);
1784    let n = op(dur.seconds as f64 * 1e9 + dur.nanos as f64);
1785
1786    let m_whole = m.trunc();
1787    let d_total = d + (m - m_whole) * AVG_DAYS_PER_MONTH;
1788    let d_whole = d_total.trunc();
1789    let n_total = n + (d_total - d_whole) * DAY_NANOS_F;
1790
1791    #[allow(
1792        clippy::cast_possible_truncation,
1793        reason = "scaled component magnitudes stay within i64 for the corpus"
1794    )]
1795    DurationValue::from_total_nanos(m_whole as i64, d_whole as i64, n_total.trunc() as i64)
1796}
1797
1798/// `date + duration` (#920): only date-precision components apply — add the
1799/// signed months then days. The duration's sub-day time is **ignored** (a date
1800/// has no time-of-day; openCypher does not carry it into days). Returns the date
1801/// (i64 days), range-complete via [`crate::calendar`] (#1011).
1802#[must_use]
1803#[allow(
1804    clippy::cast_possible_truncation,
1805    reason = "the whole-day quotient fits i64; i128 only guards the nanosecond product"
1806)]
1807pub fn date_plus_duration(date: i64, dur: &DurationValue) -> i64 {
1808    let after_months = crate::calendar::add_months_to_days(date, dur.months);
1809    // A date has no time-of-day, but the WHOLE days in the duration's sub-day
1810    // time still advance the date (the sub-day remainder is dropped). Integer
1811    // division truncates toward zero (i128 so a large-second duration can't
1812    // overflow), so a negated (subtract) duration carries the matching whole day
1813    // in the negative direction (#920 Temporal8 [1]).
1814    let total_nanos = i128::from(dur.seconds) * 1_000_000_000 + i128::from(dur.nanos);
1815    let extra_days = (total_nanos / i128::from(DAY_NANOS)) as i64;
1816    after_months + dur.days + extra_days
1817}
1818
1819/// `localtime/time + duration` (#920): only the sub-day time applies (months/days
1820/// are irrelevant to a time-of-day), wrapping mod 24h.
1821#[must_use]
1822pub fn localtime_plus_duration(nanos_of_day: i64, dur_nanos: i64) -> i64 {
1823    (nanos_of_day + dur_nanos).rem_euclid(DAY_NANOS)
1824}
1825
1826/// `localdatetime/datetime + duration` (#920): add the signed months, then days,
1827/// then the sub-day time (carrying whole-day overflow into the date). Returns the
1828/// resulting `(date, nanos_of_day)`.
1829#[must_use]
1830#[allow(
1831    clippy::cast_possible_truncation,
1832    reason = "the day carry and nanos-of-day both fit i64; i128 only guards the \
1833              nanosecond product"
1834)]
1835pub fn datetime_plus_duration(date: i64, nanos_of_day: i64, dur: &DurationValue) -> (i64, i64) {
1836    // Add the signed months (day-clamped) then the whole days, both on the date.
1837    let days = crate::calendar::add_months_to_days(date, dur.months) + dur.days;
1838    // Then the sub-day time (seconds + nanos), carrying whole-day overflow into
1839    // the date. Done in i128 so a large-second duration can't overflow. (#1011)
1840    let total_nanos =
1841        i128::from(nanos_of_day) + i128::from(dur.seconds) * 1_000_000_000 + i128::from(dur.nanos);
1842    let day_ns = i128::from(DAY_NANOS);
1843    let carry = (total_nanos.div_euclid(day_ns)) as i64;
1844    let nod = (total_nanos.rem_euclid(day_ns)) as i64;
1845    (days + carry, nod)
1846}
1847
1848/// Parse an ISO-8601 duration into a [`DurationValue`].
1849fn parse_duration(s: &str) -> Option<DurationValue> {
1850    let rest = s.strip_prefix('P')?;
1851    if rest.is_empty() {
1852        return None; // a bare `P` has no components
1853    }
1854    // The alternative `P<date>T<time>` form (`P2012-02-02T14:37:21`) is
1855    // digits-and-separators only. A designator duration can ALSO contain `-`
1856    // (a negative component, e.g. `P12Y5M-14DT16H`), so route to the
1857    // alternative parser only when the date segment has no unit letters
1858    // (Y/M/W/D/H/S) — a `:` in the time always means the alternative form.
1859    let date_seg = rest.split('T').next().unwrap_or(rest);
1860    let alternative = rest.contains(':')
1861        || (date_seg.contains('-') && !date_seg.bytes().any(|b| b.is_ascii_alphabetic()));
1862    if alternative {
1863        return parse_duration_alternative(rest);
1864    }
1865    let (date_part, time_part) = match rest.split_once('T') {
1866        Some((d, t)) => (d, Some(t)),
1867        None => (rest, None),
1868    };
1869    let mut acc = (0.0_f64, 0.0_f64, 0.0_f64);
1870    parse_designators(date_part, false, &mut acc)?;
1871    if let Some(t) = time_part {
1872        if t.is_empty() {
1873            return None; // a bare `T` with no time components is malformed
1874        }
1875        parse_designators(t, true, &mut acc)?;
1876    }
1877    Some(approximate_duration(acc.0, acc.1, acc.2))
1878}
1879
1880/// Parse the alternative `P<date>T<time>` duration form, where the "date"
1881/// components count years/months/days (not a calendar date).
1882fn parse_duration_alternative(rest: &str) -> Option<DurationValue> {
1883    let (date_str, time_str) = rest.split_once('T')?;
1884    let [years, months, days] = date_str.split('-').collect::<Vec<_>>()[..] else {
1885        return None;
1886    };
1887    let [hours, minutes, secs] = time_str.split(':').collect::<Vec<_>>()[..] else {
1888        return None;
1889    };
1890    let years: i64 = years.parse().ok()?;
1891    let months: i64 = months.parse().ok()?;
1892    let days: i64 = days.parse().ok()?;
1893    let hours: f64 = hours.parse().ok()?;
1894    let minutes: f64 = minutes.parse().ok()?;
1895    let secs: f64 = secs.parse().ok()?;
1896    #[allow(
1897        clippy::cast_possible_truncation,
1898        reason = "alternative-form times are small, well within i64 nanos"
1899    )]
1900    Some(DurationValue::from_total_nanos(
1901        years * 12 + months,
1902        days,
1903        ((hours * 3600.0 + minutes * 60.0 + secs) * 1e9).round() as i64,
1904    ))
1905}
1906
1907/// Scan `{number}{unit}` designator pairs (e.g. `14D`, `0.75M`) into the
1908/// `(months, days, seconds)` f64 accumulator. `in_time` selects the time-part
1909/// meaning of `M` (minutes vs months) and `H`/`S`. Fractional larger units are
1910/// left in their own accumulator and carried down later by
1911/// [`approximate_duration`], so `P0.75M` yields whole days, not raw seconds.
1912fn parse_designators(s: &str, in_time: bool, acc: &mut (f64, f64, f64)) -> Option<()> {
1913    let mut chars = s.chars().peekable();
1914    while chars.peek().is_some() {
1915        let mut num = String::new();
1916        // A component may carry a leading sign — `toString` renders negative
1917        // components verbatim (`P12Y5M-14DT16H`), and parsing must round-trip
1918        // them (#920 Temporal6).
1919        if matches!(chars.peek(), Some('-' | '+')) {
1920            num.push(chars.next()?);
1921        }
1922        while let Some(&c) = chars.peek() {
1923            if c.is_ascii_digit() || c == '.' {
1924                num.push(c);
1925                chars.next();
1926            } else {
1927                break;
1928            }
1929        }
1930        if num.is_empty() || num == "-" || num == "+" {
1931            return None;
1932        }
1933        let unit = chars.next()?;
1934        let val: f64 = num.parse().ok()?;
1935        match (in_time, unit) {
1936            (false, 'Y') => acc.0 += val * 12.0,
1937            (false, 'M') => acc.0 += val,
1938            (false, 'W') => acc.1 += val * 7.0,
1939            (false, 'D') => acc.1 += val,
1940            (true, 'H') => acc.2 += val * 3600.0,
1941            (true, 'M') => acc.2 += val * 60.0,
1942            (true, 'S') => acc.2 += val,
1943            _ => return None,
1944        }
1945    }
1946    Some(())
1947}
1948
1949/// Render a [`Duration`] canonically as `P[nY][nM][nD]T[nH][nM][nS]`. Years are
1950/// split off the month count; whole days spilling out of the seconds field are
1951/// folded into the day count; subsecond values trim trailing zeros.
1952fn format_duration(dur: &DurationValue) -> String {
1953    use std::fmt::Write as _;
1954    let years = dur.months / 12;
1955    let months = dur.months % 12;
1956    let days = dur.days;
1957    // The stored form FLOORS `seconds` with a non-negative `nanos`; reconstruct
1958    // the truncated-toward-zero split for rendering so every H/M/S component
1959    // shares the sub-day total's sign (`-23h59m59.9s` → `PT-23H-59M-59.9S`, not a
1960    // mix). Borrow back the floored second when the total is negative; this never
1961    // overflows (a huge span has `nanos == 0`, so no adjustment).
1962    let (mut secs, mut sub_ns) = (dur.seconds, dur.nanos);
1963    if secs < 0 && sub_ns > 0 {
1964        secs += 1;
1965        sub_ns -= 1_000_000_000;
1966    }
1967    // Split the whole sub-day `seconds` into H/M/S (each `%`/`/` truncates toward
1968    // zero). The H/M/S group and the day count are INDEPENDENT in openCypher — a
1969    // 32h sub-day time renders `PT32H`, never folded to `P1DT8H` (#920).
1970    // `seconds: i64` holds billion-year spans where a single total-nanos field
1971    // would overflow (#1011).
1972    let hours = secs / 3600;
1973    let rem = secs % 3600;
1974    let minutes = rem / 60;
1975    let whole_secs = rem % 60;
1976
1977    let mut out = String::from("P");
1978    if years != 0 {
1979        write!(out, "{years}Y").unwrap();
1980    }
1981    if months != 0 {
1982        write!(out, "{months}M").unwrap();
1983    }
1984    if days != 0 {
1985        write!(out, "{days}D").unwrap();
1986    }
1987    let mut time = String::new();
1988    if hours != 0 {
1989        write!(time, "{hours}H").unwrap();
1990    }
1991    if minutes != 0 {
1992        write!(time, "{minutes}M").unwrap();
1993    }
1994    if whole_secs != 0 || sub_ns != 0 {
1995        write!(time, "{}S", format_seconds_int(whole_secs, sub_ns)).unwrap();
1996    }
1997    if !time.is_empty() {
1998        out.push('T');
1999        out.push_str(&time);
2000    }
2001    if out == "P" {
2002        out.push_str("T0S");
2003    }
2004    out
2005}
2006
2007/// Render a duration's seconds component from an integer whole-seconds count and
2008/// sub-second nanoseconds — both share the duration's sign — trimming trailing
2009/// zeros: `10`, `49.5`, `-1.999`, `-0.001`. (#920)
2010fn format_seconds_int(whole_secs: i64, sub_ns: i64) -> String {
2011    if sub_ns == 0 {
2012        return whole_secs.to_string();
2013    }
2014    let neg = whole_secs < 0 || sub_ns < 0;
2015    let mut frac = format!("{:09}", sub_ns.unsigned_abs());
2016    while frac.ends_with('0') {
2017        frac.pop();
2018    }
2019    format!(
2020        "{}{}.{}",
2021        if neg { "-" } else { "" },
2022        whole_secs.unsigned_abs(),
2023        frac
2024    )
2025}
2026
2027/// Parse a Cypher ISO-8601 date string into a [`NaiveDate`].
2028///
2029/// Accepts every form the TCK's `date(<string>)` outline exercises:
2030///
2031/// | input         | meaning                | example      |
2032/// |---------------|------------------------|--------------|
2033/// | `2015-07-21`  | calendar (extended)    | `2015-07-21` |
2034/// | `20150721`    | calendar (basic)       | `2015-07-21` |
2035/// | `2015-07`     | year-month             | `2015-07-01` |
2036/// | `201507`      | year-month (basic)     | `2015-07-01` |
2037/// | `2015`        | year only              | `2015-01-01` |
2038/// | `2015-W30-2`  | ISO week + weekday     | `2015-07-21` |
2039/// | `2015W302`    | ISO week + weekday     | `2015-07-21` |
2040/// | `2015-W30`    | ISO week (Monday)      | `2015-07-20` |
2041/// | `2015W30`     | ISO week (Monday)      | `2015-07-20` |
2042/// | `2015-202`    | ordinal (day of year)  | `2015-07-21` |
2043/// | `2015202`     | ordinal (basic)        | `2015-07-21` |
2044///
2045/// Returns `None` for any string that is not one of these forms or that names
2046/// an out-of-range date.
2047#[must_use]
2048pub fn parse_date_string(input: &str) -> Option<i64> {
2049    use crate::calendar;
2050    let s = input.trim();
2051    // An optional leading sign for the ISO-8601 expanded-year form
2052    // (`-999999999-01-01`, `+10000-01-01`); the remainder parses as a positive
2053    // year and the sign is applied. (The pre-#1011 parser split on the first `-`,
2054    // so a leading-minus year parsed to an empty head and failed — part of why
2055    // Temporal10 [9] returned null even before the chrono range limit.)
2056    let (neg, had_sign, s) = if let Some(r) = s.strip_prefix('-') {
2057        (true, true, r)
2058    } else if let Some(r) = s.strip_prefix('+') {
2059        (false, true, r)
2060    } else {
2061        (false, false, s)
2062    };
2063    let signed = |y: i64| if neg { -y } else { y };
2064
2065    // ISO week dates carry a 'W'. Everything before it is the year; everything
2066    // after (dashes stripped) is `ww` followed by an optional weekday digit.
2067    if let Some(wpos) = s.find('W') {
2068        let year: i64 = s[..wpos].trim_end_matches('-').parse().ok()?;
2069        let rest: String = s[wpos + 1..].chars().filter(|c| *c != '-').collect();
2070        // `ww` (+ optional weekday digit). Require ASCII digits so the byte
2071        // slicing below cannot land inside a multi-byte char (would panic).
2072        if rest.len() < 2 || !rest.bytes().all(|b| b.is_ascii_digit()) {
2073            return None;
2074        }
2075        let week: u32 = rest[..2].parse().ok()?;
2076        let weekday: u32 = if rest.len() > 2 {
2077            rest[2..].parse().ok()?
2078        } else {
2079            1
2080        };
2081        return calendar::from_iso_ywd(signed(year), week, weekday);
2082    }
2083
2084    if let Some((head, tail)) = s.split_once('-') {
2085        // Extended forms: `YYYY-MM-DD`, `YYYY-MM`, or `YYYY-DDD` (ordinal).
2086        let year: i64 = head.parse().ok()?;
2087        return match tail.split_once('-') {
2088            Some((month, day)) => {
2089                calendar::ymd_to_days(signed(year), month.parse().ok()?, day.parse().ok()?)
2090            }
2091            None if tail.len() == 3 => calendar::from_ordinal(signed(year), tail.parse().ok()?),
2092            None => calendar::ymd_to_days(signed(year), tail.parse().ok()?, 1),
2093        };
2094    }
2095
2096    // Basic (dash-free) forms, disambiguated by digit count. These assume a
2097    // FIXED 4-digit year, so a stripped expanded-year sign can't apply — a signed
2098    // value must use an extended (dashed) or `W` form. Rejecting here avoids
2099    // mis-slicing `+10000202` as year `1000` (#1011).
2100    if !had_sign && !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
2101        let year: i64 = s.get(..4)?.parse().ok()?;
2102        return match s.len() {
2103            8 => calendar::ymd_to_days(signed(year), s[4..6].parse().ok()?, s[6..8].parse().ok()?),
2104            7 => calendar::from_ordinal(signed(year), s[4..].parse().ok()?),
2105            6 => calendar::ymd_to_days(signed(year), s[4..6].parse().ok()?, 1),
2106            4 => calendar::ymd_to_days(signed(year), 1, 1),
2107            _ => None,
2108        };
2109    }
2110
2111    None
2112}
2113
2114/// Canonical openCypher rendering of a date (i64 days): `YYYY-MM-DD`, signed for
2115/// the expanded-year range (#1011). Delegates to [`crate::calendar`].
2116#[must_use]
2117pub fn format_date(days: i64) -> String {
2118    crate::calendar::format_date(days)
2119}
2120
2121#[cfg(test)]
2122mod tests {
2123    use super::*;
2124
2125    fn d(s: &str) -> String {
2126        format_date(parse_date_string(s).expect("should parse"))
2127    }
2128
2129    #[test]
2130    fn calendar_forms() {
2131        assert_eq!(d("2015-07-21"), "2015-07-21");
2132        assert_eq!(d("20150721"), "2015-07-21");
2133    }
2134
2135    #[test]
2136    fn truncate_date_units() {
2137        // Expected values mirror the openCypher Temporal9 [1] table.
2138        let t = |s: &str, unit: &str| {
2139            format_date(
2140                truncate_date(parse_date_string(s).expect("parse"), unit).expect("truncate"),
2141            )
2142        };
2143        assert_eq!(t("2017-10-11", "millennium"), "2000-01-01");
2144        assert_eq!(t("1984-10-11", "century"), "1900-01-01");
2145        assert_eq!(t("1984-10-11", "decade"), "1980-01-01");
2146        assert_eq!(t("1984-10-11", "year"), "1984-01-01");
2147        // ISO week-year: 1984-02-01 → Monday of ISO week 1 of 1984.
2148        assert_eq!(t("1984-02-01", "weekYear"), "1984-01-02");
2149        assert_eq!(t("1984-11-11", "quarter"), "1984-10-01");
2150        assert_eq!(t("1984-10-11", "month"), "1984-10-01");
2151        // 1984-10-11 is a Thursday → Monday of its week is 1984-10-08.
2152        assert_eq!(t("1984-10-11", "week"), "1984-10-08");
2153        assert_eq!(t("1984-10-11", "day"), "1984-10-11");
2154        assert!(truncate_date(parse_date_string("1984-10-11").unwrap(), "bogus").is_none());
2155    }
2156
2157    #[test]
2158    fn extreme_year_date_operations_are_range_complete() {
2159        // #1011 regression guard: beyond chrono's ±262k-year range, the date
2160        // read/truncate/projection path must return the TRUE value, not NULL
2161        // (the value already parses, stores, and renders range-complete).
2162        let hi = parse_date_string("+999999999-06-15").expect("parse extreme +year");
2163        assert_eq!(date_component(hi, "year"), Some(999_999_999));
2164        assert_eq!(date_component(hi, "month"), Some(6));
2165        assert_eq!(date_component(hi, "day"), Some(15));
2166        assert_eq!(date_component(hi, "quarter"), Some(2));
2167        // weekDay/ordinalDay/dayOfQuarter also resolve (no chrono clamp).
2168        assert!(date_component(hi, "weekDay").is_some());
2169        assert!(date_component(hi, "ordinalDay").is_some());
2170        assert!(date_component(hi, "dayOfQuarter").is_some());
2171        // truncate to year start.
2172        assert_eq!(
2173            format_date(truncate_date(hi, "year").unwrap()),
2174            "+999999999-01-01"
2175        );
2176        assert_eq!(
2177            format_date(truncate_date(hi, "month").unwrap()),
2178            "+999999999-06-01"
2179        );
2180        // projection: replace the day, keep the extreme year/month.
2181        let projected = project_date(
2182            hi,
2183            &DateOverrides {
2184                day: Some(1),
2185                ..DateOverrides::default()
2186            },
2187        );
2188        assert_eq!(format_date(projected.unwrap()), "+999999999-06-01");
2189        // deep-negative year round-trips too.
2190        let lo = parse_date_string("-999999999-01-01").expect("parse extreme -year");
2191        assert_eq!(date_component(lo, "year"), Some(-999_999_999));
2192    }
2193
2194    #[test]
2195    fn duration_subsecond_accessors_dont_overflow_on_huge_spans() {
2196        // #1011 regression guard: a >~292-year `seconds` makes `seconds * 1e9`
2197        // overflow i64 — the cumulative sub-second accessors must return NULL
2198        // (unrepresentable) rather than panic (debug) or silently wrap (release).
2199        let huge = DurationValue::from_total_nanos(0, 0, 0);
2200        let huge = DurationValue {
2201            seconds: 31_556_951_999_913_600, // ~1e9-year inSeconds span
2202            ..huge
2203        };
2204        assert_eq!(duration_component(&huge, "nanoseconds"), None);
2205        assert_eq!(duration_component(&huge, "milliseconds"), None);
2206        assert_eq!(duration_component(&huge, "microseconds"), None);
2207        // The non-cumulative accessors still work.
2208        assert_eq!(
2209            duration_component(&huge, "seconds"),
2210            Some(31_556_951_999_913_600)
2211        );
2212        // A small duration's cumulative accessors are unaffected.
2213        let small = DurationValue::from_total_nanos(0, 0, 1_500_000_000);
2214        assert_eq!(duration_component(&small, "milliseconds"), Some(1_500));
2215    }
2216
2217    #[test]
2218    fn signed_basic_dateless_form_is_rejected_not_misparsed() {
2219        // #1011 regression guard: a stripped +/- sign must NOT fall through to the
2220        // fixed-4-digit-year basic branch (which mis-sliced `+10000202` → year
2221        // 1000). Signed years are only valid in the extended (dashed) / `W` forms.
2222        assert_eq!(parse_date_string("+10000202"), None);
2223        assert_eq!(parse_date_string("-10000202"), None);
2224        // The equivalent extended ordinal form still parses correctly.
2225        assert_eq!(
2226            format_date(parse_date_string("+10000-202").expect("extended ordinal")),
2227            "+10000-07-20"
2228        );
2229    }
2230
2231    #[test]
2232    fn truncate_time_units() {
2233        // 12:31:14.645876123 = 45_074_645_876_123 ns since midnight.
2234        let n = 45_074_645_876_123_i64;
2235        let r =
2236            |unit: &str| render_localtime_nanos(truncate_time_nanos(n, unit).expect("truncate"));
2237        assert_eq!(r("hour"), "12:00");
2238        assert_eq!(r("minute"), "12:31");
2239        assert_eq!(r("second"), "12:31:14");
2240        assert_eq!(r("millisecond"), "12:31:14.645");
2241        assert_eq!(r("microsecond"), "12:31:14.645876");
2242        // A unit coarser than a time-of-day truncates to midnight.
2243        assert_eq!(r("day"), "00:00");
2244        assert_eq!(r("year"), "00:00");
2245        assert!(truncate_time_nanos(n, "bogus").is_none());
2246    }
2247
2248    #[test]
2249    fn project_localtime_keeps_unspecified_subsecond_groups() {
2250        // #920: a sub-second override replaces only the named ms/µs/ns group and
2251        // keeps the rest of the base. `truncate('millisecond')` floors to .645,
2252        // then `{nanosecond: 2}` must yield .645000002, not .000000002.
2253        let base = 45_074_645_000_000_i64; // 12:31:14.645
2254        let only_ns = LocalTimeOverrides {
2255            hour: None,
2256            minute: None,
2257            second: None,
2258            millisecond: None,
2259            microsecond: None,
2260            nanosecond: Some(2),
2261        };
2262        assert_eq!(
2263            render_localtime_nanos(project_localtime(base, &only_ns).unwrap()),
2264            "12:31:14.645000002"
2265        );
2266        // Overriding the millisecond group replaces only it.
2267        let ms = LocalTimeOverrides {
2268            millisecond: Some(123),
2269            ..only_ns
2270        };
2271        assert_eq!(
2272            render_localtime_nanos(project_localtime(base, &ms).unwrap()),
2273            "12:31:14.123000002"
2274        );
2275    }
2276
2277    #[test]
2278    fn duration_str_parses_negative_components_round_trip() {
2279        // #920 Temporal6: `toString` renders negative components verbatim, and
2280        // `duration(<that string>)` must parse them back to the same value.
2281        let secs = 16 * 3600; // 16h
2282        let v = DurationValue::from_total_nanos(149, -14, i64::from(secs) * 1_000_000_000);
2283        assert_eq!(duration_value_from_str("P12Y5M-14DT16H"), Some(v));
2284        // Round-trip: render then parse yields the original components.
2285        assert_eq!(duration_value_from_str(&render_duration_value(&v)), Some(v));
2286    }
2287
2288    #[test]
2289    fn duration_construction_carries_rounded_up_subsecond() {
2290        // #1011 regression: a fractional second that rounds to 1e9 nanos must
2291        // carry into `seconds`, not leave nanos == 1_000_000_000 (which rendered
2292        // as a bogus "PT0.1S"). `duration({seconds: 0.9999999996})` → PT1S.
2293        let fields: Fields =
2294            [("seconds".to_string(), TemporalField::Float(0.999_999_999_6))].into();
2295        let dv = build_duration_map(&fields).expect("duration");
2296        assert!(dv.nanos >= 0 && dv.nanos < 1_000_000_000, "nanos canonical");
2297        assert_eq!((dv.seconds, dv.nanos), (1, 0));
2298        assert_eq!(render_duration_value(&dv), "PT1S");
2299    }
2300
2301    #[test]
2302    fn duration_render_preserves_subsecond_for_large_spans() {
2303        // #920 Temporal10: a huge sub-day span must keep exact sub-second digits
2304        // (an f64 seconds count would corrupt `.142` to `.14199996`).
2305        let nanos = (278_565_i64 * 3600 + 45 * 60 + 22) * 1_000_000_000 + 142_000_000;
2306        let dv = |n: i64| DurationValue::from_total_nanos(0, 0, n);
2307        assert_eq!(render_duration_value(&dv(nanos)), "PT278565H45M22.142S");
2308        // Sign-consistent sub-second rendering.
2309        assert_eq!(render_duration_value(&dv(-1_999_000_000)), "PT-1.999S");
2310        assert_eq!(render_duration_value(&dv(-1_000_000)), "PT-0.001S");
2311    }
2312
2313    #[test]
2314    fn duration_scale_matches_opencypher() {
2315        // Base P12Y5M14DT16H13M10.000000001S (Temporal8 [7]).
2316        let base = DurationValue::from_total_nanos(149, 14, 58_390_000_000_001);
2317        // * 2: pure component doubling; the sub-day time stays as 32H (no day-fold).
2318        assert_eq!(
2319            scale_duration(&base, 2.0, false),
2320            DurationValue::from_total_nanos(298, 28, 116_780_000_000_002),
2321            "P24Y10M28DT32H26M20.000000002S"
2322        );
2323        // / 2: a fractional month (0.5) overflows to 15.2184375 days, the
2324        // fractional day to seconds — 74mo, 22d, 48068s = 13H21M8S.
2325        assert_eq!(
2326            scale_duration(&base, 2.0, true),
2327            DurationValue::from_total_nanos(74, 22, 48_068_000_000_000),
2328            "P6Y2M22DT13H21M8S"
2329        );
2330        // * 0.5 equals / 2.
2331        assert_eq!(
2332            scale_duration(&base, 0.5, false),
2333            DurationValue::from_total_nanos(74, 22, 48_068_000_000_000)
2334        );
2335    }
2336
2337    #[test]
2338    fn duration_arithmetic() {
2339        let d = |s: &str| parse_date_string(s).unwrap();
2340        // Temporal8 [1]: date + duration{12y5mo14d16h12m70s2ns} → '1997-03-25'
2341        // (months=149, days=14, sub-day time < 24h so no extra day).
2342        let nanos = (16 * 3600 + 12 * 60 + 70) * 1_000_000_000 + 2;
2343        let dur = |m, dd, n| DurationValue::from_total_nanos(m, dd, n);
2344        assert_eq!(
2345            date_plus_duration(d("1984-10-11"), &dur(149, 14, nanos)),
2346            d("1997-03-25")
2347        );
2348        // Subtraction (negated components) → '1972-04-27'.
2349        assert_eq!(
2350            date_plus_duration(d("1984-10-11"), &dur(-149, -14, -nanos)),
2351            d("1972-04-27")
2352        );
2353        // Temporal8 [1] row 3: the duration's sub-day time exceeds 24h
2354        // (122293.5s ≈ 1d10h), so a WHOLE day carries into the date even though
2355        // the date drops the sub-day remainder: 155mo + 29d + 1d = 1997-10-11.
2356        let big = 122_293_500_000_000_i64;
2357        assert_eq!(
2358            date_plus_duration(d("1984-10-11"), &dur(155, 29, big)),
2359            d("1997-10-11")
2360        );
2361        assert_eq!(
2362            date_plus_duration(d("1984-10-11"), &dur(-155, -29, -big)),
2363            d("1971-10-12")
2364        );
2365        // localtime wraps mod 24h.
2366        let day = 86_400_000_000_000_i64;
2367        assert_eq!(
2368            localtime_plus_duration(23 * 3_600_000_000_000, 2 * 3_600_000_000_000),
2369            3_600_000_000_000
2370        );
2371        assert_eq!(localtime_plus_duration(0, -1), day - 1);
2372        // localdatetime: sub-day time carries into the date.
2373        let (date, nod) = datetime_plus_duration(
2374            d("1984-10-11"),
2375            12 * 3_600_000_000_000,
2376            &dur(0, 0, 13 * 3_600_000_000_000),
2377        );
2378        assert_eq!((date, nod), (d("1984-10-12"), 3_600_000_000_000)); // 12:00 + 13h → next day 01:00
2379    }
2380
2381    #[test]
2382    fn time_value_from_str_defaults_offset_to_utc() {
2383        // #920: a bare `time('14:30')` (no offset) defaults to UTC (offset 0).
2384        assert_eq!(
2385            time_value_from_str("14:30"),
2386            Some((14 * 3_600_000_000_000 + 30 * 60_000_000_000, 0))
2387        );
2388        // An explicit offset is honoured.
2389        assert_eq!(
2390            time_value_from_str("14:30+01:00"),
2391            Some((14 * 3_600_000_000_000 + 30 * 60_000_000_000, 3600))
2392        );
2393    }
2394
2395    #[test]
2396    fn duration_between_units() {
2397        let date = |s: &str| (Some(parse_date_string(s).unwrap()), 0_i64, None, None);
2398        let lt = |h: i64, m: i64| (None, (h * 3600 + m * 60) * 1_000_000_000, None, None);
2399        let dv = |m, dd, n| DurationValue::from_total_nanos(m, dd, n);
2400        // date → date: calendar split (Temporal10 [2]).
2401        assert_eq!(
2402            duration_between(
2403                &date("1984-10-11"),
2404                &date("2015-06-24"),
2405                BetweenMode::Between
2406            ),
2407            Some(dv(368, 13, 0)) // 30Y8M13D
2408        );
2409        // inMonths drops the days; inDays gives the whole-day total.
2410        assert_eq!(
2411            duration_between(
2412                &date("1984-10-11"),
2413                &date("2015-06-24"),
2414                BetweenMode::Months
2415            ),
2416            Some(dv(368, 0, 0))
2417        );
2418        assert_eq!(
2419            duration_between(&date("1984-10-11"), &date("2015-06-24"), BetweenMode::Days),
2420            Some(dv(0, 11213, 0))
2421        );
2422        // time-only → just the time-of-day diff, no month/day span.
2423        assert_eq!(
2424            duration_between(&lt(14, 30), &lt(16, 30), BetweenMode::Between),
2425            Some(dv(0, 0, 2 * 3_600_000_000_000))
2426        );
2427        // Negative direction: months/days/seconds/nanos share the sign.
2428        let r = duration_between(
2429            &date("2015-06-24"),
2430            &date("1984-10-11"),
2431            BetweenMode::Between,
2432        )
2433        .unwrap();
2434        assert!(r.months <= 0 && r.days <= 0 && r.seconds <= 0 && r.nanos <= 0);
2435
2436        // Sub-month backward span crossing an earlier day-of-month: months must
2437        // be 0, not -1 (#920 — the whole_months span-direction fix). 22h back.
2438        let ldt = |s: &str| {
2439            let (d, t) = s.split_once('T').unwrap();
2440            (
2441                Some(parse_date_string(d).unwrap()),
2442                nanos_of_day(&parse_time_of_day(t).unwrap()),
2443                None,
2444                None,
2445            )
2446        };
2447        assert_eq!(
2448            duration_between(
2449                &ldt("2018-01-02T10:00:00"),
2450                &ldt("2018-01-01T12:00:00"),
2451                BetweenMode::Between,
2452            ),
2453            Some(dv(0, 0, -22 * 3_600_000_000_000)),
2454            "a 22h backward span is PT-22H, not P-1M30DT2H"
2455        );
2456    }
2457
2458    #[test]
2459    fn duration_inseconds_dst_named_zone() {
2460        // #1007 Temporal10 [8]: the Stockholm fall-back day (2017-10-29) has 25
2461        // wall-clock hours, so an unzoned operand resolved in that named zone
2462        // yields the real elapsed span — not the naive wall-clock difference.
2463        let d = |y, m, day| crate::calendar::ymd_to_days(y, m, day).unwrap();
2464        let h = |n: i64| n * 3_600_000_000_000;
2465        // datetime(2017-10-29T00:00[Europe/Stockholm], +02:00) vs localdatetime 04:00.
2466        let zoned = (
2467            Some(d(2017, 10, 29)),
2468            0_i64,
2469            Some(7200_i32),
2470            Some("Europe/Stockholm".to_string()),
2471        );
2472        let unzoned_0429_04 = (Some(d(2017, 10, 29)), h(4), None, None);
2473        assert_eq!(
2474            duration_between(&zoned, &unzoned_0429_04, BetweenMode::Seconds),
2475            Some(DurationValue::from_total_nanos(0, 0, h(5))),
2476            "00:00 (+02) → 04:00 across the fall-back is 5 real hours"
2477        );
2478        // datetime(...00:00 Stockholm) vs date(2017-10-30) → a full 25-hour day.
2479        let next_date = (Some(d(2017, 10, 30)), 0_i64, None, None);
2480        assert_eq!(
2481            duration_between(&zoned, &next_date, BetweenMode::Seconds),
2482            Some(DurationValue::from_total_nanos(0, 0, h(25)))
2483        );
2484    }
2485
2486    #[test]
2487    fn localtime_str_round_trip() {
2488        // Mirrors Temporal2 [2]: pure-value rendering must reproduce these.
2489        let r = |s: &str| render_localtime_nanos(localtime_nanos_from_str(s).expect("parse"));
2490        assert_eq!(r("21:40:32.142"), "21:40:32.142");
2491        assert_eq!(r("214032.142"), "21:40:32.142");
2492        assert_eq!(r("21:40:32"), "21:40:32");
2493        assert_eq!(r("21:40"), "21:40");
2494        assert_eq!(r("21"), "21:00");
2495        // A localtime carries no offset.
2496        assert!(localtime_nanos_from_str("21:40:32+01:00").is_none());
2497    }
2498
2499    #[test]
2500    fn localtime_of_day_from_any_temporal_string() {
2501        let r = |s: &str| render_localtime_nanos(time_of_day_nanos_any(s).expect("parse"));
2502        assert_eq!(r("12:31:14.645876123"), "12:31:14.645876123"); // localtime
2503        assert_eq!(r("12:31:14.645876+01:00"), "12:31:14.645876"); // time → drop offset
2504        assert_eq!(r("1984-10-11T12:31:14.645"), "12:31:14.645"); // localdatetime
2505        assert_eq!(r("1984-10-11T12:00+01:00[Europe/Stockholm]"), "12:00"); // datetime named-zone
2506    }
2507
2508    #[test]
2509    fn localtime_projection_overrides() {
2510        let base = localtime_nanos_from_str("12:31:14.645876123").unwrap();
2511        let proj =
2512            |o: &LocalTimeOverrides| render_localtime_nanos(project_localtime(base, o).unwrap());
2513        assert_eq!(proj(&LocalTimeOverrides::default()), "12:31:14.645876123");
2514        assert_eq!(
2515            proj(&LocalTimeOverrides {
2516                second: Some(42),
2517                ..Default::default()
2518            }),
2519            "12:31:42.645876123"
2520        );
2521    }
2522
2523    #[test]
2524    fn localdatetime_str_round_trip() {
2525        // Mirrors Temporal2 [4]: pure-value rendering must reproduce these.
2526        let r = |s: &str| {
2527            let (d, n) = localdatetime_parts_from_str(s).expect("parse");
2528            render_localdatetime(d, n)
2529        };
2530        assert_eq!(r("2015-07-21T21:40:32.142"), "2015-07-21T21:40:32.142");
2531        assert_eq!(r("2015-W30-2T214032.142"), "2015-07-21T21:40:32.142");
2532        assert_eq!(r("2015-202T21:40:32"), "2015-07-21T21:40:32");
2533        assert_eq!(r("2015T214032"), "2015-01-01T21:40:32");
2534        assert_eq!(r("20150721T21:40"), "2015-07-21T21:40");
2535        assert_eq!(r("2015202T21"), "2015-07-21T21:00");
2536        // A localdatetime carries no zone.
2537        assert!(localdatetime_parts_from_str("2015-07-21T21:40:32+01:00").is_none());
2538    }
2539
2540    #[test]
2541    fn localdatetime_full_year_range() {
2542        // The two-field representation spans years an i64-nanosecond timestamp
2543        // cannot (it overflows ~year 2262) — regression guard for WithOrderBy1.
2544        let r = |s: &str| {
2545            let (d, n) = localdatetime_parts_from_str(s).expect("parse");
2546            render_localdatetime(d, n)
2547        };
2548        assert_eq!(
2549            r("0001-01-01T01:01:01.000000001"),
2550            "0001-01-01T01:01:01.000000001"
2551        );
2552        assert_eq!(
2553            r("9999-09-09T09:59:59.999999999"),
2554            "9999-09-09T09:59:59.999999999"
2555        );
2556    }
2557
2558    #[test]
2559    fn time_str_round_trip() {
2560        // Mirrors Temporal2 [3]: pure-value rendering must reproduce these.
2561        let r = |s: &str| {
2562            let (n, o) = time_value_from_str(s).expect("parse");
2563            render_time_value(n, o)
2564        };
2565        assert_eq!(r("21:40:32.142+0100"), "21:40:32.142+01:00");
2566        assert_eq!(r("214032.142Z"), "21:40:32.142Z");
2567        assert_eq!(r("214032-0100"), "21:40:32-01:00");
2568        assert_eq!(r("21:40-01:30"), "21:40-01:30");
2569        assert_eq!(r("2140-00:00"), "21:40Z"); // -00:00 → UTC
2570        assert_eq!(r("2140-02"), "21:40-02:00");
2571        assert_eq!(r("22+18:00"), "22:00+18:00");
2572        // A bare time (no offset) defaults to UTC (Z) per openCypher (#920).
2573        assert_eq!(r("21:40:32"), "21:40:32Z");
2574    }
2575
2576    #[test]
2577    fn time_projection_zone_shift_vs_attach() {
2578        let base = |s: &str| time_of_day_with_offset(s).expect("parse");
2579        let ren = |(n, o): (i64, i32)| render_time_value(n, o);
2580        let plus5 = Some(5 * 3600);
2581        // localtime base (no offset) + new zone → ATTACH (no shift).
2582        let (n, off) = base("12:31:14.645876123");
2583        assert_eq!(ren(project_time(n, off, plus5)), "12:31:14.645876123+05:00");
2584        // time base (+01:00) + new zone +05:00 → SHIFT (preserve instant).
2585        let (n, off) = base("12:31:14.645876+01:00");
2586        assert_eq!(ren(project_time(n, off, plus5)), "16:31:14.645876+05:00");
2587        // No new zone: keep the base offset, or UTC for an offset-less base.
2588        let (n, off) = base("12:31:14.645876+01:00");
2589        assert_eq!(ren(project_time(n, off, None)), "12:31:14.645876+01:00");
2590        let (n, off) = base("12:31:14.645876123");
2591        assert_eq!(ren(project_time(n, off, None)), "12:31:14.645876123Z");
2592    }
2593
2594    #[test]
2595    fn datetime_str_round_trip() {
2596        let r = |s: &str| {
2597            let (d, n, o, z) = datetime_value_from_str(s).expect("parse");
2598            render_datetime_value(d, n, o, z.as_deref())
2599        };
2600        assert_eq!(
2601            r("2015-07-21T21:40:32.142+01:00"),
2602            "2015-07-21T21:40:32.142+01:00"
2603        );
2604        assert_eq!(r("2015-07-21T21:40Z"), "2015-07-21T21:40Z");
2605        // Named zone: offset resolved at that instant (summer +02:00).
2606        assert_eq!(
2607            r("2017-08-08T12:31:14.645876123[Europe/Stockholm]"),
2608            "2017-08-08T12:31:14.645876123+02:00[Europe/Stockholm]"
2609        );
2610    }
2611
2612    #[test]
2613    fn datetime_projection_shift_and_attach() {
2614        let render = |p: Option<DateTimeParts>| {
2615            let (d, n, o, z) = p.unwrap();
2616            render_datetime_value(d, n, o, z.as_deref())
2617        };
2618        let date = parse_date_string("1984-10-11").unwrap();
2619        let nanos = nanos_of_day(&parse_time_of_day("12:31:14.645876").unwrap());
2620        // time base (+01:00) shifted to +05:00 → wall time advances 4h.
2621        assert_eq!(
2622            render(project_datetime(
2623                date,
2624                nanos,
2625                Some(3600),
2626                None,
2627                Some("+05:00")
2628            )),
2629            "1984-10-11T16:31:14.645876+05:00"
2630        );
2631        // time base (+01:00) shifted to a NAMED zone (Honolulu -10:00) → 01:31.
2632        assert_eq!(
2633            render(project_datetime(
2634                date,
2635                nanos,
2636                Some(3600),
2637                None,
2638                Some("Pacific/Honolulu")
2639            )),
2640            "1984-10-11T01:31:14.645876-10:00[Pacific/Honolulu]"
2641        );
2642        // localtime base (no offset) + named zone → ATTACH (no shift).
2643        assert_eq!(
2644            render(project_datetime(
2645                date,
2646                nanos,
2647                None,
2648                None,
2649                Some("Pacific/Honolulu")
2650            )),
2651            "1984-10-11T12:31:14.645876-10:00[Pacific/Honolulu]"
2652        );
2653        // datetime base, no new zone → keep the source offset + zone label.
2654        assert_eq!(
2655            render(project_datetime(
2656                date,
2657                nanos,
2658                Some(3600),
2659                Some("Europe/Stockholm"),
2660                None
2661            )),
2662            "1984-10-11T12:31:14.645876+01:00[Europe/Stockholm]"
2663        );
2664    }
2665
2666    #[test]
2667    fn partial_forms() {
2668        assert_eq!(d("2015-07"), "2015-07-01");
2669        assert_eq!(d("201507"), "2015-07-01");
2670        assert_eq!(d("2015"), "2015-01-01");
2671    }
2672
2673    #[test]
2674    fn week_forms() {
2675        assert_eq!(d("2015-W30-2"), "2015-07-21");
2676        assert_eq!(d("2015W302"), "2015-07-21");
2677        assert_eq!(d("2015-W30"), "2015-07-20");
2678        assert_eq!(d("2015W30"), "2015-07-20");
2679    }
2680
2681    #[test]
2682    fn ordinal_forms() {
2683        assert_eq!(d("2015-202"), "2015-07-21");
2684        assert_eq!(d("2015202"), "2015-07-21");
2685    }
2686
2687    #[test]
2688    fn rejects_garbage() {
2689        assert!(parse_date_string("not-a-date").is_none());
2690        assert!(parse_date_string("2015-13-01").is_none());
2691        assert!(parse_date_string("").is_none());
2692    }
2693
2694    #[test]
2695    fn non_ascii_week_does_not_panic() {
2696        // Multi-byte chars after 'W' must not panic on byte slicing.
2697        assert!(parse_date_string("2015W€0").is_none());
2698        assert!(parse_date_string("2015W3é").is_none());
2699    }
2700
2701    #[test]
2702    fn local_time_forms() {
2703        let r = |s| render_local_time(s).unwrap();
2704        assert_eq!(r("21:40:32.142"), "21:40:32.142");
2705        assert_eq!(r("214032.142"), "21:40:32.142");
2706        assert_eq!(r("21:40:32"), "21:40:32");
2707        assert_eq!(r("214032"), "21:40:32");
2708        assert_eq!(r("21:40"), "21:40");
2709        assert_eq!(r("2140"), "21:40");
2710        assert_eq!(r("21"), "21:00");
2711        // A local time may not carry an offset.
2712        assert!(render_local_time("21:40Z").is_none());
2713    }
2714
2715    #[test]
2716    fn time_forms() {
2717        let r = |s| render_time(s).unwrap();
2718        assert_eq!(r("21:40:32.142+0100"), "21:40:32.142+01:00");
2719        assert_eq!(r("214032.142Z"), "21:40:32.142Z");
2720        assert_eq!(r("21:40:32+01:00"), "21:40:32+01:00");
2721        assert_eq!(r("214032-0100"), "21:40:32-01:00");
2722        assert_eq!(r("21:40-01:30"), "21:40-01:30");
2723        assert_eq!(r("2140-00:00"), "21:40Z"); // zero offset renders as Z
2724        assert_eq!(r("2140-02"), "21:40-02:00");
2725        assert_eq!(r("22+18:00"), "22:00+18:00");
2726        // An offset is required.
2727        assert!(render_time("21:40").is_none());
2728    }
2729
2730    #[test]
2731    fn local_date_time_forms() {
2732        let r = |s| render_local_date_time(s).unwrap();
2733        assert_eq!(r("2015-07-21T21:40:32.142"), "2015-07-21T21:40:32.142");
2734        assert_eq!(r("2015-W30-2T214032.142"), "2015-07-21T21:40:32.142");
2735        assert_eq!(r("2015-202T21:40:32"), "2015-07-21T21:40:32");
2736        assert_eq!(r("2015T214032"), "2015-01-01T21:40:32");
2737        assert_eq!(r("20150721T21:40"), "2015-07-21T21:40");
2738        assert_eq!(r("2015-W30T2140"), "2015-07-20T21:40");
2739        assert_eq!(r("2015202T21"), "2015-07-21T21:00");
2740    }
2741
2742    #[test]
2743    fn date_time_forms() {
2744        let r = |s| render_date_time(s).unwrap();
2745        assert_eq!(
2746            r("2015-07-21T21:40:32.142+0100"),
2747            "2015-07-21T21:40:32.142+01:00"
2748        );
2749        assert_eq!(r("2015-W30-2T214032.142Z"), "2015-07-21T21:40:32.142Z");
2750        assert_eq!(r("2015-202T21:40:32+01:00"), "2015-07-21T21:40:32+01:00");
2751        assert_eq!(r("2015T214032-0100"), "2015-01-01T21:40:32-01:00");
2752        assert_eq!(r("20150721T21:40-01:30"), "2015-07-21T21:40-01:30");
2753        assert_eq!(r("2015-W30T2140-00:00"), "2015-07-20T21:40Z");
2754        assert_eq!(r("2015-W30T2140-02"), "2015-07-20T21:40-02:00");
2755        assert_eq!(r("2015202T21+18:00"), "2015-07-21T21:00+18:00");
2756    }
2757
2758    #[test]
2759    fn date_time_named_zone_forms() {
2760        let r = |s| render_date_time(s).unwrap();
2761        // Explicit offset is echoed (reformatted); zone preserved.
2762        assert_eq!(
2763            r("2015-07-21T21:40:32.142+02:00[Europe/Stockholm]"),
2764            "2015-07-21T21:40:32.142+02:00[Europe/Stockholm]"
2765        );
2766        assert_eq!(
2767            r("2015-07-21T21:40:32.142+0845[Australia/Eucla]"),
2768            "2015-07-21T21:40:32.142+08:45[Australia/Eucla]"
2769        );
2770        assert_eq!(
2771            r("2015-07-21T21:40:32.142-04[America/New_York]"),
2772            "2015-07-21T21:40:32.142-04:00[America/New_York]"
2773        );
2774        // No offset → resolved from the zone (London in July → BST +01:00).
2775        assert_eq!(
2776            r("2015-07-21T21:40:32.142[Europe/London]"),
2777            "2015-07-21T21:40:32.142+01:00[Europe/London]"
2778        );
2779        // Historical LMT before standard time (Stockholm 1818 → +00:53:28).
2780        assert_eq!(
2781            r("1818-07-21T21:40:32.142[Europe/Stockholm]"),
2782            "1818-07-21T21:40:32.142+00:53:28[Europe/Stockholm]"
2783        );
2784    }
2785
2786    #[test]
2787    fn duration_forms() {
2788        let r = |s| render_duration(s).unwrap();
2789        assert_eq!(r("P14DT16H12M"), "P14DT16H12M");
2790        assert_eq!(r("P5M1.5D"), "P5M1DT12H");
2791        assert_eq!(r("P0.75M"), "P22DT19H51M49.5S");
2792        assert_eq!(r("PT0.75M"), "PT45S");
2793        assert_eq!(r("P2.5W"), "P17DT12H");
2794        assert_eq!(r("P12Y5M14DT16H12M70S"), "P12Y5M14DT16H13M10S");
2795        assert_eq!(r("P2012-02-02T14:37:21.545"), "P2012Y2M2DT14H37M21.545S");
2796    }
2797
2798    fn fields(pairs: &[(&str, TemporalField)]) -> Fields {
2799        pairs
2800            .iter()
2801            .map(|(k, v)| {
2802                (
2803                    (*k).to_string(),
2804                    match v {
2805                        TemporalField::Int(n) => TemporalField::Int(*n),
2806                        TemporalField::Float(x) => TemporalField::Float(*x),
2807                        TemporalField::Str(s) => TemporalField::Str(s.clone()),
2808                        TemporalField::Date(d) => TemporalField::Date(*d),
2809                    },
2810                )
2811            })
2812            .collect()
2813    }
2814
2815    fn int(n: i64) -> TemporalField {
2816        TemporalField::Int(n)
2817    }
2818
2819    #[test]
2820    fn date_map_forms() {
2821        let r = |p: &[(&str, TemporalField)]| render_temporal_map("date", &fields(p)).unwrap();
2822        assert_eq!(
2823            r(&[("year", int(1984)), ("month", int(10)), ("day", int(11))]),
2824            "1984-10-11"
2825        );
2826        assert_eq!(r(&[("year", int(1984)), ("month", int(10))]), "1984-10-01");
2827        assert_eq!(
2828            r(&[
2829                ("year", int(1984)),
2830                ("week", int(10)),
2831                ("dayOfWeek", int(3))
2832            ]),
2833            "1984-03-07"
2834        );
2835        assert_eq!(r(&[("year", int(1984)), ("week", int(10))]), "1984-03-05");
2836        assert_eq!(r(&[("year", int(1984))]), "1984-01-01");
2837        assert_eq!(
2838            r(&[("year", int(1984)), ("ordinalDay", int(202))]),
2839            "1984-07-20"
2840        );
2841        assert_eq!(
2842            r(&[
2843                ("year", int(1984)),
2844                ("quarter", int(3)),
2845                ("dayOfQuarter", int(45))
2846            ]),
2847            "1984-08-14"
2848        );
2849        // ISO-week year vs calendar year
2850        assert_eq!(r(&[("year", int(1817)), ("week", int(1))]), "1816-12-30");
2851    }
2852
2853    #[test]
2854    fn date_map_anchored_week() {
2855        let anchor = TemporalField::Date(parse_date_string("1816-12-31").unwrap());
2856        let out =
2857            render_temporal_map("date", &fields(&[("date", anchor), ("week", int(2))])).unwrap();
2858        assert_eq!(out, "1817-01-07");
2859    }
2860
2861    #[test]
2862    fn time_and_subsecond_maps() {
2863        let lt =
2864            |p: &[(&str, TemporalField)]| render_temporal_map("localtime", &fields(p)).unwrap();
2865        assert_eq!(
2866            lt(&[
2867                ("hour", int(12)),
2868                ("minute", int(31)),
2869                ("second", int(14)),
2870                ("nanosecond", int(789)),
2871                ("millisecond", int(123)),
2872                ("microsecond", int(456)),
2873            ]),
2874            "12:31:14.123456789"
2875        );
2876        assert_eq!(lt(&[("hour", int(12))]), "12:00");
2877        // time() defaults to Z, honours an offset
2878        let t = |p: &[(&str, TemporalField)]| render_temporal_map("time", &fields(p)).unwrap();
2879        assert_eq!(t(&[("hour", int(12)), ("minute", int(31))]), "12:31Z");
2880        assert_eq!(
2881            t(&[
2882                ("hour", int(12)),
2883                ("minute", int(34)),
2884                ("second", int(56)),
2885                ("timezone", TemporalField::Str("+02:05:59".into())),
2886            ]),
2887            "12:34:56+02:05:59"
2888        );
2889    }
2890
2891    #[test]
2892    fn datetime_maps_with_zones() {
2893        let dt = |p: &[(&str, TemporalField)]| render_temporal_map("datetime", &fields(p)).unwrap();
2894        // default zone Z
2895        assert_eq!(
2896            dt(&[("year", int(1984)), ("month", int(10)), ("day", int(11))]),
2897            "1984-10-11T00:00Z"
2898        );
2899        // offset zone
2900        assert_eq!(
2901            dt(&[
2902                ("year", int(1984)),
2903                ("ordinalDay", int(202)),
2904                ("hour", int(12)),
2905                ("timezone", TemporalField::Str("+01:00".into())),
2906            ]),
2907            "1984-07-20T12:00+01:00"
2908        );
2909        // named zone — summer (CEST) vs winter (CET)
2910        assert_eq!(
2911            dt(&[
2912                ("year", int(1984)),
2913                ("ordinalDay", int(202)),
2914                ("hour", int(12)),
2915                ("timezone", TemporalField::Str("Europe/Stockholm".into())),
2916            ]),
2917            "1984-07-20T12:00+02:00[Europe/Stockholm]"
2918        );
2919        assert_eq!(
2920            dt(&[
2921                ("year", int(1984)),
2922                ("month", int(10)),
2923                ("day", int(11)),
2924                ("hour", int(12)),
2925                ("timezone", TemporalField::Str("Europe/Stockholm".into())),
2926            ]),
2927            "1984-10-11T12:00+01:00[Europe/Stockholm]"
2928        );
2929    }
2930
2931    #[test]
2932    fn duration_maps() {
2933        let d = |p: &[(&str, TemporalField)]| render_temporal_map("duration", &fields(p)).unwrap();
2934        assert_eq!(
2935            d(&[("days", int(14)), ("hours", int(16)), ("minutes", int(12))]),
2936            "P14DT16H12M"
2937        );
2938        assert_eq!(
2939            d(&[("months", int(5)), ("days", TemporalField::Float(1.5))]),
2940            "P5M1DT12H"
2941        );
2942        assert_eq!(
2943            d(&[("months", TemporalField::Float(0.75))]),
2944            "P22DT19H51M49.5S"
2945        );
2946        assert_eq!(d(&[("weeks", TemporalField::Float(2.5))]), "P17DT12H");
2947        assert_eq!(
2948            d(&[
2949                ("days", int(14)),
2950                ("seconds", int(70)),
2951                ("nanoseconds", int(1))
2952            ]),
2953            "P14DT1M10.000000001S"
2954        );
2955        assert_eq!(
2956            d(&[("minutes", TemporalField::Float(1.5)), ("seconds", int(1))]),
2957            "PT1M31S"
2958        );
2959    }
2960
2961    #[test]
2962    fn epoch_constructors() {
2963        assert_eq!(
2964            render_from_epoch(416_779, 999_999_999).unwrap(),
2965            "1970-01-05T19:46:19.999999999Z"
2966        );
2967        assert_eq!(
2968            render_from_epoch_millis(237_821_673_987).unwrap(),
2969            "1977-07-15T13:34:33.987Z"
2970        );
2971    }
2972
2973    #[test]
2974    fn rejects_malformed_temporals() {
2975        // Sub-nanosecond fraction (>9 digits) is rejected, not truncated.
2976        assert!(render_local_time("21:40:32.1234567890").is_none());
2977        // Signed/garbage offset components are rejected.
2978        assert!(render_time("21:40+01:-30").is_none());
2979        assert!(render_time("21:40+24:00").is_none());
2980        // A bare `P` duration has no components.
2981        assert!(render_duration("P").is_none());
2982    }
2983
2984    #[test]
2985    fn date_components() {
2986        // Temporal5 [1]: 1984-10-11.
2987        let d = parse_date_string("1984-10-11").unwrap();
2988        let c = |n| date_component(d, n).unwrap();
2989        assert_eq!(c("year"), 1984);
2990        assert_eq!(c("quarter"), 4);
2991        assert_eq!(c("month"), 10);
2992        assert_eq!(c("week"), 41);
2993        assert_eq!(c("weekYear"), 1984);
2994        assert_eq!(c("day"), 11);
2995        assert_eq!(c("ordinalDay"), 285);
2996        assert_eq!(c("weekDay"), 4);
2997        assert_eq!(c("dayOfQuarter"), 11);
2998        // Temporal5 [2]: 1984-01-01 falls in the last ISO week of 1983.
2999        let d2 = parse_date_string("1984-01-01").unwrap();
3000        assert_eq!(date_component(d2, "weekYear").unwrap(), 1983);
3001        assert_eq!(date_component(d2, "week").unwrap(), 52);
3002        assert_eq!(date_component(d2, "weekDay").unwrap(), 7);
3003        assert!(date_component(d, "bogus").is_none());
3004    }
3005}