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}