Skip to main content

velr_types/
temporal.rs

1use crate::error::{DecodeError, EncodeError};
2use crate::property::{PropertyValue, PropertyValueRef};
3use jiff::civil::{Date, DateTime, Time};
4use jiff::tz::{Offset, TimeZone};
5use jiff::{Span, Timestamp};
6use std::fmt;
7use std::str::FromStr;
8
9pub const NANOS_PER_SECOND: i64 = 1_000_000_000;
10pub const NANOS_PER_MINUTE: i64 = 60 * NANOS_PER_SECOND;
11pub const NANOS_PER_HOUR: i64 = 60 * NANOS_PER_MINUTE;
12pub const NANOS_PER_DAY: i64 = 86_400 * NANOS_PER_SECOND;
13const DAYS_PER_AVERAGE_YEAR: f64 = 365.2425;
14const DAYS_PER_AVERAGE_MONTH: f64 = DAYS_PER_AVERAGE_YEAR / 12.0;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum CurrentTemporalKind {
18    Date,
19    LocalTime,
20    Time,
21    LocalDateTime,
22    DateTime,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
26pub struct DateValue {
27    inner: Date,
28}
29
30impl DateValue {
31    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, EncodeError> {
32        let year = i16::try_from(year)
33            .map_err(|_| EncodeError::InvalidTemporal("year must be in -9999..=9999"))?;
34        let month = i8::try_from(month)
35            .map_err(|_| EncodeError::InvalidTemporal("month must be in 1..=12"))?;
36        let day =
37            i8::try_from(day).map_err(|_| EncodeError::InvalidTemporal("day must be in 1..=31"))?;
38        let inner = Date::new(year, month, day)
39            .map_err(|_| EncodeError::InvalidTemporal("invalid calendar date"))?;
40        Ok(Self { inner })
41    }
42
43    pub fn from_calendar(
44        year: i32,
45        month: Option<u8>,
46        day: Option<u8>,
47    ) -> Result<Self, EncodeError> {
48        Self::new(year, month.unwrap_or(1), day.unwrap_or(1))
49    }
50
51    pub fn from_iso_week(
52        year: i32,
53        week: u8,
54        day_of_week: Option<u8>,
55    ) -> Result<Self, EncodeError> {
56        if week == 0 || week > 53 {
57            return Err(EncodeError::InvalidTemporal("week must be in 1..=53"));
58        }
59        let day_of_week = day_of_week.unwrap_or(1);
60        if !(1..=7).contains(&day_of_week) {
61            return Err(EncodeError::InvalidTemporal("dayOfWeek must be in 1..=7"));
62        }
63
64        let start = iso_week_one_monday_epoch_day(year)?;
65        let day = start + (i64::from(week) - 1) * 7 + (i64::from(day_of_week) - 1);
66        let value = Self::from_epoch_day(day)?;
67        if value.iso_week_year()? != year {
68            return Err(EncodeError::InvalidTemporal("invalid ISO week date"));
69        }
70        Ok(value)
71    }
72
73    pub fn from_ordinal_day(year: i32, ordinal_day: u16) -> Result<Self, EncodeError> {
74        let jan1 = Self::new(year, 1, 1)?;
75        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
76        if ordinal_day == 0 || ordinal_day > days_in_year {
77            return Err(EncodeError::InvalidTemporal(
78                "ordinalDay must be valid for year",
79            ));
80        }
81        Self::from_epoch_day(jan1.epoch_day() + i64::from(ordinal_day) - 1)
82    }
83
84    pub fn from_quarter(
85        year: i32,
86        quarter: u8,
87        day_of_quarter: Option<u16>,
88    ) -> Result<Self, EncodeError> {
89        if !(1..=4).contains(&quarter) {
90            return Err(EncodeError::InvalidTemporal("quarter must be in 1..=4"));
91        }
92        let day_of_quarter = day_of_quarter.unwrap_or(1);
93        if day_of_quarter == 0 {
94            return Err(EncodeError::InvalidTemporal(
95                "dayOfQuarter must be positive",
96            ));
97        }
98
99        let month = (quarter - 1) * 3 + 1;
100        let start = Self::new(year, month, 1)?;
101        let value = Self::from_epoch_day(start.epoch_day() + i64::from(day_of_quarter) - 1)?;
102        if value.year() != year || ((value.month() - 1) / 3) + 1 != quarter {
103            return Err(EncodeError::InvalidTemporal(
104                "dayOfQuarter must be valid for quarter",
105            ));
106        }
107        Ok(value)
108    }
109
110    pub fn from_epoch_day(epoch_day: i64) -> Result<Self, EncodeError> {
111        let (year, month, day) = ymd_from_epoch_day(epoch_day)?;
112        Self::new(year, month, day)
113    }
114
115    pub fn year(&self) -> i32 {
116        i32::from(self.inner.year())
117    }
118
119    pub fn month(&self) -> u8 {
120        self.inner.month() as u8
121    }
122
123    pub fn day(&self) -> u8 {
124        self.inner.day() as u8
125    }
126
127    pub fn as_jiff(&self) -> Date {
128        self.inner
129    }
130
131    pub fn epoch_day(&self) -> i64 {
132        epoch_day_from_ymd(self.year(), self.month(), self.day())
133    }
134
135    pub fn iso_week_year(&self) -> Result<i32, EncodeError> {
136        let day = self.epoch_day();
137        let year = self.year();
138        if day < iso_week_one_monday_epoch_day(year)? {
139            Ok(year - 1)
140        } else if day >= iso_week_one_monday_epoch_day(year + 1)? {
141            Ok(year + 1)
142        } else {
143            Ok(year)
144        }
145    }
146
147    pub fn iso_day_of_week(&self) -> u8 {
148        day_of_week_from_epoch_day(self.epoch_day())
149    }
150
151    pub fn iso_week(&self) -> Result<u8, EncodeError> {
152        let week_year = self.iso_week_year()?;
153        let week_one = iso_week_one_monday_epoch_day(week_year)?;
154        Ok(((self.epoch_day() - week_one) / 7 + 1) as u8)
155    }
156
157    pub fn quarter(&self) -> u8 {
158        (self.month() - 1) / 3 + 1
159    }
160
161    pub fn ordinal_day(&self) -> Result<u16, EncodeError> {
162        let jan1 = Self::new(self.year(), 1, 1)?;
163        Ok((self.epoch_day() - jan1.epoch_day() + 1) as u16)
164    }
165
166    pub fn day_of_quarter(&self) -> Result<u16, EncodeError> {
167        let start = Self::new(self.year(), (self.quarter() - 1) * 3 + 1, 1)?;
168        Ok((self.epoch_day() - start.epoch_day() + 1) as u16)
169    }
170
171    pub fn checked_add_duration(&self, duration: &DurationValue) -> Result<Self, EncodeError> {
172        self.checked_add_duration_groups(
173            duration.total_months(),
174            duration.total_days(),
175            duration.total_time_nanos(),
176        )
177    }
178
179    pub fn checked_sub_duration(&self, duration: &DurationValue) -> Result<Self, EncodeError> {
180        self.checked_add_duration_groups(
181            checked_i64_from_i128(-i128::from(duration.total_months()))?,
182            checked_i64_from_i128(-i128::from(duration.total_days()))?,
183            duration
184                .total_time_nanos()
185                .checked_neg()
186                .ok_or(EncodeError::InvalidTemporal("duration arithmetic overflow"))?,
187        )
188    }
189
190    fn checked_add_duration_groups(
191        &self,
192        months: i64,
193        days: i64,
194        time_nanos: i128,
195    ) -> Result<Self, EncodeError> {
196        let date = self.checked_add_months(months)?;
197        let time_days = checked_i64_from_i128(time_nanos / i128::from(NANOS_PER_DAY))?;
198        date.checked_add_days(
199            days.checked_add(time_days)
200                .ok_or(EncodeError::InvalidTemporal(
201                    "date duration arithmetic overflow",
202                ))?,
203        )
204    }
205
206    fn checked_add_months(&self, months: i64) -> Result<Self, EncodeError> {
207        let month_index = i128::from(self.year()) * 12 + i128::from(self.month()) - 1;
208        let target =
209            month_index
210                .checked_add(i128::from(months))
211                .ok_or(EncodeError::InvalidTemporal(
212                    "date duration arithmetic overflow",
213                ))?;
214        let year = checked_i32_from_i128(target.div_euclid(12))?;
215        let month = u8::try_from(target.rem_euclid(12) + 1)
216            .map_err(|_| EncodeError::InvalidTemporal("month must be in 1..=12"))?;
217        let day = self.day().min(days_in_month(year, month));
218        Self::new(year, month, day)
219    }
220
221    fn checked_add_days(&self, days: i64) -> Result<Self, EncodeError> {
222        let epoch_day = i128::from(self.epoch_day())
223            .checked_add(i128::from(days))
224            .ok_or(EncodeError::InvalidTemporal(
225                "date duration arithmetic overflow",
226            ))?;
227        Self::from_epoch_day(checked_i64_from_i128(epoch_day)?)
228    }
229}
230
231impl fmt::Display for DateValue {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        self.inner.fmt(f)
234    }
235}
236
237impl FromStr for DateValue {
238    type Err = DecodeError;
239
240    fn from_str(s: &str) -> Result<Self, Self::Err> {
241        parse_open_cypher_date(s)
242    }
243}
244
245#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
246pub struct LocalTimeValue {
247    inner: Time,
248}
249
250impl LocalTimeValue {
251    pub fn new(hour: u8, minute: u8, second: u8, nanos: u32) -> Result<Self, EncodeError> {
252        let hour = i8::try_from(hour)
253            .map_err(|_| EncodeError::InvalidTemporal("hour must be in 0..=23"))?;
254        let minute = i8::try_from(minute)
255            .map_err(|_| EncodeError::InvalidTemporal("minute must be in 0..=59"))?;
256        let second = i8::try_from(second)
257            .map_err(|_| EncodeError::InvalidTemporal("second must be in 0..=59"))?;
258        let nanos = i32::try_from(nanos)
259            .map_err(|_| EncodeError::InvalidTemporal("nanos must be < 1_000_000_000"))?;
260        let inner = Time::new(hour, minute, second, nanos)
261            .map_err(|_| EncodeError::InvalidTemporal("invalid wall clock time"))?;
262        Ok(Self { inner })
263    }
264
265    pub fn hour(&self) -> u8 {
266        self.inner.hour() as u8
267    }
268
269    pub fn minute(&self) -> u8 {
270        self.inner.minute() as u8
271    }
272
273    pub fn second(&self) -> u8 {
274        self.inner.second() as u8
275    }
276
277    pub fn nanos(&self) -> u32 {
278        self.inner.subsec_nanosecond() as u32
279    }
280
281    pub fn as_jiff(&self) -> Time {
282        self.inner
283    }
284
285    pub fn nanos_since_midnight(&self) -> i64 {
286        (((self.hour() as i64 * 60 + self.minute() as i64) * 60 + self.second() as i64)
287            * NANOS_PER_SECOND)
288            + self.nanos() as i64
289    }
290
291    pub fn from_nanos_since_midnight(nanos: i64) -> Result<Self, EncodeError> {
292        if !(0..NANOS_PER_DAY).contains(&nanos) {
293            return Err(EncodeError::InvalidTemporal(
294                "nanos since midnight must be in 0..<24h",
295            ));
296        }
297        let hour = nanos / NANOS_PER_HOUR;
298        let nanos = nanos % NANOS_PER_HOUR;
299        let minute = nanos / NANOS_PER_MINUTE;
300        let nanos = nanos % NANOS_PER_MINUTE;
301        let second = nanos / NANOS_PER_SECOND;
302        let nanos = nanos % NANOS_PER_SECOND;
303        Self::new(hour as u8, minute as u8, second as u8, nanos as u32)
304    }
305
306    pub fn checked_add_duration(&self, duration: &DurationValue) -> Result<Self, EncodeError> {
307        self.checked_add_time_nanos(duration.total_time_nanos())
308    }
309
310    pub fn checked_sub_duration(&self, duration: &DurationValue) -> Result<Self, EncodeError> {
311        self.checked_add_time_nanos(
312            duration
313                .total_time_nanos()
314                .checked_neg()
315                .ok_or(EncodeError::InvalidTemporal("duration arithmetic overflow"))?,
316        )
317    }
318
319    fn checked_add_time_nanos(&self, nanos: i128) -> Result<Self, EncodeError> {
320        let shifted = i128::from(self.nanos_since_midnight())
321            .checked_add(nanos)
322            .ok_or(EncodeError::InvalidTemporal(
323                "time duration arithmetic overflow",
324            ))?;
325        let wrapped = shifted.rem_euclid(i128::from(NANOS_PER_DAY));
326        Self::from_nanos_since_midnight(checked_i64_from_i128(wrapped)?)
327    }
328}
329
330impl fmt::Display for LocalTimeValue {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        self.inner.fmt(f)
333    }
334}
335
336impl FromStr for LocalTimeValue {
337    type Err = DecodeError;
338
339    fn from_str(s: &str) -> Result<Self, Self::Err> {
340        let inner = s
341            .parse::<Time>()
342            .map_err(|err| DecodeError::InvalidTemporal(err.to_string()))?;
343        Ok(Self { inner })
344    }
345}
346
347#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
348pub struct UtcOffsetValue {
349    seconds_east: i32,
350}
351
352impl UtcOffsetValue {
353    pub fn utc() -> Self {
354        Self { seconds_east: 0 }
355    }
356
357    pub fn new(seconds_east: i32) -> Result<Self, EncodeError> {
358        Offset::from_seconds(seconds_east)
359            .map_err(|_| EncodeError::InvalidTemporal("offset out of range"))?;
360        Ok(Self { seconds_east })
361    }
362
363    pub fn seconds_east(&self) -> i32 {
364        self.seconds_east
365    }
366
367    pub fn is_utc(&self) -> bool {
368        self.seconds_east == 0
369    }
370
371    pub fn as_jiff(&self) -> Offset {
372        Offset::from_seconds(self.seconds_east)
373            .expect("UtcOffsetValue should always store a validated offset")
374    }
375}
376
377impl fmt::Display for UtcOffsetValue {
378    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
379        if self.seconds_east == 0 {
380            return f.write_str("Z");
381        }
382        let sign = if self.seconds_east < 0 { '-' } else { '+' };
383        let abs = self.seconds_east.abs();
384        let hours = abs / 3600;
385        let minutes = (abs % 3600) / 60;
386        let seconds = abs % 60;
387        if seconds == 0 {
388            write!(f, "{sign}{hours:02}:{minutes:02}")
389        } else {
390            write!(f, "{sign}{hours:02}:{minutes:02}:{seconds:02}")
391        }
392    }
393}
394
395impl FromStr for UtcOffsetValue {
396    type Err = DecodeError;
397
398    fn from_str(s: &str) -> Result<Self, Self::Err> {
399        parse_offset(s)
400    }
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
404pub struct ZonedTimeValue {
405    pub time: LocalTimeValue,
406    pub offset: UtcOffsetValue,
407}
408
409impl ZonedTimeValue {
410    pub fn new(time: LocalTimeValue, offset: UtcOffsetValue) -> Self {
411        Self { time, offset }
412    }
413
414    pub fn utc_nanos_since_midnight(&self) -> i64 {
415        let local = self.time.nanos_since_midnight();
416        let offset = self.offset.seconds_east() as i64 * NANOS_PER_SECOND;
417        (local - offset).rem_euclid(NANOS_PER_DAY)
418    }
419
420    pub fn with_offset_same_instant(&self, offset: UtcOffsetValue) -> Result<Self, EncodeError> {
421        let local =
422            self.utc_nanos_since_midnight() + offset.seconds_east() as i64 * NANOS_PER_SECOND;
423        Ok(Self {
424            time: LocalTimeValue::from_nanos_since_midnight(local.rem_euclid(NANOS_PER_DAY))?,
425            offset,
426        })
427    }
428
429    pub fn checked_add_duration(&self, duration: &DurationValue) -> Result<Self, EncodeError> {
430        Ok(Self {
431            time: self.time.checked_add_duration(duration)?,
432            offset: self.offset,
433        })
434    }
435
436    pub fn checked_sub_duration(&self, duration: &DurationValue) -> Result<Self, EncodeError> {
437        Ok(Self {
438            time: self.time.checked_sub_duration(duration)?,
439            offset: self.offset,
440        })
441    }
442}
443
444impl fmt::Display for ZonedTimeValue {
445    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
446        write!(f, "{}{}", self.time, self.offset)
447    }
448}
449
450impl FromStr for ZonedTimeValue {
451    type Err = DecodeError;
452
453    fn from_str(s: &str) -> Result<Self, Self::Err> {
454        let idx = find_offset_start(s, false)
455            .ok_or_else(|| DecodeError::InvalidTemporal("missing zone offset".into()))?;
456        Ok(Self {
457            time: s[..idx].parse()?,
458            offset: s[idx..].parse()?,
459        })
460    }
461}
462
463#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
464pub struct LocalDateTimeValue {
465    inner: DateTime,
466}
467
468impl LocalDateTimeValue {
469    pub fn new(
470        year: i32,
471        month: u8,
472        day: u8,
473        hour: u8,
474        minute: u8,
475        second: u8,
476        nanos: u32,
477    ) -> Result<Self, EncodeError> {
478        let year = i16::try_from(year)
479            .map_err(|_| EncodeError::InvalidTemporal("year must be in -9999..=9999"))?;
480        let month = i8::try_from(month)
481            .map_err(|_| EncodeError::InvalidTemporal("month must be in 1..=12"))?;
482        let day =
483            i8::try_from(day).map_err(|_| EncodeError::InvalidTemporal("day must be in 1..=31"))?;
484        let hour = i8::try_from(hour)
485            .map_err(|_| EncodeError::InvalidTemporal("hour must be in 0..=23"))?;
486        let minute = i8::try_from(minute)
487            .map_err(|_| EncodeError::InvalidTemporal("minute must be in 0..=59"))?;
488        let second = i8::try_from(second)
489            .map_err(|_| EncodeError::InvalidTemporal("second must be in 0..=59"))?;
490        let nanos = i32::try_from(nanos)
491            .map_err(|_| EncodeError::InvalidTemporal("nanos must be < 1_000_000_000"))?;
492        let inner = DateTime::new(year, month, day, hour, minute, second, nanos)
493            .map_err(|_| EncodeError::InvalidTemporal("invalid local datetime"))?;
494        Ok(Self { inner })
495    }
496
497    pub fn from_date_and_time(date: DateValue, time: LocalTimeValue) -> Result<Self, EncodeError> {
498        Self::new(
499            date.year(),
500            date.month(),
501            date.day(),
502            time.hour(),
503            time.minute(),
504            time.second(),
505            time.nanos(),
506        )
507    }
508
509    pub fn date(&self) -> DateValue {
510        DateValue {
511            inner: self.inner.date(),
512        }
513    }
514
515    pub fn time(&self) -> LocalTimeValue {
516        LocalTimeValue {
517            inner: self.inner.time(),
518        }
519    }
520
521    pub fn as_jiff(&self) -> DateTime {
522        self.inner
523    }
524
525    pub fn local_epoch_day_and_nanos(&self) -> (i64, i64) {
526        let date = self.date();
527        let time = self.time();
528        (date.epoch_day(), time.nanos_since_midnight())
529    }
530
531    pub fn checked_add_duration(&self, duration: &DurationValue) -> Result<Self, EncodeError> {
532        self.checked_add_duration_groups(
533            duration.total_months(),
534            duration.total_days(),
535            duration.total_time_nanos(),
536        )
537    }
538
539    pub fn checked_sub_duration(&self, duration: &DurationValue) -> Result<Self, EncodeError> {
540        self.checked_add_duration_groups(
541            checked_i64_from_i128(-i128::from(duration.total_months()))?,
542            checked_i64_from_i128(-i128::from(duration.total_days()))?,
543            duration
544                .total_time_nanos()
545                .checked_neg()
546                .ok_or(EncodeError::InvalidTemporal("duration arithmetic overflow"))?,
547        )
548    }
549
550    fn checked_add_duration_groups(
551        &self,
552        months: i64,
553        days: i64,
554        time_nanos: i128,
555    ) -> Result<Self, EncodeError> {
556        let date = self.date().checked_add_months(months)?;
557        let shifted_time = i128::from(self.time().nanos_since_midnight())
558            .checked_add(time_nanos)
559            .ok_or(EncodeError::InvalidTemporal(
560                "datetime duration arithmetic overflow",
561            ))?;
562        let time_days = shifted_time.div_euclid(i128::from(NANOS_PER_DAY));
563        let time_nanos = shifted_time.rem_euclid(i128::from(NANOS_PER_DAY));
564        let epoch_day = i128::from(date.epoch_day())
565            .checked_add(i128::from(days))
566            .and_then(|value| value.checked_add(time_days))
567            .ok_or(EncodeError::InvalidTemporal(
568                "datetime duration arithmetic overflow",
569            ))?;
570        let date = DateValue::from_epoch_day(checked_i64_from_i128(epoch_day)?)?;
571        let time = LocalTimeValue::from_nanos_since_midnight(checked_i64_from_i128(time_nanos)?)?;
572        Self::from_date_and_time(date, time)
573    }
574}
575
576impl fmt::Display for LocalDateTimeValue {
577    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
578        self.inner.fmt(f)
579    }
580}
581
582impl FromStr for LocalDateTimeValue {
583    type Err = DecodeError;
584
585    fn from_str(s: &str) -> Result<Self, Self::Err> {
586        match s.parse::<DateTime>() {
587            Ok(inner) => Ok(Self { inner }),
588            Err(parse_err) => {
589                let (date, time) = s.split_once('T').ok_or_else(|| {
590                    DecodeError::InvalidTemporal(format!("invalid local datetime: {parse_err}"))
591                })?;
592                Self::from_date_and_time(date.parse()?, time.parse()?)
593                    .map_err(|err| DecodeError::InvalidTemporal(err.to_string()))
594            }
595        }
596    }
597}
598
599#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
600pub struct ZonedDateTimeValue {
601    pub datetime: LocalDateTimeValue,
602    pub offset: UtcOffsetValue,
603    pub zone_id: Option<Box<str>>,
604}
605
606impl ZonedDateTimeValue {
607    pub fn new(
608        datetime: LocalDateTimeValue,
609        offset: UtcOffsetValue,
610        zone_id: Option<impl Into<Box<str>>>,
611    ) -> Result<Self, EncodeError> {
612        let zone_id = zone_id.map(Into::into);
613        if let Some(zone_id_str) = zone_id.as_deref() {
614            validate_zoned_components(&datetime, &offset, zone_id_str)
615                .map_err(|_| EncodeError::InvalidTemporal("invalid zone id or offset mismatch"))?;
616        }
617        Ok(Self {
618            datetime,
619            offset,
620            zone_id,
621        })
622    }
623
624    pub fn in_named_zone(
625        datetime: LocalDateTimeValue,
626        zone_id: impl Into<Box<str>>,
627    ) -> Result<Self, EncodeError> {
628        let zone_id = zone_id.into();
629        let zoned = datetime
630            .as_jiff()
631            .in_tz(zone_id.as_ref())
632            .map_err(|_| EncodeError::InvalidTemporal("invalid zone id or local datetime"))?;
633        Ok(Self {
634            datetime,
635            offset: UtcOffsetValue::new(zoned.offset().seconds())?,
636            zone_id: Some(zone_id),
637        })
638    }
639
640    pub fn from_epoch(seconds: i64, nanoseconds: u32) -> Result<Self, EncodeError> {
641        let nanoseconds = i32::try_from(nanoseconds)
642            .map_err(|_| EncodeError::InvalidTemporal("nanoseconds out of range"))?;
643        let timestamp = Timestamp::new(seconds, nanoseconds)
644            .map_err(|_| EncodeError::InvalidTemporal("epoch datetime out of range"))?;
645        Ok(Self::from_utc_timestamp(timestamp))
646    }
647
648    pub fn from_epoch_millis(milliseconds: i64) -> Result<Self, EncodeError> {
649        let timestamp = Timestamp::from_millisecond(milliseconds)
650            .map_err(|_| EncodeError::InvalidTemporal("epoch datetime out of range"))?;
651        Ok(Self::from_utc_timestamp(timestamp))
652    }
653
654    fn from_utc_timestamp(timestamp: Timestamp) -> Self {
655        let zoned = timestamp.to_zoned(TimeZone::UTC);
656        Self {
657            datetime: LocalDateTimeValue {
658                inner: zoned.datetime(),
659            },
660            offset: UtcOffsetValue::utc(),
661            zone_id: None,
662        }
663    }
664
665    pub fn utc_epoch_day_and_nanos(&self) -> (i64, i64) {
666        let (day, nanos) = self.datetime.local_epoch_day_and_nanos();
667        let offset = self.offset.seconds_east() as i64 * NANOS_PER_SECOND;
668        let shifted = nanos - offset;
669        (
670            day + shifted.div_euclid(NANOS_PER_DAY),
671            shifted.rem_euclid(NANOS_PER_DAY),
672        )
673    }
674
675    pub fn with_offset_same_instant(&self, offset: UtcOffsetValue) -> Result<Self, EncodeError> {
676        let (day, nanos) = self.utc_epoch_day_and_nanos();
677        Self::from_utc_epoch_day_and_nanos(day, nanos, offset, None::<Box<str>>)
678    }
679
680    #[cfg(any(feature = "tzdb-system", feature = "tzdb-bundled"))]
681    pub fn with_named_zone_same_instant(
682        &self,
683        zone_id: impl Into<Box<str>>,
684    ) -> Result<Self, EncodeError> {
685        let zone_id = zone_id.into();
686        let (day, nanos) = self.utc_epoch_day_and_nanos();
687        let timestamp = timestamp_from_epoch_day_and_nanos(day, nanos)?;
688        let zoned = timestamp
689            .in_tz(zone_id.as_ref())
690            .map_err(|_| EncodeError::InvalidTemporal("invalid zone id"))?;
691        Ok(Self {
692            datetime: LocalDateTimeValue {
693                inner: zoned.datetime(),
694            },
695            offset: UtcOffsetValue::new(zoned.offset().seconds())?,
696            zone_id: Some(zone_id),
697        })
698    }
699
700    #[cfg(not(any(feature = "tzdb-system", feature = "tzdb-bundled")))]
701    pub fn with_named_zone_same_instant(
702        &self,
703        _zone_id: impl Into<Box<str>>,
704    ) -> Result<Self, EncodeError> {
705        Err(EncodeError::InvalidTemporal("named zones are not enabled"))
706    }
707
708    fn from_utc_epoch_day_and_nanos(
709        day: i64,
710        nanos: i64,
711        offset: UtcOffsetValue,
712        zone_id: Option<impl Into<Box<str>>>,
713    ) -> Result<Self, EncodeError> {
714        let shifted = i128::from(day) * i128::from(NANOS_PER_DAY)
715            + i128::from(nanos)
716            + i128::from(offset.seconds_east()) * i128::from(NANOS_PER_SECOND);
717        let local_day = shifted.div_euclid(i128::from(NANOS_PER_DAY));
718        let local_nanos = shifted.rem_euclid(i128::from(NANOS_PER_DAY));
719        let date = DateValue::from_epoch_day(checked_i64_from_i128(local_day)?)?;
720        let time = LocalTimeValue::from_nanos_since_midnight(checked_i64_from_i128(local_nanos)?)?;
721        Ok(Self {
722            datetime: LocalDateTimeValue::from_date_and_time(date, time)?,
723            offset,
724            zone_id: zone_id.map(Into::into),
725        })
726    }
727
728    pub fn checked_add_duration(&self, duration: &DurationValue) -> Result<Self, EncodeError> {
729        if self.zone_id.is_some() {
730            return Err(EncodeError::InvalidTemporal(
731                "named-zone datetime duration arithmetic is not implemented",
732            ));
733        }
734        Ok(Self {
735            datetime: self.datetime.checked_add_duration(duration)?,
736            offset: self.offset,
737            zone_id: None,
738        })
739    }
740
741    pub fn checked_sub_duration(&self, duration: &DurationValue) -> Result<Self, EncodeError> {
742        if self.zone_id.is_some() {
743            return Err(EncodeError::InvalidTemporal(
744                "named-zone datetime duration arithmetic is not implemented",
745            ));
746        }
747        Ok(Self {
748            datetime: self.datetime.checked_sub_duration(duration)?,
749            offset: self.offset,
750            zone_id: None,
751        })
752    }
753}
754
755impl fmt::Display for ZonedDateTimeValue {
756    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
757        write!(f, "{}{}", self.datetime, self.offset)?;
758        if let Some(zone_id) = &self.zone_id {
759            write!(f, "[{zone_id}]")?;
760        }
761        Ok(())
762    }
763}
764
765impl FromStr for ZonedDateTimeValue {
766    type Err = DecodeError;
767
768    fn from_str(s: &str) -> Result<Self, Self::Err> {
769        let (main, zone_id) = split_zone_suffix(s)?;
770        let value = match find_offset_start(main, true) {
771            Some(idx) => {
772                #[cfg(any(feature = "tzdb-system", feature = "tzdb-bundled"))]
773                if zone_id.is_some() {
774                    return parse_zoned_datetime_with_jiff(s);
775                }
776
777                let value = Self {
778                    datetime: main[..idx].parse()?,
779                    offset: main[idx..].parse()?,
780                    zone_id: zone_id.map(Into::into),
781                };
782                if let Some(zone_id) = value.zone_id.as_deref() {
783                    validate_zoned_components(&value.datetime, &value.offset, zone_id)
784                        .map_err(DecodeError::InvalidTemporal)?;
785                }
786                value
787            }
788            None => {
789                let Some(zone_id) = zone_id else {
790                    return Err(DecodeError::InvalidTemporal(
791                        "missing datetime offset".into(),
792                    ));
793                };
794                Self::in_named_zone(main.parse()?, zone_id)
795                    .map_err(|err| DecodeError::InvalidTemporal(err.to_string()))?
796            }
797        };
798        Ok(value)
799    }
800}
801
802pub fn current_temporal_value(
803    kind: CurrentTemporalKind,
804    epoch_millis: i64,
805    timezone: Option<&str>,
806) -> Result<PropertyValue, EncodeError> {
807    let timestamp = Timestamp::from_millisecond(epoch_millis)
808        .map_err(|_| EncodeError::InvalidTemporal("epoch datetime out of range"))?;
809    let (timezone, zone_id) = current_time_zone(timezone)?;
810    let zoned = timestamp.to_zoned(timezone);
811    let date = DateValue {
812        inner: zoned.date(),
813    };
814    let time = LocalTimeValue {
815        inner: zoned.time(),
816    };
817    let offset = UtcOffsetValue::new(zoned.offset().seconds())?;
818    Ok(match kind {
819        CurrentTemporalKind::Date => PropertyValue::Date(date),
820        CurrentTemporalKind::LocalTime => PropertyValue::LocalTime(time),
821        CurrentTemporalKind::Time => PropertyValue::ZonedTime(ZonedTimeValue::new(time, offset)),
822        CurrentTemporalKind::LocalDateTime => PropertyValue::LocalDateTime(LocalDateTimeValue {
823            inner: zoned.datetime(),
824        }),
825        CurrentTemporalKind::DateTime => PropertyValue::ZonedDateTime(ZonedDateTimeValue {
826            datetime: LocalDateTimeValue {
827                inner: zoned.datetime(),
828            },
829            offset,
830            zone_id,
831        }),
832    })
833}
834
835fn current_time_zone(timezone: Option<&str>) -> Result<(TimeZone, Option<Box<str>>), EncodeError> {
836    let Some(timezone) = timezone else {
837        let timezone = TimeZone::system();
838        let zone_id = timezone.iana_name().map(Box::<str>::from);
839        return Ok((timezone, zone_id));
840    };
841
842    if let Ok(offset) = timezone.parse::<UtcOffsetValue>() {
843        return Ok((TimeZone::fixed(offset.as_jiff()), None));
844    }
845
846    let timezone =
847        TimeZone::get(timezone).map_err(|_| EncodeError::InvalidTemporal("invalid zone id"))?;
848    let zone_id = timezone.iana_name().map(Box::<str>::from);
849    Ok((timezone, zone_id))
850}
851
852#[derive(Debug, Clone, Copy, Default)]
853pub struct DurationMapParts {
854    pub years: f64,
855    pub months: f64,
856    pub weeks: f64,
857    pub days: f64,
858    pub hours: f64,
859    pub minutes: f64,
860    pub seconds: f64,
861    pub milliseconds: f64,
862    pub microseconds: f64,
863    pub nanoseconds: f64,
864}
865
866#[derive(Debug, Clone, Copy, Default)]
867struct DurationIntegerParts {
868    years: i64,
869    months: i64,
870    weeks: i64,
871    days: i64,
872    hours: i64,
873    minutes: i64,
874    seconds: i64,
875}
876
877#[derive(Debug, Clone)]
878pub struct DurationValue {
879    inner: Span,
880    months: i64,
881    days: i64,
882    time_nanos: i128,
883}
884
885impl DurationValue {
886    pub fn zero() -> Self {
887        Self {
888            inner: Span::default(),
889            months: 0,
890            days: 0,
891            time_nanos: 0,
892        }
893    }
894
895    #[allow(clippy::too_many_arguments)]
896    pub fn from_parts(
897        years: i64,
898        months: i64,
899        weeks: i64,
900        days: i64,
901        hours: i64,
902        minutes: i64,
903        seconds: i64,
904        milliseconds: i64,
905        microseconds: i64,
906        nanoseconds: i64,
907    ) -> Result<Self, EncodeError> {
908        let months_total = checked_i64_from_i128(i128::from(years) * 12_i128 + i128::from(months))?;
909        let days_total = checked_i64_from_i128(i128::from(weeks) * 7_i128 + i128::from(days))?;
910        let time_nanos = i128::from(hours) * i128::from(NANOS_PER_HOUR)
911            + i128::from(minutes) * i128::from(NANOS_PER_MINUTE)
912            + i128::from(seconds) * i128::from(NANOS_PER_SECOND)
913            + i128::from(milliseconds) * 1_000_000
914            + i128::from(microseconds) * 1_000
915            + i128::from(nanoseconds);
916        // jiff::Span has narrower field ranges than openCypher durations.
917        // The group totals below are authoritative for display, encoding,
918        // equality, and arithmetic; keep Span only as best-effort compatibility.
919        let inner = Span::new()
920            .try_years(years)
921            .and_then(|s| s.try_months(months))
922            .and_then(|s| s.try_weeks(weeks))
923            .and_then(|s| s.try_days(days))
924            .and_then(|s| s.try_hours(hours))
925            .and_then(|s| s.try_minutes(minutes))
926            .and_then(|s| s.try_seconds(seconds))
927            .and_then(|s| s.try_milliseconds(milliseconds))
928            .and_then(|s| s.try_microseconds(microseconds))
929            .and_then(|s| s.try_nanoseconds(nanoseconds))
930            .unwrap_or_else(|_| Span::default());
931        Ok(Self {
932            inner,
933            months: months_total,
934            days: days_total,
935            time_nanos,
936        })
937    }
938
939    pub fn from_open_cypher_parts(parts: DurationMapParts) -> Result<Self, EncodeError> {
940        let (years, years_fraction) = split_whole_fraction(parts.years)?;
941        let months_value = parts.months + years_fraction * 12.0;
942        let (months, months_fraction) = split_whole_fraction(months_value)?;
943
944        let days_value = months_fraction * DAYS_PER_AVERAGE_MONTH + parts.weeks * 7.0 + parts.days;
945        let (whole_days, day_fraction) = split_whole_fraction(days_value)?;
946
947        let nanos_value = day_fraction * NANOS_PER_DAY as f64
948            + parts.hours * NANOS_PER_HOUR as f64
949            + parts.minutes * NANOS_PER_MINUTE as f64
950            + parts.seconds * NANOS_PER_SECOND as f64
951            + parts.milliseconds * 1_000_000.0
952            + parts.microseconds * 1_000.0
953            + parts.nanoseconds;
954        if !nanos_value.is_finite() {
955            return Err(EncodeError::InvalidTemporal(
956                "duration field must be finite",
957            ));
958        }
959        let total_nanos = checked_i128_from_rounded_f64(nanos_value)?;
960        let days = whole_days;
961        let (hours, minutes, seconds, nanoseconds) = split_time_nanos(total_nanos)?;
962
963        Self::from_parts(
964            years,
965            months,
966            0,
967            days,
968            hours,
969            minutes,
970            seconds,
971            0,
972            0,
973            nanoseconds,
974        )
975    }
976
977    pub fn years(&self) -> i64 {
978        self.months / 12
979    }
980
981    pub fn months(&self) -> i64 {
982        self.months % 12
983    }
984
985    pub fn weeks(&self) -> i64 {
986        0
987    }
988
989    pub fn days(&self) -> i64 {
990        self.days
991    }
992
993    pub fn hours(&self) -> i64 {
994        split_time_nanos(self.time_nanos)
995            .expect("DurationValue should store checked time")
996            .0
997    }
998
999    pub fn minutes(&self) -> i64 {
1000        split_time_nanos(self.time_nanos)
1001            .expect("DurationValue should store checked time")
1002            .1
1003    }
1004
1005    pub fn seconds(&self) -> i64 {
1006        split_time_nanos(self.time_nanos)
1007            .expect("DurationValue should store checked time")
1008            .2
1009    }
1010
1011    pub fn milliseconds(&self) -> i64 {
1012        self.nanoseconds() / 1_000_000
1013    }
1014
1015    pub fn microseconds(&self) -> i64 {
1016        self.nanoseconds() / 1_000
1017    }
1018
1019    pub fn nanoseconds(&self) -> i64 {
1020        split_time_nanos(self.time_nanos)
1021            .expect("DurationValue should store checked time")
1022            .3
1023    }
1024
1025    pub fn total_months(&self) -> i64 {
1026        self.months
1027    }
1028
1029    pub fn total_quarters(&self) -> i64 {
1030        self.total_months() / 3
1031    }
1032
1033    pub fn total_weeks(&self) -> i64 {
1034        self.total_days() / 7
1035    }
1036
1037    pub fn total_days(&self) -> i64 {
1038        self.days
1039    }
1040
1041    pub fn total_time_nanos(&self) -> i128 {
1042        self.time_nanos
1043    }
1044
1045    pub fn as_jiff(&self) -> Option<&Span> {
1046        Self::from_jiff_span(self.inner.clone())
1047            .ok()
1048            .filter(|value| value == self)
1049            .map(|_| &self.inner)
1050    }
1051
1052    pub fn checked_add_duration(&self, other: &DurationValue) -> Result<Self, EncodeError> {
1053        let months = checked_i64_from_i128(i128::from(self.months) + i128::from(other.months))?;
1054        let days = checked_i64_from_i128(i128::from(self.days) + i128::from(other.days))?;
1055        let time_nanos = self
1056            .time_nanos
1057            .checked_add(other.time_nanos)
1058            .ok_or(EncodeError::InvalidTemporal("duration arithmetic overflow"))?;
1059        Self::from_groups(months, days, time_nanos)
1060    }
1061
1062    pub fn checked_sub_duration(&self, other: &DurationValue) -> Result<Self, EncodeError> {
1063        let months = checked_i64_from_i128(i128::from(self.months) - i128::from(other.months))?;
1064        let days = checked_i64_from_i128(i128::from(self.days) - i128::from(other.days))?;
1065        let time_nanos = self
1066            .time_nanos
1067            .checked_sub(other.time_nanos)
1068            .ok_or(EncodeError::InvalidTemporal("duration arithmetic overflow"))?;
1069        Self::from_groups(months, days, time_nanos)
1070    }
1071
1072    pub fn checked_mul_number(&self, factor: f64) -> Result<Self, EncodeError> {
1073        self.checked_scale(factor)
1074    }
1075
1076    pub fn checked_div_number(&self, divisor: f64) -> Result<Self, EncodeError> {
1077        if divisor == 0.0 {
1078            return Err(EncodeError::InvalidTemporal("duration division by zero"));
1079        }
1080        self.checked_scale(1.0 / divisor)
1081    }
1082
1083    fn checked_scale(&self, factor: f64) -> Result<Self, EncodeError> {
1084        if !factor.is_finite() {
1085            return Err(EncodeError::InvalidTemporal(
1086                "duration arithmetic factor must be finite",
1087            ));
1088        }
1089
1090        let months_value = self.months as f64 * factor;
1091        let (months, months_fraction) = split_whole_fraction(months_value)?;
1092        let days_value = self.days as f64 * factor + months_fraction * DAYS_PER_AVERAGE_MONTH;
1093        let (days, day_fraction) = split_whole_fraction(days_value)?;
1094        let nanos_value = self.time_nanos as f64 * factor + day_fraction * NANOS_PER_DAY as f64;
1095        if !nanos_value.is_finite() {
1096            return Err(EncodeError::InvalidTemporal(
1097                "duration field must be finite",
1098            ));
1099        }
1100        let time_nanos = checked_i128_from_rounded_f64(nanos_value)?;
1101        Self::from_groups(months, days, time_nanos)
1102    }
1103
1104    fn from_groups(months: i64, days: i64, time_nanos: i128) -> Result<Self, EncodeError> {
1105        let years = months / 12;
1106        let months = months % 12;
1107        let (hours, minutes, seconds, nanoseconds) = split_time_nanos(time_nanos)?;
1108        Self::from_parts(
1109            years,
1110            months,
1111            0,
1112            days,
1113            hours,
1114            minutes,
1115            seconds,
1116            0,
1117            0,
1118            nanoseconds,
1119        )
1120    }
1121
1122    fn from_jiff_span(inner: Span) -> Result<Self, EncodeError> {
1123        let months = i64::from(inner.get_years()) * 12 + i64::from(inner.get_months());
1124        let days = i64::from(inner.get_weeks()) * 7 + i64::from(inner.get_days());
1125        let time_nanos = i128::from(inner.get_hours()) * i128::from(NANOS_PER_HOUR)
1126            + i128::from(inner.get_minutes()) * i128::from(NANOS_PER_MINUTE)
1127            + i128::from(inner.get_seconds()) * i128::from(NANOS_PER_SECOND)
1128            + i128::from(inner.get_milliseconds()) * 1_000_000
1129            + i128::from(inner.get_microseconds()) * 1_000
1130            + i128::from(inner.get_nanoseconds());
1131        Ok(Self {
1132            inner,
1133            months,
1134            days,
1135            time_nanos,
1136        })
1137    }
1138}
1139
1140impl fmt::Display for DurationValue {
1141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1142        let months = self.total_months();
1143        let days = self.total_days();
1144        let nanos = self.total_time_nanos();
1145
1146        if months == 0 && days == 0 && nanos == 0 {
1147            return f.write_str("PT0S");
1148        }
1149
1150        f.write_str("P")?;
1151
1152        let years = months / 12;
1153        let months_of_year = months % 12;
1154        if years != 0 {
1155            write!(f, "{years}Y")?;
1156        }
1157        if months_of_year != 0 {
1158            write!(f, "{months_of_year}M")?;
1159        }
1160        if days != 0 {
1161            write!(f, "{days}D")?;
1162        }
1163
1164        let (hours, minutes, seconds, nanoseconds) =
1165            split_time_nanos(nanos).map_err(|_| fmt::Error)?;
1166        if hours != 0 || minutes != 0 || seconds != 0 || nanoseconds != 0 {
1167            f.write_str("T")?;
1168            if hours != 0 {
1169                write!(f, "{hours}H")?;
1170            }
1171            if minutes != 0 {
1172                write!(f, "{minutes}M")?;
1173            }
1174            if seconds != 0 || nanoseconds != 0 {
1175                write_duration_seconds(f, seconds, nanoseconds)?;
1176            }
1177        }
1178
1179        Ok(())
1180    }
1181}
1182
1183impl FromStr for DurationValue {
1184    type Err = DecodeError;
1185
1186    fn from_str(s: &str) -> Result<Self, Self::Err> {
1187        parse_open_cypher_duration(s).or_else(|_| {
1188            let inner = s
1189                .parse::<Span>()
1190                .map_err(|err| DecodeError::InvalidTemporal(err.to_string()))?;
1191            Self::from_jiff_span(inner).map_err(|err| DecodeError::InvalidTemporal(err.to_string()))
1192        })
1193    }
1194}
1195
1196impl PartialEq for DurationValue {
1197    fn eq(&self, other: &Self) -> bool {
1198        self.total_months() == other.total_months()
1199            && self.total_days() == other.total_days()
1200            && self.total_time_nanos() == other.total_time_nanos()
1201    }
1202}
1203
1204impl Eq for DurationValue {}
1205
1206pub fn temporal_component(
1207    value: PropertyValueRef<'_>,
1208    key: &str,
1209) -> Result<Option<PropertyValue>, EncodeError> {
1210    match value {
1211        PropertyValueRef::Null => Ok(Some(PropertyValue::Null)),
1212        PropertyValueRef::Date(value) => date_component(value, key),
1213        PropertyValueRef::LocalTime(value) => local_time_component(value, key),
1214        PropertyValueRef::ZonedTime(value) => zoned_time_component(value, key),
1215        PropertyValueRef::LocalDateTime(value) => local_datetime_component(value, key),
1216        PropertyValueRef::ZonedDateTime(value) => zoned_datetime_component(value, key),
1217        PropertyValueRef::Duration(value) => duration_component(value, key),
1218        _ => Ok(None),
1219    }
1220}
1221
1222fn integer(value: impl Into<i64>) -> PropertyValue {
1223    PropertyValue::Integer(value.into())
1224}
1225
1226fn string(value: impl Into<String>) -> PropertyValue {
1227    PropertyValue::String(value.into())
1228}
1229
1230fn date_component(value: &DateValue, key: &str) -> Result<Option<PropertyValue>, EncodeError> {
1231    let component = match key {
1232        "year" => integer(value.year() as i64),
1233        "quarter" => integer(value.quarter() as i64),
1234        "month" => integer(value.month() as i64),
1235        "week" => integer(value.iso_week()? as i64),
1236        "weekYear" => integer(value.iso_week_year()? as i64),
1237        "day" => integer(value.day() as i64),
1238        "ordinalDay" => integer(value.ordinal_day()? as i64),
1239        "weekDay" => integer(value.iso_day_of_week() as i64),
1240        "dayOfQuarter" => integer(value.day_of_quarter()? as i64),
1241        _ => return Ok(None),
1242    };
1243    Ok(Some(component))
1244}
1245
1246fn local_time_component(
1247    value: &LocalTimeValue,
1248    key: &str,
1249) -> Result<Option<PropertyValue>, EncodeError> {
1250    let nanos = value.nanos();
1251    let component = match key {
1252        "hour" => integer(value.hour() as i64),
1253        "minute" => integer(value.minute() as i64),
1254        "second" => integer(value.second() as i64),
1255        "millisecond" => integer((nanos / 1_000_000) as i64),
1256        "microsecond" => integer((nanos / 1_000) as i64),
1257        "nanosecond" => integer(nanos as i64),
1258        _ => return Ok(None),
1259    };
1260    Ok(Some(component))
1261}
1262
1263fn zoned_time_component(
1264    value: &ZonedTimeValue,
1265    key: &str,
1266) -> Result<Option<PropertyValue>, EncodeError> {
1267    if let Some(component) = local_time_component(&value.time, key)? {
1268        return Ok(Some(component));
1269    }
1270    let component = match key {
1271        "timezone" | "offset" => string(value.offset.to_string()),
1272        "offsetMinutes" => integer(i64::from(value.offset.seconds_east() / 60)),
1273        "offsetSeconds" => integer(i64::from(value.offset.seconds_east())),
1274        _ => return Ok(None),
1275    };
1276    Ok(Some(component))
1277}
1278
1279fn local_datetime_component(
1280    value: &LocalDateTimeValue,
1281    key: &str,
1282) -> Result<Option<PropertyValue>, EncodeError> {
1283    if let Some(component) = date_component(&value.date(), key)? {
1284        return Ok(Some(component));
1285    }
1286    local_time_component(&value.time(), key)
1287}
1288
1289fn zoned_datetime_component(
1290    value: &ZonedDateTimeValue,
1291    key: &str,
1292) -> Result<Option<PropertyValue>, EncodeError> {
1293    if let Some(component) = local_datetime_component(&value.datetime, key)? {
1294        return Ok(Some(component));
1295    }
1296    let component = match key {
1297        "timezone" => string(
1298            value
1299                .zone_id
1300                .as_deref()
1301                .map(str::to_owned)
1302                .unwrap_or_else(|| value.offset.to_string()),
1303        ),
1304        "offset" => string(value.offset.to_string()),
1305        "offsetMinutes" => integer(i64::from(value.offset.seconds_east() / 60)),
1306        "offsetSeconds" => integer(i64::from(value.offset.seconds_east())),
1307        "epochSeconds" => integer(zoned_datetime_epoch_seconds(value)?),
1308        "epochMillis" => integer(zoned_datetime_epoch_millis(value)?),
1309        _ => return Ok(None),
1310    };
1311    Ok(Some(component))
1312}
1313
1314fn zoned_datetime_epoch_seconds(value: &ZonedDateTimeValue) -> Result<i64, EncodeError> {
1315    let (day, nanos) = value.utc_epoch_day_and_nanos();
1316    checked_i64_from_i128(i128::from(day) * 86_400 + i128::from(nanos / NANOS_PER_SECOND))
1317}
1318
1319fn zoned_datetime_epoch_millis(value: &ZonedDateTimeValue) -> Result<i64, EncodeError> {
1320    let (day, nanos) = value.utc_epoch_day_and_nanos();
1321    checked_i64_from_i128(i128::from(day) * 86_400_000 + i128::from(nanos / 1_000_000))
1322}
1323
1324fn duration_component(
1325    value: &DurationValue,
1326    key: &str,
1327) -> Result<Option<PropertyValue>, EncodeError> {
1328    let months = value.total_months();
1329    let days = value.total_days();
1330    let nanos = value.total_time_nanos();
1331    let nanos_per_hour = i128::from(NANOS_PER_HOUR);
1332    let nanos_per_minute = i128::from(NANOS_PER_MINUTE);
1333    let nanos_per_second = i128::from(NANOS_PER_SECOND);
1334    let component = match key {
1335        "years" => integer(months / 12),
1336        "quarters" => integer(value.total_quarters()),
1337        "months" => integer(months),
1338        "weeks" => integer(value.total_weeks()),
1339        "days" => integer(days),
1340        "hours" => integer(checked_i64_from_i128(nanos.div_euclid(nanos_per_hour))?),
1341        "minutes" => integer(checked_i64_from_i128(nanos.div_euclid(nanos_per_minute))?),
1342        "seconds" => integer(checked_i64_from_i128(nanos.div_euclid(nanos_per_second))?),
1343        "milliseconds" => integer(checked_i64_from_i128(nanos.div_euclid(1_000_000))?),
1344        "microseconds" => integer(checked_i64_from_i128(nanos.div_euclid(1_000))?),
1345        "nanoseconds" => integer(checked_i64_from_i128(nanos)?),
1346        "quartersOfYear" => integer((months % 12) / 3),
1347        "monthsOfQuarter" => integer(months % 3),
1348        "monthsOfYear" => integer(months % 12),
1349        "daysOfWeek" => integer(days % 7),
1350        "minutesOfHour" => integer(checked_i64_from_i128(
1351            nanos.div_euclid(nanos_per_minute).rem_euclid(60),
1352        )?),
1353        "secondsOfMinute" => integer(checked_i64_from_i128(
1354            nanos.div_euclid(nanos_per_second).rem_euclid(60),
1355        )?),
1356        "millisecondsOfSecond" => integer(checked_i64_from_i128(
1357            nanos.div_euclid(1_000_000).rem_euclid(1000),
1358        )?),
1359        "microsecondsOfSecond" => integer(checked_i64_from_i128(
1360            nanos.div_euclid(1_000).rem_euclid(1_000_000),
1361        )?),
1362        "nanosecondsOfSecond" => {
1363            integer(checked_i64_from_i128(nanos.rem_euclid(nanos_per_second))?)
1364        }
1365        _ => return Ok(None),
1366    };
1367    Ok(Some(component))
1368}
1369
1370fn epoch_day_from_ymd(year: i32, month: u8, day: u8) -> i64 {
1371    let month = month as i64;
1372    let day = day as i64;
1373    let year = year as i64 - i64::from(month <= 2);
1374    let era = if year >= 0 { year } else { year - 399 } / 400;
1375    let year_of_era = year - era * 400;
1376    let month_prime = month + if month > 2 { -3 } else { 9 };
1377    let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
1378    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
1379    era * 146_097 + day_of_era - 719_468
1380}
1381
1382fn ymd_from_epoch_day(epoch_day: i64) -> Result<(i32, u8, u8), EncodeError> {
1383    let z = epoch_day + 719_468;
1384    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1385    let day_of_era = z - era * 146_097;
1386    let year_of_era =
1387        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
1388    let mut year = year_of_era + era * 400;
1389    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
1390    let month_prime = (5 * day_of_year + 2) / 153;
1391    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
1392    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
1393    year += i64::from(month <= 2);
1394
1395    let year = i32::try_from(year)
1396        .map_err(|_| EncodeError::InvalidTemporal("year must be in -9999..=9999"))?;
1397    let month =
1398        u8::try_from(month).map_err(|_| EncodeError::InvalidTemporal("month must be in 1..=12"))?;
1399    let day =
1400        u8::try_from(day).map_err(|_| EncodeError::InvalidTemporal("day must be in 1..=31"))?;
1401    Ok((year, month, day))
1402}
1403
1404fn is_leap_year(year: i32) -> bool {
1405    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
1406}
1407
1408fn days_in_month(year: i32, month: u8) -> u8 {
1409    match month {
1410        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1411        4 | 6 | 9 | 11 => 30,
1412        2 if is_leap_year(year) => 29,
1413        2 => 28,
1414        _ => 31,
1415    }
1416}
1417
1418fn day_of_week_from_epoch_day(epoch_day: i64) -> u8 {
1419    (epoch_day + 3).rem_euclid(7) as u8 + 1
1420}
1421
1422fn iso_week_one_monday_epoch_day(year: i32) -> Result<i64, EncodeError> {
1423    let jan4 = DateValue::new(year, 1, 4)?.epoch_day();
1424    Ok(jan4 - (i64::from(day_of_week_from_epoch_day(jan4)) - 1))
1425}
1426
1427fn split_whole_fraction(value: f64) -> Result<(i64, f64), EncodeError> {
1428    if !value.is_finite() {
1429        return Err(EncodeError::InvalidTemporal(
1430            "duration field must be finite",
1431        ));
1432    }
1433    let whole = value.trunc();
1434    if whole < i64::MIN as f64 || whole > i64::MAX as f64 {
1435        return Err(EncodeError::InvalidTemporal(
1436            "duration field outside supported range",
1437        ));
1438    }
1439    Ok((whole as i64, value - whole))
1440}
1441
1442fn split_time_nanos(value: i128) -> Result<(i64, i64, i64, i64), EncodeError> {
1443    let nanos_per_hour = i128::from(NANOS_PER_HOUR);
1444    let nanos_per_minute = i128::from(NANOS_PER_MINUTE);
1445    let nanos_per_second = i128::from(NANOS_PER_SECOND);
1446
1447    let hours = value / nanos_per_hour;
1448    let rem = value % nanos_per_hour;
1449    let minutes = rem / nanos_per_minute;
1450    let rem = rem % nanos_per_minute;
1451    let seconds = rem / nanos_per_second;
1452    let nanoseconds = rem % nanos_per_second;
1453
1454    Ok((
1455        checked_i64_from_i128(hours)?,
1456        checked_i64_from_i128(minutes)?,
1457        checked_i64_from_i128(seconds)?,
1458        checked_i64_from_i128(nanoseconds)?,
1459    ))
1460}
1461
1462fn write_duration_seconds(
1463    f: &mut fmt::Formatter<'_>,
1464    seconds: i64,
1465    nanoseconds: i64,
1466) -> fmt::Result {
1467    let total = i128::from(seconds) * i128::from(NANOS_PER_SECOND) + i128::from(nanoseconds);
1468    let sign = if total < 0 { "-" } else { "" };
1469    let abs = total.abs();
1470    let whole = abs / i128::from(NANOS_PER_SECOND);
1471    let frac = abs % i128::from(NANOS_PER_SECOND);
1472    if frac == 0 {
1473        write!(f, "{sign}{whole}S")
1474    } else {
1475        let mut frac = format!("{frac:09}");
1476        while frac.ends_with('0') {
1477            frac.pop();
1478        }
1479        write!(f, "{sign}{whole}.{frac}S")
1480    }
1481}
1482
1483fn checked_i64_from_i128(value: i128) -> Result<i64, EncodeError> {
1484    i64::try_from(value)
1485        .map_err(|_| EncodeError::InvalidTemporal("duration field outside supported range"))
1486}
1487
1488fn round_ties_even_with_float_tolerance(value: f64) -> f64 {
1489    let whole = value.trunc();
1490    let fraction = value - whole;
1491    let half_distance = (fraction.abs() - 0.5).abs();
1492    let tolerance = f64::EPSILON * value.abs().max(1.0) * 8.0;
1493    if half_distance <= tolerance {
1494        let whole_i128 = whole as i128;
1495        if whole_i128 % 2 == 0 {
1496            whole
1497        } else {
1498            whole + fraction.signum()
1499        }
1500    } else {
1501        value.round()
1502    }
1503}
1504
1505fn checked_i128_from_rounded_f64(value: f64) -> Result<i128, EncodeError> {
1506    if !value.is_finite() {
1507        return Err(EncodeError::InvalidTemporal(
1508            "duration field must be finite",
1509        ));
1510    }
1511    let rounded = round_ties_even_with_float_tolerance(value);
1512    if rounded < i128::MIN as f64 || rounded > i128::MAX as f64 {
1513        return Err(EncodeError::InvalidTemporal(
1514            "duration field outside supported range",
1515        ));
1516    }
1517    Ok(rounded as i128)
1518}
1519
1520fn checked_i32_from_i128(value: i128) -> Result<i32, EncodeError> {
1521    i32::try_from(value)
1522        .map_err(|_| EncodeError::InvalidTemporal("duration field outside supported range"))
1523}
1524
1525fn timestamp_from_epoch_day_and_nanos(day: i64, nanos: i64) -> Result<Timestamp, EncodeError> {
1526    if !(0..NANOS_PER_DAY).contains(&nanos) {
1527        return Err(EncodeError::InvalidTemporal(
1528            "nanos since midnight must be in 0..<24h",
1529        ));
1530    }
1531    let total_seconds = i128::from(day) * 86_400 + i128::from(nanos.div_euclid(NANOS_PER_SECOND));
1532    let seconds = checked_i64_from_i128(total_seconds)?;
1533    let subsec_nanos = i32::try_from(nanos.rem_euclid(NANOS_PER_SECOND))
1534        .map_err(|_| EncodeError::InvalidTemporal("nanoseconds out of range"))?;
1535    Timestamp::new(seconds, subsec_nanos)
1536        .map_err(|_| EncodeError::InvalidTemporal("epoch datetime out of range"))
1537}
1538
1539fn parse_open_cypher_date(s: &str) -> Result<DateValue, DecodeError> {
1540    if let Ok(inner) = s.parse::<Date>() {
1541        return Ok(DateValue { inner });
1542    }
1543
1544    if let Some(w_pos) = s.find('W') {
1545        let year_part = s[..w_pos].strip_suffix('-').unwrap_or(&s[..w_pos]);
1546        let year = parse_i32_digits(year_part, "invalid ISO week year")?;
1547        let tail = &s[w_pos + 1..];
1548        let (week, day) = match tail.split_once('-') {
1549            Some((week, day)) => (
1550                parse_u8_digits(week, "invalid ISO week")?,
1551                Some(parse_u8_digits(day, "invalid ISO day")?),
1552            ),
1553            None if tail.len() == 2 => (parse_u8_digits(tail, "invalid ISO week")?, None),
1554            None if tail.len() == 3 => (
1555                parse_u8_digits(&tail[..2], "invalid ISO week")?,
1556                Some(parse_u8_digits(&tail[2..], "invalid ISO day")?),
1557            ),
1558            _ => return Err(DecodeError::InvalidTemporal("invalid ISO week date".into())),
1559        };
1560        return DateValue::from_iso_week(year, week, day)
1561            .map_err(|err| DecodeError::InvalidTemporal(err.to_string()));
1562    }
1563
1564    if s.len() == 4 && is_ascii_digits(s) {
1565        return DateValue::from_calendar(parse_i32_digits(s, "invalid year")?, None, None)
1566            .map_err(|err| DecodeError::InvalidTemporal(err.to_string()));
1567    }
1568
1569    if s.len() == 6 && is_ascii_digits(s) {
1570        return DateValue::from_calendar(
1571            parse_i32_digits(&s[..4], "invalid year")?,
1572            Some(parse_u8_digits(&s[4..], "invalid month")?),
1573            None,
1574        )
1575        .map_err(|err| DecodeError::InvalidTemporal(err.to_string()));
1576    }
1577
1578    if s.len() == 7 && is_ascii_digits(s) {
1579        return DateValue::from_ordinal_day(
1580            parse_i32_digits(&s[..4], "invalid year")?,
1581            parse_u16_digits(&s[4..], "invalid ordinal day")?,
1582        )
1583        .map_err(|err| DecodeError::InvalidTemporal(err.to_string()));
1584    }
1585
1586    if s.len() == 7 && &s[4..5] == "-" {
1587        return DateValue::from_calendar(
1588            parse_i32_digits(&s[..4], "invalid year")?,
1589            Some(parse_u8_digits(&s[5..], "invalid month")?),
1590            None,
1591        )
1592        .map_err(|err| DecodeError::InvalidTemporal(err.to_string()));
1593    }
1594
1595    if s.len() == 8 && &s[4..5] == "-" {
1596        return DateValue::from_ordinal_day(
1597            parse_i32_digits(&s[..4], "invalid year")?,
1598            parse_u16_digits(&s[5..], "invalid ordinal day")?,
1599        )
1600        .map_err(|err| DecodeError::InvalidTemporal(err.to_string()));
1601    }
1602
1603    Err(DecodeError::InvalidTemporal("invalid date".into()))
1604}
1605
1606fn parse_open_cypher_duration(s: &str) -> Result<DurationValue, DecodeError> {
1607    let rest = s
1608        .strip_prefix('P')
1609        .ok_or_else(|| DecodeError::InvalidTemporal("duration must start with P".into()))?;
1610    if rest.contains('-') {
1611        if let Ok(value) = parse_alternative_duration(rest) {
1612            return Ok(value);
1613        }
1614    }
1615
1616    let mut parts = DurationMapParts::default();
1617    let mut integer_parts = DurationIntegerParts::default();
1618    let mut integer_only = true;
1619    let mut in_time = false;
1620    let mut number_start: Option<usize> = None;
1621
1622    for (idx, ch) in rest.char_indices() {
1623        if ch == 'T' {
1624            if number_start.is_some() {
1625                return Err(DecodeError::InvalidTemporal(
1626                    "missing duration designator".into(),
1627                ));
1628            }
1629            in_time = true;
1630            continue;
1631        }
1632
1633        if ch.is_ascii_digit() || ch == '.' || ((ch == '+' || ch == '-') && number_start.is_none())
1634        {
1635            number_start.get_or_insert(idx);
1636            continue;
1637        }
1638
1639        let start = number_start
1640            .take()
1641            .ok_or_else(|| DecodeError::InvalidTemporal("missing duration field value".into()))?;
1642        let field = &rest[start..idx];
1643        if field.contains('.') {
1644            integer_only = false;
1645        } else {
1646            let value = parse_i64_duration_field(field, "invalid duration field")?;
1647            match (ch, in_time) {
1648                ('Y', false) => integer_parts.years = value,
1649                ('M', false) => integer_parts.months = value,
1650                ('W', false) => integer_parts.weeks = value,
1651                ('D', false) => integer_parts.days = value,
1652                ('H', true) => integer_parts.hours = value,
1653                ('M', true) => integer_parts.minutes = value,
1654                ('S', true) => integer_parts.seconds = value,
1655                _ => {}
1656            }
1657        }
1658
1659        let value = parse_f64_digits(field, "invalid duration field")?;
1660        match (ch, in_time) {
1661            ('Y', false) => parts.years = value,
1662            ('M', false) => parts.months = value,
1663            ('W', false) => parts.weeks = value,
1664            ('D', false) => parts.days = value,
1665            ('H', true) => parts.hours = value,
1666            ('M', true) => parts.minutes = value,
1667            ('S', true) => parts.seconds = value,
1668            _ => {
1669                return Err(DecodeError::InvalidTemporal(
1670                    "invalid duration designator".into(),
1671                ))
1672            }
1673        }
1674    }
1675
1676    if number_start.is_some() {
1677        return Err(DecodeError::InvalidTemporal(
1678            "missing duration designator".into(),
1679        ));
1680    }
1681
1682    if integer_only {
1683        return DurationValue::from_parts(
1684            integer_parts.years,
1685            integer_parts.months,
1686            integer_parts.weeks,
1687            integer_parts.days,
1688            integer_parts.hours,
1689            integer_parts.minutes,
1690            integer_parts.seconds,
1691            0,
1692            0,
1693            0,
1694        )
1695        .map_err(|err| DecodeError::InvalidTemporal(err.to_string()));
1696    }
1697
1698    DurationValue::from_open_cypher_parts(parts)
1699        .map_err(|err| DecodeError::InvalidTemporal(err.to_string()))
1700}
1701
1702fn parse_alternative_duration(rest: &str) -> Result<DurationValue, DecodeError> {
1703    let (date, time) = rest
1704        .split_once('T')
1705        .ok_or_else(|| DecodeError::InvalidTemporal("invalid duration".into()))?;
1706    let mut date_parts = date.split('-');
1707    let years = parse_f64_digits(
1708        date_parts
1709            .next()
1710            .ok_or_else(|| DecodeError::InvalidTemporal("invalid duration year".into()))?,
1711        "invalid duration year",
1712    )?;
1713    let months = parse_f64_digits(
1714        date_parts
1715            .next()
1716            .ok_or_else(|| DecodeError::InvalidTemporal("invalid duration month".into()))?,
1717        "invalid duration month",
1718    )?;
1719    let days = parse_f64_digits(
1720        date_parts
1721            .next()
1722            .ok_or_else(|| DecodeError::InvalidTemporal("invalid duration day".into()))?,
1723        "invalid duration day",
1724    )?;
1725    if date_parts.next().is_some() {
1726        return Err(DecodeError::InvalidTemporal("invalid duration date".into()));
1727    }
1728
1729    let mut time_parts = time.split(':');
1730    let hours = parse_f64_digits(
1731        time_parts
1732            .next()
1733            .ok_or_else(|| DecodeError::InvalidTemporal("invalid duration hour".into()))?,
1734        "invalid duration hour",
1735    )?;
1736    let minutes = parse_f64_digits(
1737        time_parts
1738            .next()
1739            .ok_or_else(|| DecodeError::InvalidTemporal("invalid duration minute".into()))?,
1740        "invalid duration minute",
1741    )?;
1742    let seconds = parse_f64_digits(
1743        time_parts
1744            .next()
1745            .ok_or_else(|| DecodeError::InvalidTemporal("invalid duration second".into()))?,
1746        "invalid duration second",
1747    )?;
1748    if time_parts.next().is_some() {
1749        return Err(DecodeError::InvalidTemporal("invalid duration time".into()));
1750    }
1751
1752    DurationValue::from_open_cypher_parts(DurationMapParts {
1753        years,
1754        months,
1755        days,
1756        hours,
1757        minutes,
1758        seconds,
1759        ..DurationMapParts::default()
1760    })
1761    .map_err(|err| DecodeError::InvalidTemporal(err.to_string()))
1762}
1763
1764fn is_ascii_digits(value: &str) -> bool {
1765    !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
1766}
1767
1768fn parse_i32_digits(value: &str, msg: &str) -> Result<i32, DecodeError> {
1769    if !is_ascii_digits(value) {
1770        return Err(DecodeError::InvalidTemporal(msg.into()));
1771    }
1772    value
1773        .parse::<i32>()
1774        .map_err(|_| DecodeError::InvalidTemporal(msg.into()))
1775}
1776
1777fn parse_u8_digits(value: &str, msg: &str) -> Result<u8, DecodeError> {
1778    if !is_ascii_digits(value) {
1779        return Err(DecodeError::InvalidTemporal(msg.into()));
1780    }
1781    value
1782        .parse::<u8>()
1783        .map_err(|_| DecodeError::InvalidTemporal(msg.into()))
1784}
1785
1786fn parse_u16_digits(value: &str, msg: &str) -> Result<u16, DecodeError> {
1787    if !is_ascii_digits(value) {
1788        return Err(DecodeError::InvalidTemporal(msg.into()));
1789    }
1790    value
1791        .parse::<u16>()
1792        .map_err(|_| DecodeError::InvalidTemporal(msg.into()))
1793}
1794
1795fn parse_i64_duration_field(value: &str, msg: &str) -> Result<i64, DecodeError> {
1796    if value.is_empty()
1797        || matches!(value, "+" | "-")
1798        || !value
1799            .trim_start_matches(['+', '-'])
1800            .bytes()
1801            .all(|b| b.is_ascii_digit())
1802    {
1803        return Err(DecodeError::InvalidTemporal(msg.into()));
1804    }
1805    value
1806        .parse::<i64>()
1807        .map_err(|_| DecodeError::InvalidTemporal(msg.into()))
1808}
1809
1810fn parse_f64_digits(value: &str, msg: &str) -> Result<f64, DecodeError> {
1811    if value.is_empty() {
1812        return Err(DecodeError::InvalidTemporal(msg.into()));
1813    }
1814    let parsed = value
1815        .parse::<f64>()
1816        .map_err(|_| DecodeError::InvalidTemporal(msg.into()))?;
1817    if !parsed.is_finite() {
1818        return Err(DecodeError::InvalidTemporal(msg.into()));
1819    }
1820    Ok(parsed)
1821}
1822
1823fn parse_offset(s: &str) -> Result<UtcOffsetValue, DecodeError> {
1824    if s == "Z" {
1825        return Ok(UtcOffsetValue::utc());
1826    }
1827    if !matches!(s.len(), 3 | 5 | 6 | 7 | 9) {
1828        return Err(DecodeError::InvalidTemporal(
1829            "offset must be Z, ±HH, ±HHMM, ±HH:MM, ±HHMMSS or ±HH:MM:SS".into(),
1830        ));
1831    }
1832    let sign = match &s[0..1] {
1833        "+" => 1,
1834        "-" => -1,
1835        _ => {
1836            return Err(DecodeError::InvalidTemporal(
1837                "offset must start with '+' or '-'".into(),
1838            ))
1839        }
1840    };
1841    let hours = s[1..3]
1842        .parse::<i32>()
1843        .map_err(|_| DecodeError::InvalidTemporal("invalid offset hour".into()))?;
1844    let mut minutes = 0;
1845    let mut seconds = 0;
1846    if s.len() == 5 || s.len() == 7 {
1847        minutes = s[3..5]
1848            .parse::<i32>()
1849            .map_err(|_| DecodeError::InvalidTemporal("invalid offset minute".into()))?;
1850        if s.len() == 7 {
1851            seconds = s[5..7]
1852                .parse::<i32>()
1853                .map_err(|_| DecodeError::InvalidTemporal("invalid offset second".into()))?;
1854        }
1855    } else if s.len() >= 6 {
1856        if &s[3..4] != ":" {
1857            return Err(DecodeError::InvalidTemporal(
1858                "offset separator must be ':'".into(),
1859            ));
1860        }
1861        minutes = s[4..6]
1862            .parse::<i32>()
1863            .map_err(|_| DecodeError::InvalidTemporal("invalid offset minute".into()))?;
1864    }
1865    if s.len() == 9 {
1866        if &s[6..7] != ":" {
1867            return Err(DecodeError::InvalidTemporal(
1868                "offset separator must be ':'".into(),
1869            ));
1870        }
1871        seconds = s[7..9]
1872            .parse::<i32>()
1873            .map_err(|_| DecodeError::InvalidTemporal("invalid offset second".into()))?;
1874    }
1875    if minutes > 59 || seconds > 59 {
1876        return Err(DecodeError::InvalidTemporal(
1877            "offset minute and second must be in 0..=59".into(),
1878        ));
1879    }
1880    let seconds_east = sign * (hours * 3600 + minutes * 60 + seconds);
1881    UtcOffsetValue::new(seconds_east).map_err(|err| DecodeError::InvalidTemporal(err.to_string()))
1882}
1883
1884fn split_zone_suffix(s: &str) -> Result<(&str, Option<&str>), DecodeError> {
1885    match s.rsplit_once('[') {
1886        Some((prefix, zone)) if zone.ends_with(']') => Ok((prefix, Some(&zone[..zone.len() - 1]))),
1887        Some(_) => Err(DecodeError::InvalidTemporal(
1888            "zone id suffix must end with ']'".into(),
1889        )),
1890        None => Ok((s, None)),
1891    }
1892}
1893
1894fn find_offset_start(s: &str, has_date: bool) -> Option<usize> {
1895    if s.ends_with('Z') {
1896        return Some(s.len() - 1);
1897    }
1898    let start = if has_date {
1899        s.find('T').map(|idx| idx + 1)?
1900    } else {
1901        1
1902    };
1903    let bytes = s.as_bytes();
1904    for idx in start..bytes.len() {
1905        if bytes[idx] == b'+' || bytes[idx] == b'-' {
1906            return Some(idx);
1907        }
1908    }
1909    None
1910}
1911
1912#[cfg(any(feature = "tzdb-system", feature = "tzdb-bundled"))]
1913fn parse_zoned_datetime_with_jiff(s: &str) -> Result<ZonedDateTimeValue, DecodeError> {
1914    let zoned = s
1915        .parse::<jiff::Zoned>()
1916        .map_err(|err| DecodeError::InvalidTemporal(err.to_string()))?;
1917    let zone_id = zoned
1918        .time_zone()
1919        .iana_name()
1920        .map(|name| Box::<str>::from(name.to_owned()));
1921    let offset = zoned
1922        .offset()
1923        .to_string()
1924        .parse::<UtcOffsetValue>()
1925        .map_err(|err| DecodeError::InvalidTemporal(err.to_string()))?;
1926    Ok(ZonedDateTimeValue {
1927        datetime: LocalDateTimeValue {
1928            inner: zoned.datetime(),
1929        },
1930        offset,
1931        zone_id,
1932    })
1933}
1934
1935#[cfg(any(feature = "tzdb-system", feature = "tzdb-bundled"))]
1936fn validate_zoned_components(
1937    datetime: &LocalDateTimeValue,
1938    offset: &UtcOffsetValue,
1939    zone_id: &str,
1940) -> Result<(), String> {
1941    let candidate = format!("{}{}[{zone_id}]", datetime, offset);
1942    candidate
1943        .parse::<jiff::Zoned>()
1944        .map(|_| ())
1945        .map_err(|err| err.to_string())
1946}
1947
1948#[cfg(not(any(feature = "tzdb-system", feature = "tzdb-bundled")))]
1949fn validate_zoned_components(
1950    _datetime: &LocalDateTimeValue,
1951    _offset: &UtcOffsetValue,
1952    _zone_id: &str,
1953) -> Result<(), String> {
1954    Ok(())
1955}
1956
1957#[cfg(test)]
1958mod tests {
1959    use super::*;
1960
1961    fn storage_display(value: crate::property::PropertyValue) -> String {
1962        let storage = crate::codec::encode_property_value(&value).unwrap();
1963        crate::render::storage_value_to_display_text(storage.as_ref()).unwrap()
1964    }
1965
1966    fn storage_roundtrip(value: crate::property::PropertyValue) -> crate::property::PropertyValue {
1967        let storage = crate::codec::encode_property_value(&value).unwrap();
1968        crate::codec::decode_property_value(storage.as_ref()).unwrap()
1969    }
1970
1971    #[test]
1972    fn parses_and_formats_date() {
1973        let value = DateValue::new(2026, 4, 6).unwrap();
1974        assert_eq!(value.to_string(), "2026-04-06");
1975        assert_eq!("2026-04-06".parse::<DateValue>().unwrap(), value);
1976    }
1977
1978    #[test]
1979    fn parses_and_formats_local_time() {
1980        let value = LocalTimeValue::new(12, 0, 1, 120_000_000).unwrap();
1981        assert_eq!(value.to_string(), "12:00:01.12");
1982        assert_eq!("12:00:01.12".parse::<LocalTimeValue>().unwrap(), value);
1983    }
1984
1985    #[test]
1986    fn parses_and_formats_duration() {
1987        let value = "P2M10DT2H30M".parse::<DurationValue>().unwrap();
1988        assert_eq!(value.to_string(), "P2M10DT2H30M");
1989        assert!(value.as_jiff().is_some());
1990    }
1991
1992    #[test]
1993    fn parses_offset() {
1994        let value = "+01:00".parse::<UtcOffsetValue>().unwrap();
1995        assert_eq!(value.seconds_east(), 3600);
1996        assert_eq!(value.to_string(), "+01:00");
1997    }
1998
1999    #[test]
2000    fn parses_compact_hour_offset_and_canonicalizes() {
2001        let value = "+01".parse::<UtcOffsetValue>().unwrap();
2002        assert_eq!(value.seconds_east(), 3600);
2003        assert_eq!(value.to_string(), "+01:00");
2004    }
2005
2006    #[test]
2007    fn parses_negative_half_hour_offset() {
2008        let value = "-05:30".parse::<UtcOffsetValue>().unwrap();
2009        assert_eq!(value.seconds_east(), -(5 * 3600 + 30 * 60));
2010        assert_eq!(value.to_string(), "-05:30");
2011    }
2012
2013    #[test]
2014    fn parses_utc_and_second_precision_offsets() {
2015        let utc = "Z".parse::<UtcOffsetValue>().unwrap();
2016        assert_eq!(utc.seconds_east(), 0);
2017        assert_eq!(utc.to_string(), "Z");
2018
2019        let value = "+02:05:59".parse::<UtcOffsetValue>().unwrap();
2020        assert_eq!(value.seconds_east(), 2 * 3600 + 5 * 60 + 59);
2021        assert_eq!(value.to_string(), "+02:05:59");
2022    }
2023
2024    #[test]
2025    fn parses_zoned_time() {
2026        let value = "12:00:00+01:00".parse::<ZonedTimeValue>().unwrap();
2027        assert_eq!(value.to_string(), "12:00:00+01:00");
2028    }
2029
2030    #[test]
2031    fn parses_compact_zoned_time_and_canonicalizes() {
2032        let value = "12:00:00+01".parse::<ZonedTimeValue>().unwrap();
2033        assert_eq!(value.to_string(), "12:00:00+01:00");
2034    }
2035
2036    #[test]
2037    fn parses_offset_only_zoned_datetime() {
2038        let value = "2026-04-06T12:00:00+01:00"
2039            .parse::<ZonedDateTimeValue>()
2040            .unwrap();
2041        assert_eq!(value.to_string(), "2026-04-06T12:00:00+01:00");
2042    }
2043
2044    #[test]
2045    fn constructs_temporal1_date_forms() {
2046        assert_eq!(
2047            DateValue::from_iso_week(1817, 2, Some(2))
2048                .unwrap()
2049                .to_string(),
2050            "1817-01-07"
2051        );
2052        assert_eq!(
2053            DateValue::from_ordinal_day(1984, 202).unwrap().to_string(),
2054            "1984-07-20"
2055        );
2056        assert_eq!(
2057            DateValue::from_quarter(1984, 3, Some(45))
2058                .unwrap()
2059                .to_string(),
2060            "1984-08-14"
2061        );
2062    }
2063
2064    #[test]
2065    fn constructs_epoch_datetime_and_open_cypher_duration() {
2066        assert_eq!(
2067            ZonedDateTimeValue::from_epoch(416_779, 999_999_999)
2068                .unwrap()
2069                .to_string(),
2070            "1970-01-05T19:46:19.999999999Z"
2071        );
2072        assert_eq!(
2073            ZonedDateTimeValue::from_epoch_millis(237_821_673_987)
2074                .unwrap()
2075                .to_string(),
2076            "1977-07-15T13:34:33.987Z"
2077        );
2078
2079        let duration = DurationValue::from_open_cypher_parts(DurationMapParts {
2080            months: 0.75,
2081            ..DurationMapParts::default()
2082        })
2083        .unwrap();
2084        assert_eq!(duration.to_string(), "P22DT19H51M49.5S");
2085    }
2086
2087    #[test]
2088    fn parses_temporal2_date_string_forms() {
2089        assert_eq!(
2090            "2015-07-01",
2091            "2015-07".parse::<DateValue>().unwrap().to_string()
2092        );
2093        assert_eq!(
2094            "2015-07-01",
2095            "201507".parse::<DateValue>().unwrap().to_string()
2096        );
2097        assert_eq!(
2098            "2015-07-21",
2099            "2015-W30-2".parse::<DateValue>().unwrap().to_string()
2100        );
2101        assert_eq!(
2102            "2015-07-20",
2103            "2015W30".parse::<DateValue>().unwrap().to_string()
2104        );
2105        assert_eq!(
2106            "2015-07-21",
2107            "2015-202".parse::<DateValue>().unwrap().to_string()
2108        );
2109        assert_eq!(
2110            "2015-01-01",
2111            "2015".parse::<DateValue>().unwrap().to_string()
2112        );
2113    }
2114
2115    #[test]
2116    fn parses_temporal2_datetime_string_forms() {
2117        assert_eq!(
2118            "2015-07-21T21:40:32.142",
2119            storage_display(crate::property::PropertyValue::LocalDateTime(
2120                "2015-W30-2T214032.142"
2121                    .parse::<LocalDateTimeValue>()
2122                    .unwrap()
2123            ))
2124        );
2125        assert_eq!(
2126            "2015-07-21T21:40:32+01:00",
2127            storage_display(crate::property::PropertyValue::ZonedDateTime(
2128                "2015-202T21:40:32+0100"
2129                    .parse::<ZonedDateTimeValue>()
2130                    .unwrap()
2131            ))
2132        );
2133        assert_eq!(
2134            "2015-07-21T21:40",
2135            storage_display(crate::property::PropertyValue::LocalDateTime(
2136                "20150721T21:40".parse::<LocalDateTimeValue>().unwrap()
2137            ))
2138        );
2139        assert_eq!(
2140            "2015-07-20T21:40-02:00",
2141            storage_display(crate::property::PropertyValue::ZonedDateTime(
2142                "2015-W30T2140-02".parse::<ZonedDateTimeValue>().unwrap()
2143            ))
2144        );
2145    }
2146
2147    #[test]
2148    fn parses_temporal2_duration_string_forms() {
2149        assert_eq!(
2150            "P5M1DT12H",
2151            "P5M1.5D".parse::<DurationValue>().unwrap().to_string()
2152        );
2153        assert_eq!(
2154            "P22DT19H51M49.5S",
2155            "P0.75M".parse::<DurationValue>().unwrap().to_string()
2156        );
2157        assert_eq!(
2158            "PT45S",
2159            "PT0.75M".parse::<DurationValue>().unwrap().to_string()
2160        );
2161        assert_eq!(
2162            "P17DT12H",
2163            "P2.5W".parse::<DurationValue>().unwrap().to_string()
2164        );
2165        assert_eq!(
2166            "P12Y5M14DT16H13M10S",
2167            "P12Y5M14DT16H12M70S"
2168                .parse::<DurationValue>()
2169                .unwrap()
2170                .to_string()
2171        );
2172        assert_eq!(
2173            "P13DT40H13M10S",
2174            "P13DT40H13M10S"
2175                .parse::<DurationValue>()
2176                .unwrap()
2177                .to_string()
2178        );
2179        assert_eq!(
2180            "P2012Y2M2DT14H37M21.545S",
2181            "P2012-02-02T14:37:21.545"
2182                .parse::<DurationValue>()
2183                .unwrap()
2184                .to_string()
2185        );
2186    }
2187
2188    #[test]
2189    fn duration_preserves_open_cypher_component_groups() {
2190        let base = DurationValue::from_open_cypher_parts(DurationMapParts {
2191            years: 12.0,
2192            months: 5.0,
2193            days: 14.0,
2194            hours: 16.0,
2195            minutes: 12.0,
2196            seconds: 70.0,
2197            ..DurationMapParts::default()
2198        })
2199        .unwrap();
2200        let same_time_group = DurationValue::from_open_cypher_parts(DurationMapParts {
2201            years: 12.0,
2202            months: 5.0,
2203            days: 14.0,
2204            hours: 16.0,
2205            minutes: 13.0,
2206            seconds: 10.0,
2207            ..DurationMapParts::default()
2208        })
2209        .unwrap();
2210        let different_day_group = DurationValue::from_open_cypher_parts(DurationMapParts {
2211            years: 12.0,
2212            months: 5.0,
2213            days: 13.0,
2214            hours: 40.0,
2215            minutes: 13.0,
2216            seconds: 10.0,
2217            ..DurationMapParts::default()
2218        })
2219        .unwrap();
2220
2221        assert_eq!(base, same_time_group);
2222        assert_ne!(base, different_day_group);
2223        assert_eq!(base.to_string(), "P12Y5M14DT16H13M10S");
2224        assert_eq!(different_day_group.to_string(), "P12Y5M13DT40H13M10S");
2225    }
2226
2227    #[test]
2228    fn negative_duration_components_use_floor_seconds() {
2229        let duration =
2230            DurationValue::from_parts(0, 0, 0, 0, -23, -59, -59, 0, 0, -900_000_000).unwrap();
2231        let value = crate::property::PropertyValue::Duration(duration);
2232
2233        assert_eq!(storage_display(value.clone()), "PT-23H-59M-59.9S");
2234        assert_eq!(
2235            temporal_component(value.as_ref(), "seconds").unwrap(),
2236            Some(crate::property::PropertyValue::Integer(-86_400))
2237        );
2238        assert_eq!(
2239            temporal_component(value.as_ref(), "nanosecondsOfSecond").unwrap(),
2240            Some(crate::property::PropertyValue::Integer(100_000_000))
2241        );
2242        assert_eq!(
2243            temporal_component(value.as_ref(), "secondsOfMinute").unwrap(),
2244            Some(crate::property::PropertyValue::Integer(0))
2245        );
2246        assert_eq!(
2247            temporal_component(value.as_ref(), "millisecondsOfSecond").unwrap(),
2248            Some(crate::property::PropertyValue::Integer(100))
2249        );
2250    }
2251
2252    #[test]
2253    fn duration_supports_large_open_cypher_groups() {
2254        let calendar =
2255            DurationValue::from_parts(1_999_999_998, 11, 0, 30, 0, 0, 0, 0, 0, 0).unwrap();
2256        assert_eq!(calendar.years(), 1_999_999_998);
2257        assert_eq!(calendar.days(), 30);
2258        assert!(calendar.as_jiff().is_none());
2259        let calendar_value = crate::property::PropertyValue::Duration(calendar);
2260        assert_eq!(
2261            storage_display(calendar_value.clone()),
2262            "P1999999998Y11M30D"
2263        );
2264        assert_eq!(storage_roundtrip(calendar_value.clone()), calendar_value);
2265
2266        let seconds =
2267            DurationValue::from_parts(0, 0, 0, 0, 17_531_639_991_215, 59, 59, 0, 0, 0).unwrap();
2268        assert_eq!(seconds.hours(), 17_531_639_991_215);
2269        assert!(seconds.as_jiff().is_none());
2270        let seconds_value = crate::property::PropertyValue::Duration(seconds);
2271        assert_eq!(
2272            storage_display(seconds_value.clone()),
2273            "PT17531639991215H59M59S"
2274        );
2275        assert_eq!(storage_roundtrip(seconds_value.clone()), seconds_value);
2276    }
2277
2278    #[test]
2279    fn duration_fractional_nanoseconds_use_ties_even_rounding() {
2280        for (nanoseconds, expected) in [
2281            (0.5, "PT0S"),
2282            (0.6, "PT0.000000001S"),
2283            (1.5, "PT0.000000002S"),
2284            (-0.5, "PT0S"),
2285            (-0.6, "PT-0.000000001S"),
2286            (-1.5, "PT-0.000000002S"),
2287        ] {
2288            let duration = DurationValue::from_open_cypher_parts(DurationMapParts {
2289                nanoseconds,
2290                ..DurationMapParts::default()
2291            })
2292            .unwrap();
2293            assert_eq!(duration.to_string(), expected);
2294        }
2295    }
2296
2297    #[test]
2298    fn duration_fractional_years_cascade_through_months() {
2299        let duration = DurationValue::from_open_cypher_parts(DurationMapParts {
2300            years: 12.5,
2301            months: 5.5,
2302            days: 14.5,
2303            hours: 16.5,
2304            minutes: 12.5,
2305            seconds: 70.5,
2306            nanoseconds: 3.0,
2307            ..DurationMapParts::default()
2308        })
2309        .unwrap();
2310
2311        assert_eq!(duration.to_string(), "P12Y11M29DT33H58M13.500000003S");
2312    }
2313
2314    #[test]
2315    fn temporal8_date_time_and_datetime_arithmetic() {
2316        let duration = DurationValue::from_open_cypher_parts(DurationMapParts {
2317            years: 12.5,
2318            months: 5.5,
2319            days: 14.5,
2320            hours: 16.5,
2321            minutes: 12.5,
2322            seconds: 70.5,
2323            nanoseconds: 3.0,
2324            ..DurationMapParts::default()
2325        })
2326        .unwrap();
2327
2328        let date = DateValue::new(1984, 10, 11).unwrap();
2329        assert_eq!(
2330            date.checked_add_duration(&duration).unwrap().to_string(),
2331            "1997-10-11"
2332        );
2333        assert_eq!(
2334            date.checked_sub_duration(&duration).unwrap().to_string(),
2335            "1971-10-12"
2336        );
2337
2338        let time = LocalTimeValue::new(12, 31, 14, 1).unwrap();
2339        assert_eq!(
2340            time.checked_add_duration(&duration).unwrap().to_string(),
2341            "22:29:27.500000004"
2342        );
2343        assert_eq!(
2344            time.checked_sub_duration(&duration).unwrap().to_string(),
2345            "02:33:00.499999998"
2346        );
2347
2348        let datetime = LocalDateTimeValue::new(1984, 10, 11, 12, 31, 14, 1).unwrap();
2349        assert_eq!(
2350            datetime
2351                .checked_add_duration(&duration)
2352                .unwrap()
2353                .to_string(),
2354            "1997-10-11T22:29:27.500000004"
2355        );
2356        assert_eq!(
2357            datetime
2358                .checked_sub_duration(&duration)
2359                .unwrap()
2360                .to_string(),
2361            "1971-10-12T02:33:00.499999998"
2362        );
2363    }
2364
2365    #[test]
2366    fn temporal8_duration_subtract_multiply_and_divide() {
2367        let base = DurationValue::from_open_cypher_parts(DurationMapParts {
2368            years: 12.0,
2369            months: 5.0,
2370            days: 14.0,
2371            hours: 16.0,
2372            minutes: 12.0,
2373            seconds: 70.0,
2374            nanoseconds: 1.0,
2375            ..DurationMapParts::default()
2376        })
2377        .unwrap();
2378        let fractional = DurationValue::from_open_cypher_parts(DurationMapParts {
2379            years: 12.5,
2380            months: 5.5,
2381            days: 14.5,
2382            hours: 16.5,
2383            minutes: 12.5,
2384            seconds: 70.5,
2385            nanoseconds: 3.0,
2386            ..DurationMapParts::default()
2387        })
2388        .unwrap();
2389
2390        assert_eq!(
2391            base.checked_sub_duration(&fractional).unwrap().to_string(),
2392            "P-6M-15DT-17H-45M-3.500000002S"
2393        );
2394        assert_eq!(
2395            base.checked_mul_number(0.5).unwrap().to_string(),
2396            "P6Y2M22DT13H21M8S"
2397        );
2398        assert_eq!(
2399            base.checked_div_number(2.0).unwrap().to_string(),
2400            "P6Y2M22DT13H21M8S"
2401        );
2402    }
2403
2404    #[test]
2405    fn duration_formats_negative_time_components_without_day_carry() {
2406        let cases = [
2407            (
2408                DurationMapParts {
2409                    seconds: -2.0,
2410                    milliseconds: 1.0,
2411                    ..DurationMapParts::default()
2412                },
2413                "PT-1.999S",
2414            ),
2415            (
2416                DurationMapParts {
2417                    days: 1.0,
2418                    milliseconds: -1.0,
2419                    ..DurationMapParts::default()
2420                },
2421                "P1DT-0.001S",
2422            ),
2423            (
2424                DurationMapParts {
2425                    seconds: -60.0,
2426                    milliseconds: -1.0,
2427                    ..DurationMapParts::default()
2428                },
2429                "PT-1M-0.001S",
2430            ),
2431        ];
2432
2433        for (parts, expected) in cases {
2434            assert_eq!(
2435                DurationValue::from_open_cypher_parts(parts)
2436                    .unwrap()
2437                    .to_string(),
2438                expected
2439            );
2440        }
2441    }
2442
2443    #[cfg(any(feature = "tzdb-system", feature = "tzdb-bundled"))]
2444    #[test]
2445    fn parses_named_zone_zoned_datetime() {
2446        let value = "2026-04-06T12:00:00+01:00[Europe/Lisbon]"
2447            .parse::<ZonedDateTimeValue>()
2448            .unwrap();
2449        assert_eq!(
2450            value.to_string(),
2451            "2026-04-06T12:00:00+01:00[Europe/Lisbon]"
2452        );
2453        assert_eq!(value.zone_id.as_deref(), Some("Europe/Lisbon"));
2454    }
2455
2456    #[cfg(any(feature = "tzdb-system", feature = "tzdb-bundled"))]
2457    #[test]
2458    fn rejects_named_zone_offset_mismatch() {
2459        let err = "2026-04-06T12:00:00+00:00[Europe/Lisbon]"
2460            .parse::<ZonedDateTimeValue>()
2461            .unwrap_err();
2462        assert!(matches!(err, DecodeError::InvalidTemporal(_)));
2463    }
2464}