1use std::fmt;
18use std::str::FromStr;
19
20use anyhow::{Context, bail};
21use chrono::{FixedOffset, LocalResult, NaiveDateTime, Offset, TimeZone};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct Zone(Kind);
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum Kind {
31 Fixed(i32),
33 Named(chrono_tz::Tz),
35}
36
37impl Default for Zone {
38 fn default() -> Self {
39 Zone::UTC
40 }
41}
42
43impl Zone {
44 pub const UTC: Zone = Zone(Kind::Fixed(0));
46
47 pub const fn fixed(seconds: i32) -> Zone {
50 Zone(Kind::Fixed(seconds))
51 }
52
53 pub fn offset_at(&self, unix: i64) -> i64 {
55 match &self.0 {
56 Kind::Fixed(offset) => i64::from(*offset),
57 Kind::Named(tz) => {
58 let utc = chrono::DateTime::from_timestamp(unix, 0).unwrap_or_default();
59 i64::from(
60 tz.offset_from_utc_datetime(&utc.naive_utc())
61 .fix()
62 .local_minus_utc(),
63 )
64 }
65 }
66 }
67
68 pub fn local(&self, unix: i64) -> NaiveDateTime {
70 let utc = chrono::DateTime::from_timestamp(unix, 0).unwrap_or_default();
71 (utc + chrono::TimeDelta::seconds(self.offset_at(unix))).naive_utc()
72 }
73
74 pub fn resolve(&self, local: NaiveDateTime) -> Option<i64> {
78 let result = match &self.0 {
79 Kind::Fixed(offset) => FixedOffset::east_opt(*offset)?
80 .from_local_datetime(&local)
81 .map(|t| t.timestamp()),
82 Kind::Named(tz) => tz.from_local_datetime(&local).map(|t| t.timestamp()),
83 };
84 match result {
85 LocalResult::Single(at) => Some(at),
86 LocalResult::Ambiguous(first, second) => Some(first.min(second)),
87 LocalResult::None => None,
88 }
89 }
90
91 pub fn fixed_at(&self, unix: i64) -> FixedOffset {
93 FixedOffset::east_opt(self.offset_at(unix) as i32)
94 .unwrap_or_else(|| FixedOffset::east_opt(0).expect("zero offset"))
95 }
96}
97
98impl FromStr for Zone {
99 type Err = anyhow::Error;
100
101 fn from_str(value: &str) -> anyhow::Result<Self> {
103 let value = value.trim();
104 if value.is_empty() || value.eq_ignore_ascii_case("utc") || value == "Z" {
105 return Ok(Zone::UTC);
106 }
107 if let Some(sign) = value
108 .strip_prefix('+')
109 .map(|rest| (1, rest))
110 .or_else(|| value.strip_prefix('-').map(|rest| (-1, rest)))
111 {
112 let (sign, rest) = sign;
113 let (hour, minute) = rest
114 .split_once(':')
115 .context("expected an offset like +07:00")?;
116 let (hour, minute): (i32, i32) = (hour.parse()?, minute.parse()?);
117 if !(0..=14).contains(&hour) || !(0..60).contains(&minute) {
118 bail!("`{value}` is not a UTC offset");
119 }
120 return Ok(Zone::fixed(sign * (hour * 3600 + minute * 60)));
121 }
122 match value.parse::<chrono_tz::Tz>() {
123 Ok(tz) => Ok(Zone(Kind::Named(tz))),
124 Err(_) => bail!(
125 "unknown time zone `{value}`: use UTC, an offset like +07:00 or an IANA name \
126 like Asia/Jakarta"
127 ),
128 }
129 }
130}
131
132impl fmt::Display for Zone {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 match &self.0 {
135 Kind::Fixed(0) => f.write_str("UTC"),
136 Kind::Fixed(offset) => {
137 let sign = if *offset < 0 { '-' } else { '+' };
138 let offset = offset.abs();
139 write!(f, "{sign}{:02}:{:02}", offset / 3600, offset % 3600 / 60)
140 }
141 Kind::Named(tz) => f.write_str(tz.name()),
142 }
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn at(text: &str) -> NaiveDateTime {
151 text.parse().unwrap()
152 }
153
154 #[test]
155 fn parses_every_form() {
156 assert_eq!("UTC".parse::<Zone>().unwrap(), Zone::UTC);
157 assert_eq!("".parse::<Zone>().unwrap(), Zone::UTC);
158 assert_eq!(
159 "-03:30".parse::<Zone>().unwrap(),
160 Zone::fixed(-(3 * 3600 + 1800))
161 );
162 assert_eq!(
163 "Asia/Jakarta".parse::<Zone>().unwrap().to_string(),
164 "Asia/Jakarta"
165 );
166 assert_eq!("+07:00".parse::<Zone>().unwrap().to_string(), "+07:00");
167 assert!("Mars/Olympus".parse::<Zone>().is_err());
168 assert!("+25:00".parse::<Zone>().is_err());
169 assert!("+7".parse::<Zone>().is_err());
170 }
171
172 #[test]
173 fn daylight_saving_gaps_and_repeats() {
174 let ams: Zone = "Europe/Amsterdam".parse().unwrap();
175 assert_eq!(ams.resolve(at("2026-03-29T02:30:00")), None);
177 let three = ams.resolve(at("2026-03-29T03:00:00")).unwrap();
178 assert_eq!(ams.offset_at(three), 7200);
179 let twice = ams.resolve(at("2026-10-25T02:30:00")).unwrap();
181 assert_eq!(ams.offset_at(twice), 7200);
182 assert_eq!(ams.local(twice), at("2026-10-25T02:30:00"));
183 assert_eq!(
184 ams.local(twice + 3600),
185 at("2026-10-25T02:30:00"),
186 "the repeat"
187 );
188 }
189}