1use core::fmt;
4
5use crate::{
6 civil::SECS_PER_DAY,
7 error::{self, Error},
8 tzdb::{self, ZoneEntry, ZoneRules},
9 tzif::ZoneInfo,
10};
11
12#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
18#[non_exhaustive]
19pub enum Disambiguation {
20 Compatible,
23 Earlier,
26 Later,
29 Reject,
32}
33
34#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
36pub enum Ambiguity {
37 Exact {
39 offset: i32,
41 },
42 Fold {
44 earlier: i32,
46 later: i32,
48 },
49 Gap {
51 before: i32,
53 after: i32,
55 },
56}
57
58pub(crate) const OFFSET_LIMIT: i32 = 93_599;
60
61const PROBE: i64 = 2 * SECS_PER_DAY;
63
64#[derive(Clone, Copy)]
83pub struct TimeZone {
84 zone: ZoneKind,
85}
86
87#[derive(Clone, Copy)]
88enum ZoneKind {
89 Utc,
92 Fixed {
94 offset: i32,
96 },
97 Named(&'static ZoneEntry),
100}
101
102impl TimeZone {
103 pub const UTC: TimeZone = TimeZone {
105 zone: ZoneKind::Utc,
106 };
107
108 pub const fn fixed(offset: i32) -> Result<TimeZone, Error> {
114 if offset < -OFFSET_LIMIT || offset > OFFSET_LIMIT {
115 return Err(error::out_of_range("UTC offset is outside the supported range"));
116 }
117 Ok(TimeZone {
118 zone: ZoneKind::Fixed {
119 offset,
120 },
121 })
122 }
123
124 pub fn named(name: &str) -> Result<TimeZone, Error> {
138 if is_utc_convenience(name) {
139 return Ok(TimeZone::UTC);
140 }
141 if name.eq_ignore_ascii_case("local") {
142 return TimeZone::system();
143 }
144 match tzdb::lookup(name) {
145 Some(entry) => Ok(TimeZone {
146 zone: ZoneKind::Named(entry),
147 }),
148 None => Err(error::unknown_time_zone("unknown time zone")),
149 }
150 }
151
152 pub(crate) const fn annotation_name(&self) -> Option<&'static str> {
155 match self.zone {
156 ZoneKind::Named(entry) => Some(entry.name),
157 _ => None,
158 }
159 }
160
161 #[must_use]
163 #[inline]
164 pub const fn name(&self) -> Option<&'static str> {
165 match self.zone {
166 ZoneKind::Utc => Some("UTC"),
167 ZoneKind::Fixed {
168 ..
169 } => None,
170 ZoneKind::Named(entry) => Some(entry.name),
171 }
172 }
173
174 pub(crate) fn offset_at(self, secs: i64) -> i32 {
177 match self.info_at(secs) {
178 Some(info) => info.offset,
179 None => 0,
180 }
181 }
182
183 pub(crate) fn info_at(self, secs: i64) -> Option<ZoneInfo<'static>> {
185 Some(match self.zone {
186 ZoneKind::Utc => ZoneInfo {
187 offset: 0,
188 is_dst: false,
189 abbrev: "UTC",
190 },
191 ZoneKind::Fixed {
192 offset,
193 } => ZoneInfo {
194 offset,
195 is_dst: false,
196 abbrev: "",
197 },
198 ZoneKind::Named(entry) => match &entry.rules {
199 ZoneRules::Embedded(zone) => {
200 let info = embedded_at(zone, secs);
201 #[cfg(all(test, feature = "timezone-db"))]
202 parity_check(entry.name, secs, &info);
203 info
204 }
205 ZoneRules::Runtime(tzif) => tzif.lookup(secs)?,
206 },
207 })
208 }
209
210 pub(crate) fn is_dst_at(self, secs: i64) -> bool {
212 self.info_at(secs).is_some_and(|info| info.is_dst)
213 }
214
215 pub(crate) fn resolve_compatible(self, local_secs: i64) -> i64 {
220 match self.resolve_with(local_secs, Disambiguation::Compatible) {
221 Ok(instant) => instant,
222 Err(_) => local_secs,
224 }
225 }
226
227 fn candidate_offsets(self, local_secs: i64) -> (i32, i32) {
237 if let ZoneKind::Named(entry) = self.zone {
238 let nearby = match &entry.rules {
239 ZoneRules::Embedded(zone) => embedded_nearby(zone, local_secs),
240 ZoneRules::Runtime(tzif) => tzif.nearby_offsets(local_secs),
241 };
242 if let Some(pair) = nearby {
243 return pair;
244 }
245 }
246 (self.offset_at(local_secs - PROBE), self.offset_at(local_secs + PROBE))
247 }
248
249 pub(crate) fn resolve_with(self, local_secs: i64, disambiguation: Disambiguation) -> Result<i64, Error> {
252 match self.zone {
253 ZoneKind::Utc => Ok(local_secs),
254 ZoneKind::Fixed {
255 offset,
256 } => Ok(local_secs - i64::from(offset)),
257 ZoneKind::Named(..) => {
258 let (offset_before, offset_after) = self.candidate_offsets(local_secs);
259 if offset_before == offset_after {
260 return Ok(local_secs - i64::from(offset_before));
261 }
262 let instant_before = local_secs - i64::from(offset_before);
263 let instant_after = local_secs - i64::from(offset_after);
264 let before_ok = self.offset_at(instant_before) == offset_before;
265 let after_ok = self.offset_at(instant_after) == offset_after;
266 match (before_ok, after_ok) {
267 (true, true) => match disambiguation {
269 Disambiguation::Later => Ok(instant_after),
270 Disambiguation::Reject => Err(error::ambiguous("civil time occurs twice in this time zone")),
271 _ => Ok(instant_before),
272 },
273 (true, false) => Ok(instant_before),
274 (false, true) => Ok(instant_after),
275 (false, false) => match disambiguation {
277 Disambiguation::Reject => Err(error::ambiguous("civil time does not exist in this time zone")),
278 _ => Ok(instant_before),
279 },
280 }
281 }
282 }
283 }
284
285 pub(crate) fn classify(self, local_secs: i64) -> Ambiguity {
290 match self.zone {
291 ZoneKind::Utc => Ambiguity::Exact {
292 offset: 0,
293 },
294 ZoneKind::Fixed {
295 offset,
296 } => Ambiguity::Exact {
297 offset,
298 },
299 ZoneKind::Named(..) => {
300 let (offset_before, offset_after) = self.candidate_offsets(local_secs);
301 if offset_before == offset_after {
302 return Ambiguity::Exact {
303 offset: offset_before,
304 };
305 }
306 let instant_before = local_secs - i64::from(offset_before);
307 let instant_after = local_secs - i64::from(offset_after);
308 let before_ok = self.offset_at(instant_before) == offset_before;
309 let after_ok = self.offset_at(instant_after) == offset_after;
310 match (before_ok, after_ok) {
311 (true, true) => Ambiguity::Fold {
313 earlier: offset_before,
314 later: offset_after,
315 },
316 (true, false) => Ambiguity::Exact {
319 offset: offset_before,
320 },
321 (false, true) => Ambiguity::Exact {
322 offset: offset_after,
323 },
324 (false, false) => Ambiguity::Gap {
326 before: offset_before,
327 after: offset_after,
328 },
329 }
330 }
331 }
332 }
333
334 pub fn ambiguity(
340 self,
341 year: i32,
342 month: u8,
343 day: u8,
344 hour: u8,
345 minute: u8,
346 second: u8,
347 ) -> Result<Ambiguity, Error> {
348 let local = crate::datetime::local_seconds_checked(year, month, day, hour, minute, second)?;
349 Ok(self.classify(local))
350 }
351
352 pub(crate) fn bounds_at(self, secs: i64) -> (Option<i64>, Option<i64>) {
355 match self.zone {
356 ZoneKind::Named(entry) => match &entry.rules {
357 ZoneRules::Embedded(zone) => {
358 let starts = zone.starts;
359 if starts.is_empty() {
360 return (None, None);
361 }
362 let past = starts.partition_point(|&start| start <= secs);
363 if past == 0 {
364 return (None, Some(starts[0]));
365 }
366 if past == starts.len() {
367 return (Some(starts[starts.len() - 1]), None);
368 }
369 (Some(starts[past - 1]), Some(starts[past]))
370 }
371 ZoneRules::Runtime(_) => (None, None),
373 },
374 _ => (None, None),
375 }
376 }
377
378 #[cfg(feature = "timezone-system")]
395 pub fn system() -> Result<TimeZone, Error> {
396 static SYSTEM_ZONE: std::sync::OnceLock<Result<TimeZone, Error>> = std::sync::OnceLock::new();
397 *SYSTEM_ZONE.get_or_init(system::detect)
398 }
399
400 #[cfg(not(feature = "timezone-system"))]
409 pub fn system() -> Result<TimeZone, Error> {
410 Ok(TimeZone::UTC)
411 }
412}
413
414fn is_utc_convenience(name: &str) -> bool {
422 if name.eq_ignore_ascii_case("z") {
423 return true;
424 }
425 #[cfg(not(feature = "timezone-db"))]
426 {
427 ["utc", "gmt", "zulu", "universal", "uct", "etc/utc", "etc/gmt"]
428 .iter()
429 .any(|candidate| name.eq_ignore_ascii_case(candidate))
430 }
431 #[cfg(feature = "timezone-db")]
432 {
433 false
434 }
435}
436
437#[cfg(feature = "timezone-db")]
439fn embedded_at(zone: &'static tzdb::ZoneData, secs: i64) -> ZoneInfo<'static> {
440 zone.lookup(secs)
441}
442
443#[cfg(not(feature = "timezone-db"))]
447fn embedded_at(_zone: &'static tzdb::ZoneData, _secs: i64) -> ZoneInfo<'static> {
448 ZoneInfo {
449 offset: 0,
450 is_dst: false,
451 abbrev: "",
452 }
453}
454
455fn embedded_nearby(zone: &'static tzdb::ZoneData, local: i64) -> Option<(i32, i32)> {
459 let starts = zone.starts;
460 if starts.is_empty() {
461 return None;
462 }
463 let past = starts.partition_point(|&start| start <= local);
464 let mut best_index = 0usize;
465 let mut best_distance = i64::MAX;
466 let mut consider = |index: usize| {
467 if index < starts.len() {
468 let distance = (starts[index] - local).abs();
469 if distance < best_distance {
470 best_distance = distance;
471 best_index = index;
472 }
473 }
474 };
475 if let Some(index) = past.checked_sub(1) {
476 consider(index);
477 }
478 consider(past);
479 if best_distance > 2 * i64::from(OFFSET_LIMIT) {
480 return None;
481 }
482 let after = zone.record_at(best_index).offset;
483 let before = if best_index == 0 {
484 zone.initial.offset
485 } else {
486 zone.record_at(best_index - 1).offset
487 };
488 Some((before, after))
489}
490
491#[cfg(all(test, feature = "timezone-db"))]
494fn parity_check(name: &'static str, secs: i64, evaluated: &ZoneInfo<'static>) {
495 if let Some((_, data)) = tzdb::lookup_legacy(name) {
496 if let Some(expected) = crate::tzif::Tzif::parse(data).and_then(|tz| tz.lookup(secs)) {
497 assert!(
498 expected.offset == evaluated.offset
499 && expected.is_dst == evaluated.is_dst
500 && expected.abbrev == evaluated.abbrev,
501 "zone engine mismatch for {name} at {secs}: {expected:?} != {evaluated:?}",
502 );
503 }
504 }
505}
506
507impl fmt::Debug for TimeZone {
508 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
509 match self.zone {
510 ZoneKind::Utc => f.write_str("Utc"),
511 ZoneKind::Fixed {
512 offset,
513 } => write!(f, "Fixed({offset})"),
514 ZoneKind::Named(entry) => write!(f, "Named({})", entry.name),
515 }
516 }
517}
518
519impl fmt::Display for TimeZone {
520 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
521 match self.zone {
522 ZoneKind::Utc => f.write_str("UTC"),
523 ZoneKind::Fixed {
524 offset,
525 } => crate::format::write_offset(f, offset),
526 ZoneKind::Named(entry) => f.write_str(entry.name),
527 }
528 }
529}
530
531impl PartialEq for TimeZone {
532 fn eq(&self, other: &TimeZone) -> bool {
533 match (self.zone, other.zone) {
534 (ZoneKind::Utc, ZoneKind::Utc) => true,
535 (
536 ZoneKind::Fixed {
537 offset: left,
538 },
539 ZoneKind::Fixed {
540 offset: right,
541 },
542 ) => left == right,
543 (ZoneKind::Named(left), ZoneKind::Named(right)) => left.name == right.name,
544 _ => false,
545 }
546 }
547}
548
549impl Eq for TimeZone {}
550
551impl core::hash::Hash for TimeZone {
552 fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
553 match self.zone {
554 ZoneKind::Utc => 0u8.hash(state),
555 ZoneKind::Fixed {
556 offset,
557 } => {
558 1u8.hash(state);
559 offset.hash(state);
560 }
561 ZoneKind::Named(entry) => {
562 2u8.hash(state);
563 entry.name.hash(state);
564 }
565 }
566 }
567}
568
569#[cfg(feature = "timezone-system")]
570mod system {
571 use alloc::{borrow::ToOwned, boxed::Box, vec::Vec};
572
573 use small_collections::SmallString;
574
575 use super::{TimeZone, ZoneEntry, ZoneKind, ZoneRules};
576 use crate::{
577 error::{self, Error},
578 tzif::Tzif,
579 };
580
581 fn leak_named(name: &str, data: Vec<u8>) -> Result<TimeZone, Error> {
588 Tzif::parse(&data).ok_or_else(|| error::unknown_system_time_zone("cannot parse system time zone data"))?;
589 let data: &'static [u8] = Box::leak(data.into_boxed_slice());
590 let tzif: Tzif<'static> =
591 Tzif::parse(data).ok_or_else(|| error::unknown_system_time_zone("cannot parse system time zone data"))?;
592 let rules: &'static Tzif<'static> = Box::leak(Box::new(tzif));
593 let entry: &'static ZoneEntry = Box::leak(Box::new(ZoneEntry {
594 name: Box::leak(name.to_owned().into_boxed_str()),
595 rules: ZoneRules::Runtime(rules),
596 }));
597 Ok(TimeZone {
598 zone: ZoneKind::Named(entry),
599 })
600 }
601
602 const PATH_INLINE_LENGTH: usize = 128;
608
609 fn read_zone_file(dir: &str, value: &str) -> Option<Vec<u8>> {
611 let mut path: SmallString<PATH_INLINE_LENGTH> = SmallString::new();
612 path.push_str(dir);
613 path.push('/');
614 path.push_str(value);
615 std::fs::read(path.as_str()).ok()
616 }
617
618 fn name_from_path(path: &str) -> &str {
619 path.find("/zoneinfo/")
620 .map(|index| &path[index + "/zoneinfo/".len()..])
621 .unwrap_or("localtime")
622 }
623
624 pub(super) fn detect() -> Result<TimeZone, Error> {
625 if let Ok(value) = std::env::var("TZ") {
626 let value = value.strip_prefix(':').unwrap_or(&value);
627 if value.is_empty() {
628 return Ok(TimeZone::UTC);
629 }
630 if value.starts_with('/') || value.starts_with('.') {
631 let data =
632 std::fs::read(value).map_err(|_| error::unknown_system_time_zone("cannot read TZ time zone"))?;
633 return leak_named(value, data);
634 }
635 if let Ok(zone) = TimeZone::named(value) {
636 return Ok(zone);
637 }
638 let tzdir = std::env::var("TZDIR").ok();
639 for dir in tzdir.as_deref().into_iter().chain([
640 "/usr/share/zoneinfo",
641 "/usr/lib/zoneinfo",
642 "/usr/share/lib/zoneinfo",
643 "/etc/zoneinfo",
644 ]) {
645 if let Some(data) = read_zone_file(dir, value) {
646 return leak_named(value, data);
647 }
648 }
649 return Err(error::unknown_time_zone("unknown TZ time zone"));
650 }
651
652 let data = std::fs::read("/etc/localtime")
653 .map_err(|_| error::unknown_system_time_zone("cannot read /etc/localtime"))?;
654 let link = std::fs::read_link("/etc/localtime").ok();
655 let name = link
656 .as_deref()
657 .and_then(|path| path.to_str())
658 .map(name_from_path)
659 .unwrap_or("localtime");
660 leak_named(name, data)
661 }
662}