1use chrono::{DateTime, FixedOffset, Local, Utc};
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::fmt;
6use std::str::FromStr;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum DisplayTimezone {
11 #[default]
13 Local,
14 Utc,
16 Fixed(FixedOffset),
18}
19
20impl DisplayTimezone {
21 #[must_use]
23 pub fn format_datetime(&self, value: DateTime<Utc>, pattern: &str) -> String {
24 match self {
25 Self::Local => value.with_timezone(&Local).format(pattern).to_string(),
26 Self::Utc => value.format(pattern).to_string(),
27 Self::Fixed(offset) => value.with_timezone(offset).format(pattern).to_string(),
28 }
29 }
30}
31
32impl fmt::Display for DisplayTimezone {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 match self {
35 Self::Local => f.write_str("local"),
36 Self::Utc => f.write_str("UTC"),
37 Self::Fixed(offset) => {
38 let seconds = offset.local_minus_utc();
39 let sign = if seconds < 0 { '-' } else { '+' };
40 let seconds = seconds.unsigned_abs();
41 write!(
42 f,
43 "{sign}{:02}:{:02}",
44 seconds / 3_600,
45 seconds % 3_600 / 60
46 )
47 }
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct TimezoneParseError {
55 value: String,
56}
57
58impl fmt::Display for TimezoneParseError {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 write!(
61 f,
62 "invalid display timezone {:?}; use `local`, `UTC`, or a fixed offset `+HH:MM` / `-HH:MM`",
63 self.value
64 )
65 }
66}
67
68impl std::error::Error for TimezoneParseError {}
69
70impl FromStr for DisplayTimezone {
71 type Err = TimezoneParseError;
72
73 fn from_str(value: &str) -> Result<Self, Self::Err> {
74 let value = value.trim();
75 if value.eq_ignore_ascii_case("local") {
76 return Ok(Self::Local);
77 }
78 if value.eq_ignore_ascii_case("utc") || value.eq_ignore_ascii_case("z") {
79 return Ok(Self::Utc);
80 }
81
82 let offset = value
83 .get(..3)
84 .filter(|prefix| prefix.eq_ignore_ascii_case("utc"))
85 .map_or(value, |_| &value[3..]);
86 let valid_shape = offset.len() == 6
87 && matches!(offset.as_bytes().first(), Some(b'+' | b'-'))
88 && offset.as_bytes().get(3) == Some(&b':')
89 && offset[1..3].bytes().all(|byte| byte.is_ascii_digit())
90 && offset[4..].bytes().all(|byte| byte.is_ascii_digit());
91 if !valid_shape {
92 return Err(TimezoneParseError {
93 value: value.to_string(),
94 });
95 }
96 let hours = offset[1..3]
97 .parse::<i32>()
98 .map_err(|_| TimezoneParseError {
99 value: value.to_string(),
100 })?;
101 let minutes = offset[4..].parse::<i32>().map_err(|_| TimezoneParseError {
102 value: value.to_string(),
103 })?;
104 if hours > 23 || minutes > 59 {
105 return Err(TimezoneParseError {
106 value: value.to_string(),
107 });
108 }
109 let seconds =
110 (hours * 3_600 + minutes * 60) * if offset.as_bytes()[0] == b'-' { -1 } else { 1 };
111 let fixed = FixedOffset::east_opt(seconds).ok_or_else(|| TimezoneParseError {
112 value: value.to_string(),
113 })?;
114 Ok(Self::Fixed(fixed))
115 }
116}
117
118impl Serialize for DisplayTimezone {
119 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
120 where
121 S: Serializer,
122 {
123 serializer.serialize_str(&self.to_string())
124 }
125}
126
127impl<'de> Deserialize<'de> for DisplayTimezone {
128 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129 where
130 D: Deserializer<'de>,
131 {
132 let value = String::deserialize(deserializer)?;
133 value.parse().map_err(serde::de::Error::custom)
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use chrono::{DateTime, Utc};
141
142 fn instant(seconds: i64) -> DateTime<Utc> {
143 DateTime::from_timestamp(seconds, 0).expect("valid timestamp")
144 }
145
146 #[test]
147 fn parses_local_utc_and_fixed_offsets_and_formats_date_boundaries() {
148 assert_eq!(
149 "local".parse::<DisplayTimezone>().expect("local"),
150 DisplayTimezone::Local
151 );
152 assert_eq!(
153 "UTC".parse::<DisplayTimezone>().expect("UTC"),
154 DisplayTimezone::Utc
155 );
156 let plus = "+09:00"
157 .parse::<DisplayTimezone>()
158 .expect("positive offset");
159 let minus = "UTC-01:00"
160 .parse::<DisplayTimezone>()
161 .expect("negative offset");
162
163 assert_eq!(
164 DisplayTimezone::Utc.format_datetime(instant(0), "%Y-%m-%d %H:%M"),
165 "1970-01-01 00:00"
166 );
167 assert_eq!(
168 plus.format_datetime(instant(0), "%Y-%m-%d %H:%M"),
169 "1970-01-01 09:00"
170 );
171 assert_eq!(
172 minus.format_datetime(instant(30 * 60), "%Y-%m-%d %H:%M"),
173 "1969-12-31 23:30"
174 );
175 }
176
177 #[test]
178 fn rejects_invalid_or_unavailable_timezone_values_with_guidance() {
179 for value in ["", "Mars/Base", "+24:00", "+09:60", "UTC+9"] {
180 let error = value
181 .parse::<DisplayTimezone>()
182 .expect_err("invalid timezone");
183 assert!(
184 error.to_string().contains("local")
185 && error.to_string().contains("UTC")
186 && error.to_string().contains("HH:MM"),
187 "guidance for {value:?}: {error}"
188 );
189 }
190 }
191}