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.
171#[derive(Debug)]
172pub struct SnapshotClock {
173    inner: std::sync::Arc<dyn ClockProvider>,
174    /// Timezone cloned from `inner` at construction so `timezone()` can hand
175    /// out a reference without locking.
176    timezone: TimeZoneSpec,
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<NaiveDateTime>,
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 = inner.timezone().clone();
186        let sample = inner.now();
187        Self {
188            inner,
189            timezone,
190            sample: std::sync::RwLock::new(sample),
191        }
192    }
193
194    /// Re-sample the underlying clock. Called once per evaluation request.
195    pub fn refresh(&self) {
196        let now = self.inner.now();
197        *self.sample.write().expect("clock sample lock poisoned") = now;
198    }
199}
200
201impl ClockProvider for SnapshotClock {
202    fn timezone(&self) -> &TimeZoneSpec {
203        &self.timezone
204    }
205
206    fn now(&self) -> NaiveDateTime {
207        *self.sample.read().expect("clock sample lock poisoned")
208    }
209}