Skip to main content

formualizer_eval/
timezone.rs

1/// Clock/timezone support for date/time functions.
2///
3/// Note: deterministic evaluation requires that the evaluation clock is injectable.
4/// Builtins should not call `Local::now()` / `Utc::now()` directly.
5#[cfg(feature = "system-clock")]
6use chrono::{DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, Utc};
7#[cfg(not(feature = "system-clock"))]
8use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, Utc};
9
10/// Timezone specification for date/time calculations
11/// Excel behavior: always uses local timezone
12/// This enum allows future extensions while maintaining Excel compatibility
13#[derive(Clone, Debug, Default, Eq, PartialEq)]
14pub enum TimeZoneSpec {
15    /// Use the system's local timezone (Excel default behavior)
16    #[default]
17    Local,
18    /// Use UTC timezone
19    Utc,
20    /// Use a fixed offset from UTC (seconds east of UTC).
21    ///
22    /// This is the only timezone-like option permitted under deterministic mode.
23    FixedOffsetSeconds(i32),
24    // Named timezone variant removed until feature introduced.
25}
26
27// (Derived Default provides Local)
28
29impl TimeZoneSpec {
30    pub fn fixed_offset(&self) -> Option<FixedOffset> {
31        match self {
32            TimeZoneSpec::Utc => FixedOffset::east_opt(0),
33            TimeZoneSpec::FixedOffsetSeconds(secs) => FixedOffset::east_opt(*secs),
34            TimeZoneSpec::Local => None,
35        }
36    }
37
38    pub fn validate_for_determinism(&self) -> Result<(), String> {
39        match self {
40            TimeZoneSpec::Local => Err(
41                "Deterministic mode forbids `Local` timezone (use UTC or a fixed offset)"
42                    .to_string(),
43            ),
44            TimeZoneSpec::Utc => Ok(()),
45            TimeZoneSpec::FixedOffsetSeconds(secs) => {
46                FixedOffset::east_opt(*secs).ok_or_else(|| {
47                    format!("Invalid fixed offset: {secs} seconds (must be within +/-24h)")
48                })?;
49                Ok(())
50            }
51        }
52    }
53}
54
55/// Injectable clock provider for volatile date/time builtins.
56pub trait ClockProvider: std::fmt::Debug + Send + Sync {
57    fn timezone(&self) -> &TimeZoneSpec;
58    fn now(&self) -> NaiveDateTime;
59    fn today(&self) -> NaiveDate {
60        self.now().date()
61    }
62}
63
64/// Default clock implementation: reads from the system clock.
65///
66/// Only available when the `system-clock` feature is enabled.
67/// For portable wasm / raw wasmtime guests use `FixedClock` or inject your own `ClockProvider`.
68#[cfg(feature = "system-clock")]
69#[derive(Clone, Debug)]
70pub struct SystemClock {
71    timezone: TimeZoneSpec,
72}
73
74#[cfg(feature = "system-clock")]
75impl SystemClock {
76    pub fn new(timezone: TimeZoneSpec) -> Self {
77        Self { timezone }
78    }
79}
80
81#[cfg(feature = "system-clock")]
82impl ClockProvider for SystemClock {
83    fn timezone(&self) -> &TimeZoneSpec {
84        &self.timezone
85    }
86
87    fn now(&self) -> NaiveDateTime {
88        match &self.timezone {
89            TimeZoneSpec::Local => Local::now().naive_local(),
90            TimeZoneSpec::Utc => Utc::now().naive_utc(),
91            TimeZoneSpec::FixedOffsetSeconds(secs) => {
92                let off = FixedOffset::east_opt(*secs)
93                    .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap());
94                let utc_now: DateTime<Utc> = Utc::now();
95                utc_now.with_timezone(&off).naive_local()
96            }
97        }
98    }
99}
100
101/// Deterministic clock implementation: always returns the configured instant.
102#[derive(Clone, Debug)]
103pub struct FixedClock {
104    timestamp_utc: DateTime<Utc>,
105    timezone: TimeZoneSpec,
106}
107
108impl FixedClock {
109    pub fn new(timestamp_utc: DateTime<Utc>, timezone: TimeZoneSpec) -> Self {
110        Self {
111            timestamp_utc,
112            timezone,
113        }
114    }
115
116    pub fn new_deterministic(
117        timestamp_utc: DateTime<Utc>,
118        timezone: TimeZoneSpec,
119    ) -> Result<Self, String> {
120        timezone.validate_for_determinism()?;
121        Ok(Self::new(timestamp_utc, timezone))
122    }
123
124    fn now_in_timezone(&self) -> NaiveDateTime {
125        match &self.timezone {
126            TimeZoneSpec::Utc => self.timestamp_utc.naive_utc(),
127            TimeZoneSpec::FixedOffsetSeconds(secs) => {
128                let off = FixedOffset::east_opt(*secs).expect("validated fixed offset");
129                self.timestamp_utc.with_timezone(&off).naive_local()
130            }
131            TimeZoneSpec::Local => {
132                // Should be unreachable due to validation, but keep behaviour
133                // predictable. Fall back to UTC when Local is unavailable
134                // (portable wasm profile) rather than refusing to compile.
135                #[cfg(feature = "system-clock")]
136                {
137                    self.timestamp_utc.with_timezone(&Local).naive_local()
138                }
139                #[cfg(not(feature = "system-clock"))]
140                {
141                    self.timestamp_utc.naive_utc()
142                }
143            }
144        }
145    }
146}
147
148impl ClockProvider for FixedClock {
149    fn timezone(&self) -> &TimeZoneSpec {
150        &self.timezone
151    }
152
153    fn now(&self) -> NaiveDateTime {
154        self.now_in_timezone()
155    }
156}
157
158/// Per-recalc snapshotting wrapper around a [`ClockProvider`]
159/// (spec `formualizer-cycle-semantics-spec.md` §7.11).
160///
161/// Excel samples the clock ONCE per recalculation: every `NOW()` / `TODAY()`
162/// call within a single recalc — including all iteration passes of an
163/// iterating SCC — observes the same instant, and the sample only advances on
164/// the next recalculation. A raw `SystemClock` violates this (each call reads
165/// the OS clock, so `NOW()` drifts between SCC settle passes and even between
166/// two cells of one acyclic pass).
167///
168/// The engine wraps its configured clock in `SnapshotClock` and calls
169/// [`SnapshotClock::refresh`] once at the start of every evaluation request;
170/// builtins then read the frozen sample via the normal `ClockProvider` API.
171pub struct SnapshotClock {
172    inner: std::sync::Arc<dyn ClockProvider>,
173    /// Timezone cloned from `inner` at construction so `timezone()` can hand
174    /// out a reference without locking.
175    timezone: TimeZoneSpec,
176    timezone_failed: bool,
177    /// The frozen per-recalc sample. RwLock (not Cell) because evaluation may
178    /// read the clock from rayon worker threads.
179    sample: std::sync::RwLock<Result<NaiveDateTime, formualizer_common::ExcelError>>,
180}
181
182impl SnapshotClock {
183    /// Wrap `inner`, taking an initial sample immediately.
184    pub fn new(inner: std::sync::Arc<dyn ClockProvider>) -> Self {
185        let timezone = crate::function::catch_function_panic("resolver:clock", || inner.timezone());
186        let timezone_failed = timezone.is_err();
187        let now = crate::function::catch_function_panic("resolver:clock", || inner.now());
188        let sample = timezone.as_ref().map_err(Clone::clone).and(now);
189        let timezone = timezone.cloned().unwrap_or(TimeZoneSpec::Utc);
190        Self {
191            inner,
192            timezone,
193            timezone_failed,
194            sample: std::sync::RwLock::new(sample),
195        }
196    }
197
198    /// Re-sample once per logical recalculation. Host panics freeze a failed
199    /// sample; clock-reading formula roots receive a typed error and diagnostic.
200    pub fn refresh(&self) {
201        // The external call completes (or is caught) before acquiring the lock.
202        let now = crate::function::catch_function_panic("resolver:clock", || self.inner.now());
203        let now = if self.timezone_failed {
204            crate::function::catch_function_panic("resolver:clock", || self.inner.timezone())
205                .and(now)
206        } else {
207            now
208        };
209        *self.sample.write().expect("clock sample lock poisoned") = now;
210    }
211}
212
213impl std::fmt::Debug for SnapshotClock {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        f.debug_struct("SnapshotClock")
216            .field("timezone", &self.timezone)
217            .finish_non_exhaustive()
218    }
219}
220impl ClockProvider for SnapshotClock {
221    fn timezone(&self) -> &TimeZoneSpec {
222        if self.timezone_failed {
223            match crate::function::catch_function_panic("resolver:clock", || self.inner.timezone())
224            {
225                Ok(timezone) => timezone,
226                Err(error) => {
227                    crate::external_callbacks::clock_failure(error);
228                    &self.timezone
229                }
230            }
231        } else {
232            &self.timezone
233        }
234    }
235
236    fn now(&self) -> NaiveDateTime {
237        let sample = self
238            .sample
239            .read()
240            .expect("clock sample lock poisoned")
241            .clone();
242        match sample {
243            Ok(now) => now,
244            Err(error) => {
245                crate::external_callbacks::clock_failure(error);
246                chrono::DateTime::UNIX_EPOCH.naive_utc()
247            }
248        }
249    }
250}