sofie_core/
display_zone.rs1use std::sync::OnceLock;
2
3use jiff::civil::DateTime;
4use jiff::tz::{AmbiguousOffset, TimeZone};
5
6use crate::{Error, FieldError, Instant, Result};
7
8static INSTALLED: OnceLock<DisplayZone> = OnceLock::new();
10
11#[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 #[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 #[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 #[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#[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
138fn 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}