Skip to main content

sofie_core/
display_zone.rs

1use std::sync::OnceLock;
2
3use jiff::civil::DateTime;
4use jiff::tz::{AmbiguousOffset, TimeZone};
5
6use crate::{Error, FieldError, Instant, Result};
7
8/// Process-wide because an instant read from a form deserializes with no request at hand.
9static INSTALLED: OnceLock<DisplayZone> = OnceLock::new();
10
11/// Where people see and type instants; stored and JSON instants stay UTC.
12#[derive(Clone, Debug)]
13pub struct DisplayZone {
14    zone: TimeZone,
15}
16
17impl DisplayZone {
18    pub fn named(name: &str) -> Result<Self> {
19        let refused = || format!("`{name}` is not an IANA time zone such as Europe/Istanbul");
20        let zone = TimeZone::get(name.trim()).map_err(|error| Error::internal(refused(), error))?;
21        if !zone.iana_name().is_some_and(is_iana_identifier) {
22            return Err(Error::internal_message(refused()));
23        }
24        Ok(Self { zone })
25    }
26
27    /// UTC unless installed first; reading it fixes it, so no instant is read in two zones.
28    #[must_use]
29    pub fn current() -> &'static Self {
30        INSTALLED.get_or_init(|| Self {
31            zone: TimeZone::UTC,
32        })
33    }
34
35    pub fn install(self) -> Result<()> {
36        let installed = INSTALLED.get_or_init(|| self.clone());
37        if installed.name() == self.name() {
38            return Ok(());
39        }
40        Err(Error::internal_message(format!(
41            "the display zone is {}, fixed when first installed or first read, not {}",
42            installed.name(),
43            self.name()
44        )))
45    }
46
47    #[must_use]
48    pub fn name(&self) -> &str {
49        self.zone
50            .iana_name()
51            .expect("UTC and a zone found in the database carry their name")
52    }
53
54    #[must_use]
55    pub fn wall_clock(&self, instant: Instant) -> DateTime {
56        self.zone.to_datetime(instant.timestamp())
57    }
58
59    /// True where a backward shift repeats it, so only an offset tells its two instants apart.
60    #[must_use]
61    pub fn repeats(&self, wall_clock: DateTime) -> bool {
62        matches!(
63            self.zone.to_ambiguous_timestamp(wall_clock).offset(),
64            AmbiguousOffset::Fold { .. }
65        )
66    }
67
68    #[must_use]
69    pub fn repeated_offset(&self, instant: Instant) -> Option<String> {
70        let zoned = instant.timestamp().to_zoned(self.zone.clone());
71        self.repeats(zoned.datetime())
72            .then(|| zoned.strftime("%:z").to_string())
73    }
74
75    /// On the zone's wall clock with its offset there, such as `2026-10-25T02:30:00+01:00`.
76    #[must_use]
77    pub fn with_offset(&self, instant: Instant) -> String {
78        let offset = self.zone.to_offset(instant.timestamp());
79        instant.timestamp().display_with_offset(offset).to_string()
80    }
81
82    pub fn instant_at(&self, wall_clock: DateTime) -> Result<Instant> {
83        let placed = self.zone.to_ambiguous_timestamp(wall_clock);
84        if let AmbiguousOffset::Fold { before, after } = placed.offset() {
85            let [earlier, later] = [before, after].map(|offset| {
86                offset
87                    .to_timestamp(wall_clock)
88                    .expect("the zone's own offsets place a time it repeats")
89                    .display_with_offset(offset)
90                    .to_string()
91            });
92            let refusal = FieldError::new(
93                "instant",
94                "validation.instant.repeated",
95                format!(
96                    "{wall_clock} happens twice in {}; give its offset: {earlier} or {later}",
97                    self.name()
98                ),
99            );
100            return Err(Error::Invalid(vec![
101                refusal
102                    .with("wall_clock", wall_clock.to_string())
103                    .with("zone", self.name())
104                    .with("earlier", earlier)
105                    .with("later", later),
106            ]));
107        }
108        wall_clock_instant(&self.zone, wall_clock)
109            .map(Instant::from)
110            .ok_or_else(|| {
111                let refusal = FieldError::new(
112                    "instant",
113                    "validation.instant.out_of_range",
114                    format!("{wall_clock} in {} is out of range", self.name()),
115                );
116                Error::Invalid(vec![
117                    refusal
118                        .with("wall_clock", wall_clock.to_string())
119                        .with("zone", self.name()),
120                ])
121            })
122    }
123}
124
125/// A time a forward shift skips goes where the shift ends, a repeated one to its first occurrence.
126#[must_use]
127pub fn wall_clock_instant(zone: &TimeZone, local: DateTime) -> Option<jiff::Timestamp> {
128    let ambiguous = zone.to_ambiguous_timestamp(local);
129    let earlier = ambiguous.earlier().ok()?;
130    if !matches!(ambiguous.offset(), AmbiguousOffset::Gap { .. }) {
131        return Some(earlier);
132    }
133    zone.following(earlier)
134        .next()
135        .map(|shift| shift.timestamp())
136}
137
138/// The zoneinfo directory also holds `posixrules`, `Factory`, `+VERSION` and tables, which a
139/// browser's `Intl` refuses; tzdb names its zones with components that start with a capital.
140fn is_iana_identifier(name: &str) -> bool {
141    name != "Factory"
142        && name.split('/').all(|component| {
143            component.starts_with(|first: char| first.is_ascii_uppercase())
144                && component
145                    .chars()
146                    .all(|character| character.is_ascii_alphanumeric() || "_-+".contains(character))
147        })
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn shows_an_instant_on_the_zone_s_wall_clock_and_reads_it_back() {
156        let zone = DisplayZone::named("Europe/Istanbul").unwrap();
157        let instant = Instant::parse("2026-10-11T09:30:00Z").unwrap();
158
159        let wall_clock = zone.wall_clock(instant);
160
161        assert_eq!(wall_clock.to_string(), "2026-10-11T12:30:00");
162        assert_eq!(zone.instant_at(wall_clock).unwrap(), instant);
163        assert_eq!(zone.name(), "Europe/Istanbul");
164    }
165
166    #[test]
167    fn writes_an_instant_in_a_repeated_hour_with_the_offset_that_reads_it_back() {
168        let london = DisplayZone::named("Europe/London").unwrap();
169        for (at, written) in [
170            ("2026-10-25T00:30:00Z", "2026-10-25T01:30:00+01:00"),
171            ("2026-10-25T01:30:15.5Z", "2026-10-25T01:30:15.5+00:00"),
172        ] {
173            let instant = Instant::parse(at).unwrap();
174
175            assert!(london.repeats(london.wall_clock(instant)));
176            assert_eq!(london.with_offset(instant), written);
177            assert_eq!(Instant::parse_in(written, &london).unwrap(), instant);
178        }
179        let outside = Instant::parse("2026-10-25T02:30:00Z").unwrap();
180        assert!(!london.repeats(london.wall_clock(outside)));
181    }
182
183    #[test]
184    fn names_an_instant_s_offset_only_where_its_wall_clock_repeats() {
185        let berlin = DisplayZone::named("Europe/Berlin").unwrap();
186        let offset = |at: &str| berlin.repeated_offset(Instant::parse(at).unwrap());
187
188        assert_eq!(offset("2026-10-25T00:30:00Z").as_deref(), Some("+02:00"));
189        assert_eq!(offset("2026-10-25T01:30:00Z").as_deref(), Some("+01:00"));
190        assert_eq!(offset("2026-10-25T02:30:00Z"), None);
191    }
192
193    #[test]
194    fn refuses_an_unknown_zone_naming_it() {
195        let error = DisplayZone::named("Mars/Olympus").unwrap_err();
196        assert!(error.to_string().contains("`Mars/Olympus`"), "{error}");
197    }
198
199    #[test]
200    fn refuses_a_zone_a_browser_cannot_show_with_the_same_message() {
201        for name in [
202            "posix/Europe/Istanbul",
203            "right/Europe/Istanbul",
204            "Factory",
205            "localtime",
206            "posixrules",
207            "+VERSION",
208            "zone.tab",
209        ] {
210            let error = DisplayZone::named(name).unwrap_err();
211            assert!(
212                error.to_string().contains("is not an IANA time zone"),
213                "{name}: {error}"
214            );
215        }
216    }
217
218    #[test]
219    fn refuses_the_unknown_zone_which_has_no_iana_name() {
220        let error = DisplayZone::named("Etc/Unknown").unwrap_err();
221        assert!(error.to_string().contains("`Etc/Unknown`"), "{error}");
222    }
223
224    #[test]
225    fn keeps_the_database_s_spelling_of_a_zone() {
226        assert_eq!(
227            DisplayZone::named("europe/istanbul").unwrap().name(),
228            "Europe/Istanbul"
229        );
230        assert_eq!(DisplayZone::named("utc").unwrap().name(), "UTC");
231    }
232
233    #[test]
234    fn reading_the_zone_fixes_it_so_a_later_other_zone_is_refused() {
235        assert_eq!(DisplayZone::current().name(), "UTC");
236
237        let error = DisplayZone::named("Europe/Berlin")
238            .unwrap()
239            .install()
240            .unwrap_err();
241
242        assert!(error.to_string().contains("first read"), "{error}");
243        assert_eq!(DisplayZone::current().name(), "UTC");
244        assert!(DisplayZone::named("UTC").unwrap().install().is_ok());
245    }
246
247    #[test]
248    fn is_utc_until_installed() {
249        assert_eq!(
250            DisplayZone::current()
251                .wall_clock(Instant::parse("2026-10-11T09:30:00Z").unwrap())
252                .to_string(),
253            "2026-10-11T09:30:00"
254        );
255    }
256}