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
138#[must_use]
141pub fn is_iana_identifier(name: &str) -> bool {
142 name != "Factory"
143 && name.split('/').all(|component| {
144 component.starts_with(|first: char| first.is_ascii_uppercase())
145 && component
146 .chars()
147 .all(|character| character.is_ascii_alphanumeric() || "_-+".contains(character))
148 })
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn shows_an_instant_on_the_zone_s_wall_clock_and_reads_it_back() {
157 let zone = DisplayZone::named("Europe/Istanbul").unwrap();
158 let instant = Instant::parse("2026-10-11T09:30:00Z").unwrap();
159
160 let wall_clock = zone.wall_clock(instant);
161
162 assert_eq!(wall_clock.to_string(), "2026-10-11T12:30:00");
163 assert_eq!(zone.instant_at(wall_clock).unwrap(), instant);
164 assert_eq!(zone.name(), "Europe/Istanbul");
165 }
166
167 #[test]
168 fn writes_an_instant_in_a_repeated_hour_with_the_offset_that_reads_it_back() {
169 let london = DisplayZone::named("Europe/London").unwrap();
170 for (at, written) in [
171 ("2026-10-25T00:30:00Z", "2026-10-25T01:30:00+01:00"),
172 ("2026-10-25T01:30:15.5Z", "2026-10-25T01:30:15.5+00:00"),
173 ] {
174 let instant = Instant::parse(at).unwrap();
175
176 assert!(london.repeats(london.wall_clock(instant)));
177 assert_eq!(london.with_offset(instant), written);
178 assert_eq!(Instant::parse_in(written, &london).unwrap(), instant);
179 }
180 let outside = Instant::parse("2026-10-25T02:30:00Z").unwrap();
181 assert!(!london.repeats(london.wall_clock(outside)));
182 }
183
184 #[test]
185 fn names_an_instant_s_offset_only_where_its_wall_clock_repeats() {
186 let berlin = DisplayZone::named("Europe/Berlin").unwrap();
187 let offset = |at: &str| berlin.repeated_offset(Instant::parse(at).unwrap());
188
189 assert_eq!(offset("2026-10-25T00:30:00Z").as_deref(), Some("+02:00"));
190 assert_eq!(offset("2026-10-25T01:30:00Z").as_deref(), Some("+01:00"));
191 assert_eq!(offset("2026-10-25T02:30:00Z"), None);
192 }
193
194 #[test]
195 fn refuses_an_unknown_zone_naming_it() {
196 let error = DisplayZone::named("Mars/Olympus").unwrap_err();
197 assert!(error.to_string().contains("`Mars/Olympus`"), "{error}");
198 }
199
200 #[test]
201 fn refuses_a_zone_a_browser_cannot_show_with_the_same_message() {
202 for name in [
203 "posix/Europe/Istanbul",
204 "right/Europe/Istanbul",
205 "Factory",
206 "localtime",
207 "posixrules",
208 "+VERSION",
209 "zone.tab",
210 ] {
211 let error = DisplayZone::named(name).unwrap_err();
212 assert!(
213 error.to_string().contains("is not an IANA time zone"),
214 "{name}: {error}"
215 );
216 }
217 }
218
219 #[test]
220 fn refuses_the_unknown_zone_which_has_no_iana_name() {
221 let error = DisplayZone::named("Etc/Unknown").unwrap_err();
222 assert!(error.to_string().contains("`Etc/Unknown`"), "{error}");
223 }
224
225 #[test]
226 fn keeps_the_database_s_spelling_of_a_zone() {
227 assert_eq!(
228 DisplayZone::named("europe/istanbul").unwrap().name(),
229 "Europe/Istanbul"
230 );
231 assert_eq!(DisplayZone::named("utc").unwrap().name(), "UTC");
232 }
233
234 #[test]
235 fn reading_the_zone_fixes_it_so_a_later_other_zone_is_refused() {
236 assert_eq!(DisplayZone::current().name(), "UTC");
237
238 let error = DisplayZone::named("Europe/Berlin")
239 .unwrap()
240 .install()
241 .unwrap_err();
242
243 assert!(error.to_string().contains("first read"), "{error}");
244 assert_eq!(DisplayZone::current().name(), "UTC");
245 assert!(DisplayZone::named("UTC").unwrap().install().is_ok());
246 }
247
248 #[test]
249 fn is_utc_until_installed() {
250 assert_eq!(
251 DisplayZone::current()
252 .wall_clock(Instant::parse("2026-10-11T09:30:00Z").unwrap())
253 .to_string(),
254 "2026-10-11T09:30:00"
255 );
256 }
257}