sim_time/
lib.rs

1#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
2#![cfg_attr(feature = "fail-on-warnings", deny(clippy::all))]
3#![forbid(unsafe_code)]
4
5mod config;
6
7use chrono::{DateTime, Utc};
8pub use config::*;
9use std::{
10    sync::{
11        Arc, OnceLock,
12        atomic::{AtomicU64, Ordering},
13    },
14    time::Duration,
15};
16
17static INSTANCE: OnceLock<Time> = OnceLock::new();
18
19#[derive(Clone)]
20struct Time {
21    config: TimeConfig,
22    elapsed_ms: Arc<AtomicU64>,
23    ticker_task: Arc<OnceLock<()>>,
24}
25
26impl Time {
27    fn new(config: TimeConfig) -> Self {
28        let time = Self {
29            config,
30            elapsed_ms: Arc::new(AtomicU64::new(0)),
31            ticker_task: Arc::new(OnceLock::new()),
32        };
33        if !time.config.realtime {
34            time.spawn_ticker();
35        }
36        time
37    }
38
39    fn spawn_ticker(&self) {
40        let elapsed_ms = self.elapsed_ms.clone();
41        let sim_config = &self.config.simulation;
42        let tick_interval_ms = sim_config.tick_interval_ms;
43        let tick_duration = sim_config.tick_duration_secs;
44        self.ticker_task.get_or_init(|| {
45            tokio::spawn(async move {
46                let mut interval =
47                    tokio::time::interval(tokio::time::Duration::from_millis(tick_interval_ms));
48                loop {
49                    interval.tick().await;
50                    elapsed_ms.fetch_add(tick_duration.as_millis() as u64, Ordering::Relaxed);
51                }
52            });
53        });
54    }
55
56    fn now(&self) -> DateTime<Utc> {
57        if self.config.realtime {
58            Utc::now()
59        } else {
60            let sim_config = &self.config.simulation;
61            let elapsed_ms = self.elapsed_ms.load(Ordering::Relaxed);
62
63            let simulated_time =
64                sim_config.start_at + chrono::Duration::milliseconds(elapsed_ms as i64);
65
66            if sim_config.transform_to_realtime && simulated_time >= Utc::now() {
67                Utc::now()
68            } else {
69                simulated_time
70            }
71        }
72    }
73
74    fn real_ms(&self, duration: Duration) -> Duration {
75        if self.config.realtime {
76            duration
77        } else {
78            let sim_config = &self.config.simulation;
79
80            let current_time = self.now();
81            let real_now = Utc::now();
82
83            if sim_config.transform_to_realtime && current_time >= real_now {
84                return duration;
85            }
86
87            let sim_ms_per_real_ms = sim_config.tick_duration_secs.as_millis() as f64
88                / sim_config.tick_interval_ms as f64;
89
90            Duration::from_millis((duration.as_millis() as f64 / sim_ms_per_real_ms).ceil() as u64)
91        }
92    }
93
94    fn sleep(&self, duration: Duration) -> tokio::time::Sleep {
95        tokio::time::sleep(self.real_ms(duration))
96    }
97
98    fn timeout<F>(&self, duration: Duration, future: F) -> tokio::time::Timeout<F::IntoFuture>
99    where
100        F: core::future::IntoFuture,
101    {
102        tokio::time::timeout(self.real_ms(duration), future)
103    }
104
105    pub async fn wait_until_realtime(&self) {
106        if self.config.realtime {
107            return;
108        }
109
110        let current = self.now();
111        let real_now = Utc::now();
112
113        if current >= real_now {
114            return;
115        }
116
117        let wait_duration =
118            std::time::Duration::from_millis((real_now - current).num_milliseconds() as u64);
119
120        self.sleep(wait_duration).await;
121    }
122}
123
124/// Returns a future that will return when the simulation has caught up to the current time.
125///
126/// Assumes that the simulation was configured to start in the past and has
127/// [`SimulationConfig::transform_to_realtime`](`config::SimulationConfig::transform_to_realtime`) set to `true`.
128pub async fn wait_until_realtime() {
129    INSTANCE
130        .get_or_init(|| Time::new(TimeConfig::default()))
131        .wait_until_realtime()
132        .await
133}
134
135/// Pass the [`TimeConfig`] to configure `sim-time` globally.
136/// Must be called before any other `fn`s otherwise `sim-time` will initialize with defaults.
137pub fn init(config: TimeConfig) {
138    INSTANCE.get_or_init(|| Time::new(config));
139}
140
141/// Returns the current time in the simulation
142pub fn now() -> DateTime<Utc> {
143    INSTANCE
144        .get_or_init(|| Time::new(TimeConfig::default()))
145        .now()
146}
147
148/// Will sleep for the simulated duration.
149pub fn sleep(duration: Duration) -> tokio::time::Sleep {
150    INSTANCE
151        .get_or_init(|| Time::new(TimeConfig::default()))
152        .sleep(duration)
153}
154
155/// Will timeout for the simulated duration.
156pub fn timeout<F>(duration: Duration, future: F) -> tokio::time::Timeout<F::IntoFuture>
157where
158    F: core::future::IntoFuture,
159{
160    INSTANCE
161        .get_or_init(|| Time::new(TimeConfig::default()))
162        .timeout(duration, future)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use chrono::Duration as ChronoDuration;
169    use std::time::Duration as StdDuration;
170
171    #[tokio::test]
172    async fn test_simulated_time() {
173        // Configure time where 10ms = 10 days of simulated time
174        let config = TimeConfig {
175            realtime: false,
176            simulation: SimulationConfig {
177                start_at: Utc::now(),
178                tick_interval_ms: 10,
179                tick_duration_secs: StdDuration::from_secs(10 * 24 * 60 * 60), // 10 days in seconds
180                transform_to_realtime: false,
181            },
182        };
183
184        init(config);
185        let start = now();
186        tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
187        let end = now();
188        let elapsed = end - start;
189
190        assert!(
191            elapsed >= ChronoDuration::days(19) && elapsed <= ChronoDuration::days(21),
192            "Expected ~20 days to pass, but got {} days",
193            elapsed.num_days()
194        );
195    }
196
197    #[test]
198    fn test_default_realtime() {
199        let t1 = now();
200        let t2 = Utc::now();
201        assert!(t2 - t1 < ChronoDuration::seconds(1));
202    }
203}