sofie_values/time/
time_zone.rs1use std::fmt;
2use std::hash::{Hash, Hasher};
3
4use jiff::civil;
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use sofie_core::{Cast, ColumnReader, ColumnSpec, Error, Result, Show, Shown, SqlValue};
7
8use crate::refusal::refusal;
9use crate::{Date, Instant, TimeOfDay};
10use sofie_core::deserialize_parsed;
11
12#[derive(Clone)]
14pub struct TimeZone {
15 zone: jiff::tz::TimeZone,
17}
18
19impl TimeZone {
20 pub fn parse(name: &str) -> Result<Self> {
21 let refused = || {
22 refusal(
23 "time_zone",
24 "validation.time_zone.invalid",
25 "must be an IANA time zone such as Europe/Istanbul",
26 )
27 };
28 let zone = jiff::tz::TimeZone::get(name).map_err(|_| refused())?;
29 if !zone.iana_name().is_some_and(sofie_core::is_iana_identifier) {
30 return Err(refused());
31 }
32 Ok(Self { zone })
33 }
34
35 #[must_use]
36 pub fn name(&self) -> &str {
37 self.zone
38 .iana_name()
39 .expect("a zone found in the database carries its name")
40 }
41
42 pub(crate) fn zoned(&self, instant: Instant) -> jiff::Zoned {
43 instant.timestamp().to_zoned(self.zone.clone())
44 }
45
46 pub(crate) fn instant_of(&self, local: civil::DateTime) -> Result<Instant> {
47 sofie_core::wall_clock_instant(&self.zone, local)
48 .map(Instant::from)
49 .ok_or_else(|| Error::internal_message(format!("placing {local} in {}", self.name())))
50 }
51
52 pub fn instant_at(&self, day: Date, time: TimeOfDay) -> Result<Instant> {
53 self.instant_of(day.civil().to_datetime(time.civil()))
54 }
55}
56
57impl PartialEq for TimeZone {
58 fn eq(&self, other: &Self) -> bool {
59 self.name() == other.name()
60 }
61}
62
63impl Eq for TimeZone {}
64
65impl Hash for TimeZone {
66 fn hash<H: Hasher>(&self, state: &mut H) {
67 self.name().hash(state);
68 }
69}
70
71impl fmt::Debug for TimeZone {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 write!(formatter, "TimeZone({})", self.name())
74 }
75}
76
77impl fmt::Display for TimeZone {
78 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79 formatter.write_str(self.name())
80 }
81}
82
83impl Show for TimeZone {
84 fn show(&self) -> Shown<'_> {
85 Shown::Text(self.name())
86 }
87}
88
89impl Cast for TimeZone {
91 const COLUMNS: &'static [ColumnSpec] = String::COLUMNS;
92
93 fn encode(&self, out: &mut Vec<SqlValue>) {
94 out.push(SqlValue::Text(self.name().to_owned()));
95 }
96
97 fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
101 let name = row.text()?;
102 let canonical = name
103 .strip_prefix("posix/")
104 .or_else(|| name.strip_prefix("right/"))
105 .and_then(|iana_name| Self::parse(iana_name).ok());
106 if let Some(zone) = canonical {
107 return Ok(zone);
108 }
109 let zone = jiff::tz::TimeZone::get(name)
110 .map_err(|_| Error::invalid("time_zone", "must be an IANA time zone"));
111 sofie_core::value::stored(zone.map(|zone| Self { zone }))
112 }
113}
114
115impl Serialize for TimeZone {
116 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
117 serializer.serialize_str(self.name())
118 }
119}
120
121impl<'de> Deserialize<'de> for TimeZone {
122 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
123 deserialize_parsed(deserializer, "an IANA time zone name", Self::parse)
124 }
125}
126
127crate::present::present_text!(TimeZone, crate::present::TEXT, Exact, |zone| zone
128 .name()
129 .to_owned());
130
131#[cfg(test)]
132mod tests {
133 use sofie_core::{Cast, RowReader, SqlValue};
134
135 use super::*;
136 use crate::testing::{assert_refused, round_trip};
137
138 #[test]
139 fn accepts_iana_names_in_their_own_spelling() {
140 assert_eq!(
141 TimeZone::parse("europe/istanbul").unwrap().name(),
142 "Europe/Istanbul"
143 );
144 assert_eq!(TimeZone::parse("UTC").unwrap().to_string(), "UTC");
145 }
146
147 #[test]
148 fn refuses_what_is_not_a_zone_name() {
149 assert_refused(
150 TimeZone::parse,
151 &["", "Europe/Atlantis", "+03:00", "Istanbul Time"],
152 );
153 }
154
155 #[test]
156 fn refuses_zoneinfo_files_a_browser_cannot_show() {
157 assert_refused(
158 TimeZone::parse,
159 &["Factory", "posixrules", "posix/Europe/Istanbul", "zone.tab"],
160 );
161 }
162
163 #[test]
164 fn a_zone_stored_before_input_was_held_to_the_browser_list_still_reads() {
165 let values = [SqlValue::Text("Factory".into())];
166 let zone = TimeZone::decode(&mut RowReader::new(&values)).unwrap();
167 assert_eq!(zone.name(), "Factory");
168
169 let unknown = [SqlValue::Text("Europe/Atlantis".into())];
170 assert!(TimeZone::decode(&mut RowReader::new(&unknown)).is_err());
171 }
172
173 #[test]
174 fn a_stored_posix_or_right_copy_reads_as_its_iana_zone_and_round_trips() {
175 for stored in ["posix/Europe/Istanbul", "right/Europe/Istanbul"] {
176 let values = [SqlValue::Text(stored.into())];
177 let zone = TimeZone::decode(&mut RowReader::new(&values)).unwrap();
178
179 assert_eq!(zone.name(), "Europe/Istanbul");
180 let json = serde_json::to_value(&zone).unwrap();
181 assert_eq!(serde_json::from_value::<TimeZone>(json).unwrap(), zone);
182 }
183 }
184
185 #[test]
186 fn a_stored_zone_with_no_iana_name_reads_but_does_not_round_trip_through_input() {
187 let values = [SqlValue::Text("Factory".into())];
188 let zone = TimeZone::decode(&mut RowReader::new(&values)).unwrap();
189
190 let json = serde_json::to_value(&zone).unwrap();
191 assert!(serde_json::from_value::<TimeZone>(json).is_err());
192 }
193
194 #[test]
195 fn round_trips_through_a_text_column_and_json() {
196 let zone = TimeZone::parse("America/New_York").unwrap();
197 assert_eq!(
198 round_trip(&zone),
199 vec![SqlValue::Text("America/New_York".into())]
200 );
201 let json = serde_json::to_value(&zone).unwrap();
202 assert_eq!(serde_json::from_value::<TimeZone>(json).unwrap(), zone);
203 assert_eq!(format!("{zone:?}"), "TimeZone(America/New_York)");
204 }
205
206 fn placed(zone: &str, day: &str, time: &str) -> String {
207 TimeZone::parse(zone)
208 .unwrap()
209 .instant_at(Date::parse(day).unwrap(), TimeOfDay::parse(time).unwrap())
210 .unwrap()
211 .to_string()
212 }
213
214 #[test]
215 fn a_skipped_time_is_placed_where_the_shift_ends_and_a_repeated_one_first() {
216 assert_eq!(
217 placed("Europe/Istanbul", "2026-10-25", "02:30"),
218 "2026-10-24T23:30:00Z"
219 );
220 assert_eq!(
221 placed("Europe/Berlin", "2026-03-29", "02:30"),
222 "2026-03-29T01:00:00Z"
223 );
224 assert_eq!(
225 placed("Europe/Berlin", "2026-10-25", "02:30"),
226 "2026-10-25T00:30:00Z"
227 );
228 assert_eq!(
229 placed("Europe/Berlin", "2026-10-25", "03:00"),
230 "2026-10-25T02:00:00Z"
231 );
232 }
233
234 #[test]
235 fn a_stored_name_that_is_no_zone_is_an_internal_error() {
236 let values = [SqlValue::Text("Europe/Atlantis".into())];
237
238 assert!(matches!(
239 TimeZone::decode(&mut RowReader::new(&values)),
240 Err(Error::Internal { .. })
241 ));
242 }
243}