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.
5use chrono::{DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, Utc};
6
7/// Timezone specification for date/time calculations
8/// Excel behavior: always uses local timezone
9/// This enum allows future extensions while maintaining Excel compatibility
10#[derive(Clone, Debug, Default, Eq, PartialEq)]
11pub enum TimeZoneSpec {
12    /// Use the system's local timezone (Excel default behavior)
13    #[default]
14    Local,
15    /// Use UTC timezone
16    Utc,
17    /// Use a fixed offset from UTC (seconds east of UTC).
18    ///
19    /// This is the only timezone-like option permitted under deterministic mode.
20    FixedOffsetSeconds(i32),
21    // Named timezone variant removed until feature introduced.
22}
23
24// (Derived Default provides Local)
25
26impl TimeZoneSpec {
27    pub fn fixed_offset(&self) -> Option<FixedOffset> {
28        match self {
29            TimeZoneSpec::Utc => FixedOffset::east_opt(0),
30            TimeZoneSpec::FixedOffsetSeconds(secs) => FixedOffset::east_opt(*secs),
31            TimeZoneSpec::Local => None,
32        }
33    }
34
35    pub fn validate_for_determinism(&self) -> Result<(), String> {
36        match self {
37            TimeZoneSpec::Local => Err(
38                "Deterministic mode forbids `Local` timezone (use UTC or a fixed offset)"
39                    .to_string(),
40            ),
41            TimeZoneSpec::Utc => Ok(()),
42            TimeZoneSpec::FixedOffsetSeconds(secs) => {
43                FixedOffset::east_opt(*secs).ok_or_else(|| {
44                    format!("Invalid fixed offset: {secs} seconds (must be within +/-24h)")
45                })?;
46                Ok(())
47            }
48        }
49    }
50}
51
52/// Injectable clock provider for volatile date/time builtins.
53pub trait ClockProvider: std::fmt::Debug + Send + Sync {
54    fn timezone(&self) -> &TimeZoneSpec;
55    fn now(&self) -> NaiveDateTime;
56    fn today(&self) -> NaiveDate {
57        self.now().date()
58    }
59}
60
61/// Default clock implementation: reads from the system clock.
62#[derive(Clone, Debug)]
63pub struct SystemClock {
64    timezone: TimeZoneSpec,
65}
66
67impl SystemClock {
68    pub fn new(timezone: TimeZoneSpec) -> Self {
69        Self { timezone }
70    }
71}
72
73impl ClockProvider for SystemClock {
74    fn timezone(&self) -> &TimeZoneSpec {
75        &self.timezone
76    }
77
78    fn now(&self) -> NaiveDateTime {
79        match &self.timezone {
80            TimeZoneSpec::Local => Local::now().naive_local(),
81            TimeZoneSpec::Utc => Utc::now().naive_utc(),
82            TimeZoneSpec::FixedOffsetSeconds(secs) => {
83                let off = FixedOffset::east_opt(*secs)
84                    .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap());
85                let utc_now: DateTime<Utc> = Utc::now();
86                utc_now.with_timezone(&off).naive_local()
87            }
88        }
89    }
90}
91
92/// Deterministic clock implementation: always returns the configured instant.
93#[derive(Clone, Debug)]
94pub struct FixedClock {
95    timestamp_utc: DateTime<Utc>,
96    timezone: TimeZoneSpec,
97}
98
99impl FixedClock {
100    pub fn new(timestamp_utc: DateTime<Utc>, timezone: TimeZoneSpec) -> Self {
101        Self {
102            timestamp_utc,
103            timezone,
104        }
105    }
106
107    pub fn new_deterministic(
108        timestamp_utc: DateTime<Utc>,
109        timezone: TimeZoneSpec,
110    ) -> Result<Self, String> {
111        timezone.validate_for_determinism()?;
112        Ok(Self::new(timestamp_utc, timezone))
113    }
114
115    fn now_in_timezone(&self) -> NaiveDateTime {
116        match &self.timezone {
117            TimeZoneSpec::Utc => self.timestamp_utc.naive_utc(),
118            TimeZoneSpec::FixedOffsetSeconds(secs) => {
119                let off = FixedOffset::east_opt(*secs).expect("validated fixed offset");
120                self.timestamp_utc.with_timezone(&off).naive_local()
121            }
122            TimeZoneSpec::Local => {
123                // Should be unreachable due to validation, but keep behavior predictable.
124                self.timestamp_utc.with_timezone(&Local).naive_local()
125            }
126        }
127    }
128}
129
130impl ClockProvider for FixedClock {
131    fn timezone(&self) -> &TimeZoneSpec {
132        &self.timezone
133    }
134
135    fn now(&self) -> NaiveDateTime {
136        self.now_in_timezone()
137    }
138}