Skip to main content

radixdb_core/
timezone.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Connection-local time-zone parsing and civil/instant conversion.
16//!
17//! A session time zone is execution policy only. It never becomes part of a
18//! persisted `TIMESTAMP` or `TIMESTAMPTZ` payload.
19
20use std::{fmt, str::FromStr};
21
22use chrono::{DateTime, FixedOffset, LocalResult, NaiveDateTime, SecondsFormat, TimeZone, Utc};
23use chrono_tz::Tz;
24
25use crate::{value::parse_civil_timestamp, Error, Result};
26
27const EXPLICIT_TIMESTAMP_FORMATS: &[&str] = &[
28    "%Y-%m-%dT%H:%M:%S%.f%:z",
29    "%Y-%m-%dT%H:%M:%S%:z",
30    "%Y-%m-%dT%H:%M:%S%.fZ",
31    "%Y-%m-%dT%H:%M:%SZ",
32    "%Y-%m-%d %H:%M:%S%.f%:z",
33    "%Y-%m-%d %H:%M:%S%:z",
34    "%Y-%m-%d %H:%M:%S%.fZ",
35    "%Y-%m-%d %H:%M:%SZ",
36];
37
38/// A validated session time zone.
39///
40/// Named zones use the bundled IANA rules. Fixed offsets intentionally remain
41/// available for legacy data whose source contract is an offset rather than a
42/// geographic zone.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum SessionTimeZone {
45    /// Coordinated Universal Time.
46    #[default]
47    Utc,
48    /// An offset that does not vary by date.
49    Fixed(FixedOffset),
50    /// A named IANA time zone with date-dependent rules.
51    Named(Tz),
52}
53
54impl SessionTimeZone {
55    /// Parse `UTC`, a fixed `+HH:MM`/`-HH:MM` offset, or an IANA zone name.
56    pub fn parse(value: &str) -> Result<Self> {
57        let value = value.trim();
58        if value.eq_ignore_ascii_case("UTC") || value.eq_ignore_ascii_case("Z") {
59            return Ok(Self::Utc);
60        }
61
62        if value.starts_with('+') || value.starts_with('-') {
63            let offset = parse_fixed_offset(value)?;
64            return Ok(if offset.local_minus_utc() == 0 {
65                Self::Utc
66            } else {
67                Self::Fixed(offset)
68            });
69        }
70
71        Tz::from_str(value).map(Self::Named).map_err(|_| {
72            Error::invalid_argument(format!(
73                "invalid time zone '{value}'; expected UTC, a fixed offset such as +07:00, or an IANA name such as Asia/Barnaul"
74            ))
75        })
76    }
77
78    /// Convert a civil date-time to one UTC instant.
79    ///
80    /// DST gaps and overlaps fail closed because either would require the
81    /// engine to invent policy not present in the value.
82    pub fn civil_to_instant(self, civil: NaiveDateTime) -> Result<DateTime<Utc>> {
83        let local = match self {
84            Self::Utc => Utc
85                .from_local_datetime(&civil)
86                .map(|value| value.with_timezone(&Utc)),
87            Self::Fixed(offset) => offset
88                .from_local_datetime(&civil)
89                .map(|value| value.with_timezone(&Utc)),
90            Self::Named(zone) => zone
91                .from_local_datetime(&civil)
92                .map(|value| value.with_timezone(&Utc)),
93        };
94
95        match local {
96            LocalResult::Single(value) => Ok(value),
97            LocalResult::None => Err(Error::invalid_argument(format!(
98                "civil timestamp {civil} does not exist in time zone {self}"
99            ))),
100            LocalResult::Ambiguous(_, _) => Err(Error::invalid_argument(format!(
101                "civil timestamp {civil} is ambiguous in time zone {self}; use an explicit offset"
102            ))),
103        }
104    }
105
106    /// Convert a UTC instant to its unambiguous civil representation.
107    pub fn instant_to_civil(self, instant: DateTime<Utc>) -> NaiveDateTime {
108        match self {
109            Self::Utc => instant.naive_utc(),
110            Self::Fixed(offset) => instant.with_timezone(&offset).naive_local(),
111            Self::Named(zone) => instant.with_timezone(&zone).naive_local(),
112        }
113    }
114
115    /// Parse a TIMESTAMPTZ input. An explicit input offset wins; otherwise the
116    /// civil text is interpreted using this session zone.
117    pub fn parse_instant(self, value: &str) -> Result<DateTime<Utc>> {
118        if let Ok(instant) = parse_timestamp_with_explicit_offset(value) {
119            return Ok(instant);
120        }
121        self.civil_to_instant(parse_civil_timestamp(value)?)
122    }
123
124    /// Render an instant with the offset selected by this zone at that date.
125    pub fn format_instant(self, instant: DateTime<Utc>) -> String {
126        match self {
127            Self::Utc => instant.to_rfc3339_opts(SecondsFormat::AutoSi, false),
128            Self::Fixed(offset) => instant
129                .with_timezone(&offset)
130                .to_rfc3339_opts(SecondsFormat::AutoSi, false),
131            Self::Named(zone) => instant
132                .with_timezone(&zone)
133                .to_rfc3339_opts(SecondsFormat::AutoSi, false),
134        }
135    }
136}
137
138impl fmt::Display for SessionTimeZone {
139    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            Self::Utc => formatter.write_str("UTC"),
142            Self::Fixed(offset) => {
143                let seconds = offset.local_minus_utc();
144                let sign = if seconds < 0 { '-' } else { '+' };
145                let absolute = seconds.unsigned_abs();
146                write!(
147                    formatter,
148                    "{sign}{:02}:{:02}",
149                    absolute / 3_600,
150                    (absolute % 3_600) / 60
151                )
152            }
153            Self::Named(zone) => zone.fmt(formatter),
154        }
155    }
156}
157
158fn parse_fixed_offset(value: &str) -> Result<FixedOffset> {
159    let bytes = value.as_bytes();
160    if bytes.len() != 6 || bytes[3] != b':' {
161        return Err(Error::invalid_argument(format!(
162            "invalid fixed time-zone offset '{value}'; expected +HH:MM or -HH:MM"
163        )));
164    }
165    let hours = parse_two_digits(&bytes[1..3], value)?;
166    let minutes = parse_two_digits(&bytes[4..6], value)?;
167    if hours > 23 || minutes > 59 {
168        return Err(Error::invalid_argument(format!(
169            "invalid fixed time-zone offset '{value}'"
170        )));
171    }
172    let magnitude = hours * 3_600 + minutes * 60;
173    let seconds = if bytes[0] == b'-' {
174        -magnitude
175    } else if bytes[0] == b'+' {
176        magnitude
177    } else {
178        return Err(Error::invalid_argument(format!(
179            "invalid fixed time-zone offset '{value}'"
180        )));
181    };
182    if seconds == 0 {
183        return Ok(FixedOffset::east_opt(0).expect("zero offset is valid"));
184    }
185    FixedOffset::east_opt(seconds)
186        .ok_or_else(|| Error::invalid_argument(format!("invalid fixed time-zone offset '{value}'")))
187}
188
189/// Parse only timestamp forms that carry an explicit UTC offset or `Z`.
190pub fn parse_timestamp_with_explicit_offset(value: &str) -> Result<DateTime<Utc>> {
191    let value = value.trim();
192    if let Ok(timestamp) = DateTime::parse_from_rfc3339(value) {
193        return Ok(timestamp.with_timezone(&Utc));
194    }
195    for format in EXPLICIT_TIMESTAMP_FORMATS {
196        if let Ok(timestamp) = DateTime::parse_from_str(value, format) {
197            return Ok(timestamp.with_timezone(&Utc));
198        }
199    }
200    Err(Error::parse(format!(
201        "timestamp has no valid explicit time-zone offset: {value}"
202    )))
203}
204
205fn parse_two_digits(bytes: &[u8], original: &str) -> Result<i32> {
206    if bytes.len() != 2 || !bytes.iter().all(u8::is_ascii_digit) {
207        return Err(Error::invalid_argument(format!(
208            "invalid fixed time-zone offset '{original}'"
209        )));
210    }
211    Ok(i32::from(bytes[0] - b'0') * 10 + i32::from(bytes[1] - b'0'))
212}
213
214#[cfg(test)]
215mod tests {
216    use chrono::NaiveDate;
217
218    use super::*;
219
220    fn civil(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> NaiveDateTime {
221        NaiveDate::from_ymd_opt(year, month, day)
222            .unwrap()
223            .and_hms_opt(hour, minute, 0)
224            .unwrap()
225    }
226
227    #[test]
228    fn parses_and_canonicalizes_supported_zone_forms() {
229        assert_eq!(SessionTimeZone::parse("UTC").unwrap().to_string(), "UTC");
230        assert_eq!(SessionTimeZone::parse("z").unwrap().to_string(), "UTC");
231        assert_eq!(SessionTimeZone::parse("+00:00").unwrap().to_string(), "UTC");
232        assert_eq!(
233            SessionTimeZone::parse("+07:00").unwrap().to_string(),
234            "+07:00"
235        );
236        assert_eq!(
237            SessionTimeZone::parse("Asia/Barnaul").unwrap().to_string(),
238            "Asia/Barnaul"
239        );
240    }
241
242    #[test]
243    fn rejects_invalid_fixed_offsets() {
244        for value in ["+7:00", "+24:00", "+02:60", "+01", "+aa:00"] {
245            assert!(SessionTimeZone::parse(value).is_err(), "{value}");
246        }
247    }
248
249    #[test]
250    fn fixed_offset_round_trip_is_exact() {
251        let zone = SessionTimeZone::parse("+07:00").unwrap();
252        let civil = civil(2026, 9, 11, 12, 30);
253        let instant = zone.civil_to_instant(civil).unwrap();
254        assert_eq!(instant.to_rfc3339(), "2026-09-11T05:30:00+00:00");
255        assert_eq!(zone.instant_to_civil(instant), civil);
256        assert_eq!(zone.format_instant(instant), "2026-09-11T12:30:00+07:00");
257    }
258
259    #[test]
260    fn explicit_input_offset_wins_over_session_zone() {
261        let zone = SessionTimeZone::parse("America/New_York").unwrap();
262        let explicit = zone.parse_instant("2026-09-11T12:30:00+07:00").unwrap();
263        assert_eq!(explicit.to_rfc3339(), "2026-09-11T05:30:00+00:00");
264
265        let contextual = zone.parse_instant("2026-09-11 12:30:00").unwrap();
266        assert_eq!(contextual.to_rfc3339(), "2026-09-11T16:30:00+00:00");
267    }
268
269    #[test]
270    fn named_zone_applies_historical_rules() {
271        let zone = SessionTimeZone::parse("America/New_York").unwrap();
272        let winter = zone.civil_to_instant(civil(2026, 1, 15, 12, 0)).unwrap();
273        let summer = zone.civil_to_instant(civil(2026, 7, 15, 12, 0)).unwrap();
274        assert_eq!(winter.to_rfc3339(), "2026-01-15T17:00:00+00:00");
275        assert_eq!(summer.to_rfc3339(), "2026-07-15T16:00:00+00:00");
276    }
277
278    #[test]
279    fn named_zone_rejects_dst_gap_and_overlap() {
280        let zone = SessionTimeZone::parse("America/New_York").unwrap();
281        assert!(zone.civil_to_instant(civil(2026, 3, 8, 2, 30)).is_err());
282        assert!(zone.civil_to_instant(civil(2026, 11, 1, 1, 30)).is_err());
283    }
284}