1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum SessionTimeZone {
45 #[default]
47 Utc,
48 Fixed(FixedOffset),
50 Named(Tz),
52}
53
54impl SessionTimeZone {
55 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 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 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 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 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
189pub 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}