1#[cfg(feature = "system-timezone")]
2use sha2::{Digest, Sha256};
3#[cfg(feature = "system-timezone")]
4use std::cell::RefCell;
5use std::sync::{Arc, OnceLock};
6
7mod civil;
8
9pub use civil::{
10 CivilDate, CivilDateTime, IsoWeek, OffsetDateTime, ParseCivilDateError, UtcOffset, Weekday,
11 days_in_month, is_leap_year,
12};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct LocalTimeZoneProvenance {
17 source: LocalTimeZoneSource,
18 identifier: String,
19 rules_sha256: Option<String>,
20}
21
22impl LocalTimeZoneProvenance {
23 pub const fn source(&self) -> LocalTimeZoneSource {
24 self.source
25 }
26
27 pub fn identifier(&self) -> &str {
28 &self.identifier
29 }
30
31 pub fn rules_sha256(&self) -> Option<&str> {
33 self.rules_sha256.as_deref()
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum LocalTimeZoneSource {
39 FixedOffset,
40 System,
41}
42
43#[derive(Debug, Clone)]
53pub struct LocalTimeZone {
54 data: Arc<LocalTimeZoneData>,
55}
56
57#[derive(Debug)]
58struct LocalTimeZoneData {
59 inner: LocalTimeZoneInner,
60 provenance: OnceLock<LocalTimeZoneProvenance>,
61}
62
63#[derive(Debug)]
64enum LocalTimeZoneInner {
65 Fixed(UtcOffset),
66 #[cfg(feature = "system-timezone")]
67 System(jiff::tz::TimeZone),
68}
69
70impl LocalTimeZone {
71 pub fn fixed(offset_minutes: i32) -> Result<Self, crate::runtime::RuntimePolicyError> {
72 let offset = UtcOffset::from_minutes(offset_minutes).ok_or(
73 crate::runtime::RuntimePolicyError::InvalidFixedOffset(offset_minutes),
74 )?;
75 Ok(Self {
76 data: Arc::new(LocalTimeZoneData {
77 inner: LocalTimeZoneInner::Fixed(offset),
78 provenance: OnceLock::from(LocalTimeZoneProvenance {
79 source: LocalTimeZoneSource::FixedOffset,
80 identifier: offset.to_string(),
81 rules_sha256: None,
82 }),
83 }),
84 })
85 }
86
87 pub fn utc() -> Self {
88 Self::fixed(0).expect("UTC is a valid fixed offset")
89 }
90
91 pub fn try_system() -> Result<Self, crate::runtime::RuntimePolicyError> {
93 #[cfg(feature = "system-timezone")]
94 {
95 thread_local! {
96 static CACHE: RefCell<Option<LocalTimeZone>> = const { RefCell::new(None) };
97 }
98
99 let time_zone = jiff::tz::TimeZone::try_system().map_err(|error| {
100 crate::runtime::RuntimePolicyError::SystemTimeZone(error.to_string())
101 })?;
102 CACHE.with(|cache| {
103 let mut cache = cache.borrow_mut();
104 if let Some(cached) = cache.as_ref()
105 && cached.materialized_system_time_zone() == Some(&time_zone)
106 {
107 return Ok(cached.clone());
108 }
109 let captured = Self {
110 data: Arc::new(LocalTimeZoneData {
111 inner: LocalTimeZoneInner::System(time_zone),
112 provenance: OnceLock::new(),
113 }),
114 };
115 *cache = Some(captured.clone());
116 Ok(captured)
117 })
118 }
119 #[cfg(not(feature = "system-timezone"))]
120 {
121 Err(crate::runtime::RuntimePolicyError::MissingCapability(
122 crate::runtime::RuntimeCapability::SystemTimeZone,
123 ))
124 }
125 }
126
127 pub fn provenance(&self) -> &LocalTimeZoneProvenance {
128 self.data.provenance.get_or_init(|| match &self.data.inner {
129 LocalTimeZoneInner::Fixed(offset) => LocalTimeZoneProvenance {
130 source: LocalTimeZoneSource::FixedOffset,
131 identifier: offset.to_string(),
132 rules_sha256: None,
133 },
134 #[cfg(feature = "system-timezone")]
135 LocalTimeZoneInner::System(time_zone) => system_provenance(time_zone),
136 })
137 }
138
139 pub fn is_system(&self) -> bool {
140 #[cfg(feature = "system-timezone")]
141 {
142 matches!(self.data.inner, LocalTimeZoneInner::System(_))
143 }
144 #[cfg(not(feature = "system-timezone"))]
145 {
146 false
147 }
148 }
149
150 pub fn fixed_offset_minutes(&self) -> Option<i32> {
151 match &self.data.inner {
152 LocalTimeZoneInner::Fixed(offset) => Some(offset.minutes()),
153 #[cfg(feature = "system-timezone")]
154 LocalTimeZoneInner::System(_) => None,
155 }
156 }
157
158 pub fn resolve_local(&self, local: CivilDateTime) -> Option<OffsetDateTime> {
163 match &self.data.inner {
164 LocalTimeZoneInner::Fixed(offset) => OffsetDateTime::from_local(local, *offset),
165 #[cfg(feature = "system-timezone")]
166 LocalTimeZoneInner::System(time_zone) => resolve_local_system(time_zone, local),
167 }
168 }
169
170 pub fn at_instant(&self, unix_millis: i64) -> Option<OffsetDateTime> {
172 let instant = OffsetDateTime::from_unix_millis(unix_millis, UtcOffset::UTC);
173 match &self.data.inner {
174 LocalTimeZoneInner::Fixed(offset) => Some(instant.to_offset(*offset)),
175 #[cfg(feature = "system-timezone")]
176 LocalTimeZoneInner::System(time_zone) => {
177 let timestamp = utc_timestamp_candidates(instant.utc_datetime()).next()?;
178 let offset = UtcOffset::from_seconds(time_zone.to_offset(timestamp).seconds())?;
179 Some(instant.to_offset(offset))
180 }
181 }
182 }
183
184 #[cfg(feature = "system-timezone")]
185 fn materialized_system_time_zone(&self) -> Option<&jiff::tz::TimeZone> {
186 match &self.data.inner {
187 LocalTimeZoneInner::Fixed(_) => None,
188 LocalTimeZoneInner::System(time_zone) => Some(time_zone),
189 }
190 }
191}
192
193impl PartialEq for LocalTimeZone {
194 fn eq(&self, other: &Self) -> bool {
195 match (&self.data.inner, &other.data.inner) {
196 (LocalTimeZoneInner::Fixed(left), LocalTimeZoneInner::Fixed(right)) => left == right,
197 #[cfg(feature = "system-timezone")]
198 (LocalTimeZoneInner::System(left), LocalTimeZoneInner::System(right)) => left == right,
199 #[cfg(feature = "system-timezone")]
200 _ => false,
201 }
202 }
203}
204
205impl Eq for LocalTimeZone {}
206
207#[cfg(feature = "system-timezone")]
208const JIFF_MIN_YEAR: i32 = -9999;
209
210#[cfg(feature = "system-timezone")]
211const JIFF_MAX_YEAR: i32 = 9999;
212
213#[cfg(feature = "system-timezone")]
214const GREGORIAN_CYCLE_YEARS: i32 = 400;
215
216#[cfg(feature = "system-timezone")]
217fn jiff_year_candidates(year: i32) -> [Option<i32>; 2] {
218 if (JIFF_MIN_YEAR..=JIFF_MAX_YEAR).contains(&year) {
219 let fallback = if year > JIFF_MAX_YEAR - GREGORIAN_CYCLE_YEARS {
220 Some(year - GREGORIAN_CYCLE_YEARS)
221 } else if year < JIFF_MIN_YEAR + GREGORIAN_CYCLE_YEARS {
222 Some(year + GREGORIAN_CYCLE_YEARS)
223 } else {
224 None
225 };
226 return [Some(year), fallback];
227 }
228
229 let projected = if year > JIFF_MAX_YEAR {
230 JIFF_MAX_YEAR - GREGORIAN_CYCLE_YEARS + 1 + year.rem_euclid(GREGORIAN_CYCLE_YEARS)
233 } else {
234 let distance = i64::from(year) - i64::from(JIFF_MIN_YEAR);
237 JIFF_MIN_YEAR + distance.rem_euclid(i64::from(GREGORIAN_CYCLE_YEARS)) as i32
238 };
239 [Some(projected), None]
240}
241
242#[cfg(feature = "system-timezone")]
243fn jiff_civil_datetime(local: CivilDateTime, year: i32) -> Option<jiff::civil::DateTime> {
244 jiff::civil::DateTime::new(
245 year.try_into().ok()?,
246 local.month().try_into().ok()?,
247 local.day().try_into().ok()?,
248 local.hour().try_into().ok()?,
249 local.minute().try_into().ok()?,
250 local.second().try_into().ok()?,
251 (local.millisecond() * 1_000_000).try_into().ok()?,
252 )
253 .ok()
254}
255
256#[cfg(feature = "system-timezone")]
257fn resolve_local_system(
258 time_zone: &jiff::tz::TimeZone,
259 local: CivilDateTime,
260) -> Option<OffsetDateTime> {
261 for year in jiff_year_candidates(local.year()).into_iter().flatten() {
262 let Some(civil) = jiff_civil_datetime(local, year) else {
263 continue;
264 };
265 let Ok(timestamp) = time_zone.to_timestamp(civil) else {
266 continue;
267 };
268 let offset = UtcOffset::from_seconds(time_zone.to_offset(timestamp).seconds())?;
269 let resolved = OffsetDateTime::from_unix_millis(timestamp.as_millisecond(), offset);
270 if year == local.year() {
271 return Some(resolved);
272 }
273
274 let projected = CivilDate::new(year, local.month(), local.day())?.at_hms_milli(
275 local.hour(),
276 local.minute(),
277 local.second(),
278 local.millisecond(),
279 )?;
280 let compatible_shift = resolved
281 .local_datetime()
282 .signed_duration_millis_since(projected)?;
283 let original_resolved = local.checked_add_millis(compatible_shift)?;
284 return OffsetDateTime::from_local(original_resolved, offset);
285 }
286 None
287}
288
289#[cfg(feature = "system-timezone")]
290fn utc_timestamp_candidates(utc: CivilDateTime) -> impl Iterator<Item = jiff::Timestamp> {
291 jiff_year_candidates(utc.year())
292 .into_iter()
293 .flatten()
294 .filter_map(move |year| {
295 let mapped = CivilDate::new(year, utc.month(), utc.day())?.at_hms_milli(
296 utc.hour(),
297 utc.minute(),
298 utc.second(),
299 utc.millisecond(),
300 )?;
301 jiff::Timestamp::from_millisecond(mapped.naive_unix_millis()?).ok()
302 })
303}
304
305#[cfg(feature = "system-timezone")]
306fn system_provenance(time_zone: &jiff::tz::TimeZone) -> LocalTimeZoneProvenance {
307 let identifier = time_zone.iana_name().map(str::to_owned).unwrap_or_else(|| {
308 if time_zone.is_unknown() {
309 "Etc/Unknown".to_string()
310 } else {
311 format!("{time_zone:?}")
312 }
313 });
314
315 let mut digest = Sha256::new();
316 digest.update(b"merman-local-time-zone-offset-rules-v1\0");
317 let minimum = jiff::Timestamp::MIN;
318 digest.update(time_zone.to_offset(minimum).seconds().to_be_bytes());
319 for transition in time_zone.following(minimum) {
320 let timestamp = transition.timestamp();
321 digest.update(timestamp.as_second().to_be_bytes());
322 digest.update(timestamp.subsec_nanosecond().to_be_bytes());
323 digest.update(transition.offset().seconds().to_be_bytes());
324 }
325 let hash = digest.finalize();
326 let mut rules_sha256 = String::with_capacity(hash.len() * 2);
327 use std::fmt::Write as _;
328 for byte in hash {
329 write!(rules_sha256, "{byte:02x}").expect("writing to a String cannot fail");
330 }
331
332 LocalTimeZoneProvenance {
333 source: LocalTimeZoneSource::System,
334 identifier,
335 rules_sha256: Some(rules_sha256),
336 }
337}
338
339#[cfg(all(test, feature = "system-timezone"))]
340mod system_timezone_tests {
341 use super::*;
342
343 fn date_time(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> CivilDateTime {
344 CivilDate::new(year, month, day)
345 .expect("valid date")
346 .at_hms(hour, minute, 0)
347 .expect("valid time")
348 }
349
350 #[test]
351 fn deterministic_resolver_construction_uses_bounded_stack() {
352 let handle = std::thread::Builder::new()
353 .name("merman-core-deterministic-time-zone-small-stack".to_string())
354 .stack_size(256 * 1024)
355 .spawn(|| std::hint::black_box(LocalTimeZone::utc()))
356 .expect("spawn deterministic time-zone construction test");
357
358 handle
359 .join()
360 .expect("deterministic time-zone construction should not overflow a 256 KiB stack");
361 }
362
363 #[test]
364 fn system_resolver_supports_mermaid_year_boundary_beyond_jiff_civil_range() {
365 let resolver = LocalTimeZone::try_system().expect("system time-zone adapter");
366 for year in [-10000, 10000] {
367 let local = date_time(year, 1, 1, 0, 0);
368 let resolved = resolver
369 .resolve_local(local)
370 .expect("system TZif rules should project the boundary year");
371 assert_eq!(resolved.local_datetime(), local);
372 }
373 }
374
375 #[test]
376 fn system_resolver_maps_out_of_range_utc_instants_without_losing_the_instant() {
377 let resolver = LocalTimeZone::try_system().expect("system time-zone adapter");
378 let utc = OffsetDateTime::from_local(date_time(10000, 1, 1, 0, 0), UtcOffset::UTC)
379 .expect("boundary UTC instant");
380 let local = resolver
381 .at_instant(utc.timestamp_millis())
382 .expect("system TZif rules should resolve the boundary instant");
383 assert_eq!(local.timestamp_millis(), utc.timestamp_millis());
384 }
385
386 #[test]
387 fn system_resolver_preserves_compatible_dst_gap_resolution() {
388 let time_zone = jiff::tz::TimeZone::posix("EST5EDT,M3.2.0,M11.1.0")
389 .expect("valid deterministic US Eastern POSIX rule");
390 let resolver = LocalTimeZone {
391 data: Arc::new(LocalTimeZoneData {
392 inner: LocalTimeZoneInner::System(time_zone),
393 provenance: OnceLock::new(),
394 }),
395 };
396 let resolved = resolver
397 .resolve_local(date_time(2026, 3, 8, 2, 30))
398 .expect("compatible DST gap resolution should succeed");
399 assert_eq!(resolved.local_datetime().hour(), 3);
400 assert_eq!(resolved.local_datetime().minute(), 30);
401 }
402
403 #[test]
404 fn system_resolver_preserves_compatible_dst_fold_resolution() {
405 let time_zone = jiff::tz::TimeZone::posix("EST5EDT,M3.2.0,M11.1.0")
406 .expect("valid deterministic US Eastern POSIX rule");
407 let resolver = LocalTimeZone {
408 data: Arc::new(LocalTimeZoneData {
409 inner: LocalTimeZoneInner::System(time_zone),
410 provenance: OnceLock::new(),
411 }),
412 };
413 let repeated = date_time(2026, 11, 1, 1, 30);
414
415 let resolved = resolver
416 .resolve_local(repeated)
417 .expect("compatible DST fold resolution should succeed");
418
419 assert_eq!(resolved.local_datetime(), repeated);
420 assert_eq!(resolved.offset().seconds(), -4 * 60 * 60);
421 }
422}