Skip to main content

sofie_values/time/
time_zone.rs

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