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::parsed::deserialize_parsed;
9use crate::{Date, Instant, TimeOfDay};
10
11#[derive(Clone)]
13pub struct TimeZone {
14 zone: jiff::tz::TimeZone,
16}
17
18impl TimeZone {
19 pub fn parse(name: &str) -> Result<Self> {
20 let zone = jiff::tz::TimeZone::get(name).map_err(|_| {
21 Error::invalid(
22 "time_zone",
23 "must be an IANA time zone such as Europe/Istanbul",
24 )
25 })?;
26 Ok(Self { zone })
27 }
28
29 #[must_use]
30 pub fn name(&self) -> &str {
31 self.zone
32 .iana_name()
33 .expect("a zone found in the database carries its name")
34 }
35
36 pub(crate) fn zoned(&self, instant: Instant) -> jiff::Zoned {
37 instant.timestamp().to_zoned(self.zone.clone())
38 }
39
40 pub(crate) fn instant_of(&self, local: civil::DateTime) -> Result<Instant> {
41 sofie_core::wall_clock_instant(&self.zone, local)
42 .map(Instant::from)
43 .ok_or_else(|| Error::internal_message(format!("placing {local} in {}", self.name())))
44 }
45
46 pub fn instant_at(&self, day: Date, time: TimeOfDay) -> Result<Instant> {
47 self.instant_of(day.civil().to_datetime(time.civil()))
48 }
49}
50
51impl PartialEq for TimeZone {
52 fn eq(&self, other: &Self) -> bool {
53 self.name() == other.name()
54 }
55}
56
57impl Eq for TimeZone {}
58
59impl Hash for TimeZone {
60 fn hash<H: Hasher>(&self, state: &mut H) {
61 self.name().hash(state);
62 }
63}
64
65impl fmt::Debug for TimeZone {
66 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67 write!(formatter, "TimeZone({})", self.name())
68 }
69}
70
71impl fmt::Display for TimeZone {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 formatter.write_str(self.name())
74 }
75}
76
77impl Show for TimeZone {
78 fn show(&self) -> Shown<'_> {
79 Shown::Text(self.name())
80 }
81}
82
83impl Cast for TimeZone {
85 const COLUMNS: &'static [ColumnSpec] = String::COLUMNS;
86
87 fn encode(&self, out: &mut Vec<SqlValue>) {
88 out.push(SqlValue::Text(self.name().to_owned()));
89 }
90
91 fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
92 sofie_core::value::stored(Self::parse(row.text()?))
93 }
94}
95
96impl Serialize for TimeZone {
97 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
98 serializer.serialize_str(self.name())
99 }
100}
101
102impl<'de> Deserialize<'de> for TimeZone {
103 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
104 deserialize_parsed(deserializer, "an IANA time zone name", Self::parse)
105 }
106}
107
108crate::present::present_text!(TimeZone, crate::present::TEXT, Exact, |zone| zone
109 .name()
110 .to_owned());
111
112#[cfg(test)]
113mod tests {
114 use sofie_core::{Cast, RowReader, SqlValue};
115
116 use super::*;
117 use crate::testing::{assert_refused, round_trip};
118
119 #[test]
120 fn accepts_iana_names_in_their_own_spelling() {
121 assert_eq!(
122 TimeZone::parse("europe/istanbul").unwrap().name(),
123 "Europe/Istanbul"
124 );
125 assert_eq!(TimeZone::parse("UTC").unwrap().to_string(), "UTC");
126 }
127
128 #[test]
129 fn refuses_what_is_not_a_zone_name() {
130 assert_refused(
131 TimeZone::parse,
132 &["", "Europe/Atlantis", "+03:00", "Istanbul Time"],
133 );
134 }
135
136 #[test]
137 fn round_trips_through_a_text_column_and_json() {
138 let zone = TimeZone::parse("America/New_York").unwrap();
139 assert_eq!(
140 round_trip(&zone),
141 vec![SqlValue::Text("America/New_York".into())]
142 );
143 let json = serde_json::to_value(&zone).unwrap();
144 assert_eq!(serde_json::from_value::<TimeZone>(json).unwrap(), zone);
145 assert_eq!(format!("{zone:?}"), "TimeZone(America/New_York)");
146 }
147
148 fn placed(zone: &str, day: &str, time: &str) -> String {
149 TimeZone::parse(zone)
150 .unwrap()
151 .instant_at(Date::parse(day).unwrap(), TimeOfDay::parse(time).unwrap())
152 .unwrap()
153 .to_string()
154 }
155
156 #[test]
157 fn a_skipped_time_is_placed_where_the_shift_ends_and_a_repeated_one_first() {
158 assert_eq!(
159 placed("Europe/Istanbul", "2026-10-25", "02:30"),
160 "2026-10-24T23:30:00Z"
161 );
162 assert_eq!(
163 placed("Europe/Berlin", "2026-03-29", "02:30"),
164 "2026-03-29T01:00:00Z"
165 );
166 assert_eq!(
167 placed("Europe/Berlin", "2026-10-25", "02:30"),
168 "2026-10-25T00:30:00Z"
169 );
170 assert_eq!(
171 placed("Europe/Berlin", "2026-10-25", "03:00"),
172 "2026-10-25T02:00:00Z"
173 );
174 }
175
176 #[test]
177 fn a_stored_name_that_is_no_zone_is_an_internal_error() {
178 let values = [SqlValue::Text("Europe/Atlantis".into())];
179
180 assert!(matches!(
181 TimeZone::decode(&mut RowReader::new(&values)),
182 Err(Error::Internal { .. })
183 ));
184 }
185}