Skip to main content

merman_core/
time.rs

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/// Stable evidence for the local-time rules used by an engine or render operation.
15#[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    /// SHA-256 of the complete offset-transition behavior supported by the resolver.
32    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/// An immutable local-time resolver.
44///
45/// Materialized system resolvers own the complete time-zone rule set rather than the offset sampled
46/// at "now": another target date may be on the other side of a daylight-saving transition. The
47/// captured rules and their lazily computed provenance are shared by every clone of a resolver.
48///
49/// Wide civil dates remain project-owned. Jiff is used only as the system time-zone rule engine;
50/// dates outside Jiff's civil range are projected onto a Gregorian-equivalent year before rules
51/// are queried and are then mapped back without narrowing the represented instant.
52#[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    /// Captures the system time-zone rules or reports that the adapter is unavailable.
92    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    /// Resolves a local civil date-time using this resolver's compatible DST policy.
159    ///
160    /// Gaps move forward by the gap duration and folds select the earlier instant, matching
161    /// Mermaid's JavaScript date behavior and Jiff's compatible disambiguation.
162    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    /// Applies this resolver's offset rules to an absolute millisecond instant.
171    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        // TZif's POSIX tail repeats on the proleptic Gregorian 400-year cycle. Keep the
231        // projected year in the final supported cycle so the query uses that tail.
232        JIFF_MAX_YEAR - GREGORIAN_CYCLE_YEARS + 1 + year.rem_euclid(GREGORIAN_CYCLE_YEARS)
233    } else {
234        // Before the first TZif transition the initial offset is constant. Keep the projected
235        // year in the first supported cycle so that historical rule remains stable.
236        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}