fraiseql_core/utils/clock.rs
1//! Clock abstraction for deterministic time-dependent testing.
2//!
3//! Inject [`Clock`] into any component that calls `SystemTime::now()`,
4//! enabling unit tests to control time without real-time delays.
5//!
6//! # Usage
7//!
8//! Production code should accept `Arc<dyn Clock>` and use
9//! [`SystemClock`] as the default:
10//!
11//! ```rust
12//! use std::sync::Arc;
13//! use fraiseql_core::utils::clock::{Clock, SystemClock};
14//!
15//! struct MyComponent {
16//! clock: Arc<dyn Clock>,
17//! }
18//!
19//! impl MyComponent {
20//! pub fn new() -> Self {
21//! Self { clock: Arc::new(SystemClock) }
22//! }
23//!
24//! pub fn new_with_clock(clock: Arc<dyn Clock>) -> Self {
25//! Self { clock }
26//! }
27//! }
28//! ```
29
30use std::time::{SystemTime, UNIX_EPOCH};
31
32/// Abstraction over the system clock.
33///
34/// Inject this into any component that needs time-based logic to enable
35/// deterministic testing without real-time delays.
36pub trait Clock: Send + Sync + 'static {
37 /// Return the current time.
38 fn now(&self) -> SystemTime;
39
40 /// Return the current Unix timestamp in whole seconds.
41 ///
42 /// Equivalent to `now().duration_since(UNIX_EPOCH).as_secs()`.
43 fn now_secs(&self) -> u64 {
44 self.now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
45 }
46
47 /// Return the current Unix timestamp as a signed 64-bit integer.
48 ///
49 /// Safe until the year 292,277,026,596 when `u64` overflows `i64`.
50 fn now_secs_i64(&self) -> i64 {
51 i64::try_from(self.now_secs()).unwrap_or(0)
52 }
53}
54
55/// Production clock: delegates to [`SystemTime::now()`].
56#[derive(Debug, Clone, Default)]
57pub struct SystemClock;
58
59impl Clock for SystemClock {
60 #[inline]
61 fn now(&self) -> SystemTime {
62 SystemTime::now()
63 }
64}
65
66/// Manually advanceable clock for deterministic tests.
67///
68/// Starts at `UNIX_EPOCH + 1_000_000 s` to avoid edge cases near the epoch.
69/// All clones share the same underlying time via an [`std::sync::Arc`].
70///
71/// # Example
72///
73/// ```rust
74/// use std::time::Duration;
75/// use fraiseql_core::utils::clock::{Clock, ManualClock};
76///
77/// let clock = ManualClock::new();
78/// let t0 = clock.now_secs();
79///
80/// clock.advance(Duration::from_secs(10));
81/// assert_eq!(clock.now_secs(), t0 + 10);
82/// ```
83#[cfg(any(test, feature = "test-utils"))]
84#[derive(Debug, Clone)]
85pub struct ManualClock {
86 current: std::sync::Arc<std::sync::Mutex<SystemTime>>,
87}
88
89#[cfg(any(test, feature = "test-utils"))]
90impl Default for ManualClock {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96#[cfg(any(test, feature = "test-utils"))]
97impl ManualClock {
98 /// Create a new clock starting at `UNIX_EPOCH + 1_000_000 s`.
99 #[must_use]
100 pub fn new() -> Self {
101 Self {
102 current: std::sync::Arc::new(std::sync::Mutex::new(
103 SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000),
104 )),
105 }
106 }
107
108 /// Advance the clock by `delta`. All clones see the new time immediately.
109 ///
110 /// # Panics
111 ///
112 /// Panics if the internal mutex is poisoned.
113 pub fn advance(&self, delta: std::time::Duration) {
114 *self.current.lock().expect("ManualClock mutex poisoned") += delta;
115 }
116
117 /// Set the clock to an absolute time.
118 ///
119 /// # Panics
120 ///
121 /// Panics if the internal mutex is poisoned.
122 pub fn set(&self, t: SystemTime) {
123 *self.current.lock().expect("ManualClock mutex poisoned") = t;
124 }
125}
126
127#[cfg(any(test, feature = "test-utils"))]
128impl Clock for ManualClock {
129 fn now(&self) -> SystemTime {
130 *self.current.lock().expect("ManualClock mutex poisoned")
131 }
132}