Skip to main content

jerrycan_core/
clock.rs

1//! Injectable time. Handlers/extensions take `Dep<Clock>` and call `now()`;
2//! tests control it via `TestApp::clock().advance(..)`. The serve engine's
3//! own timeouts deliberately stay on real tokio time — Clock is for DOMAIN
4//! time (rate windows, schedules, expiry), not transport timeouts.
5//!
6//! `App::new()` provides a [`Clock::system`] singleton by default; `provide`
7//! a different one to override it. `into_test()` swaps in a [`Clock::test`]
8//! that [`TestApp::clock`](crate::TestApp::clock) hands back so a test can
9//! [`advance`](Clock::advance) or [`set`](Clock::set) the injected clock and
10//! observe the effect through real requests.
11
12use std::sync::Arc;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16/// The current UTC instant as an RFC3339 string with seconds precision and a `Z`
17/// suffix (e.g. `2026-07-26T12:34:56Z`) — the exact shape jerrycan's `datetime`
18/// fields ride as (`String`) and the OpenAPI/fixture format uses.
19///
20/// This is the runtime companion to the design-contract `"default": "now"`
21/// sentinel (issue #110): a `datetime` field defaulting to `"now"` is omitted from
22/// the request DTO, and the generated create handler sets it to `now_rfc3339()`.
23/// Prelude-exported so a generated handler under `use jerrycan::prelude::*;` calls
24/// it bare, with no ad-hoc `chrono` dependency.
25///
26/// Reads the real system clock directly (NOT the injectable [`Clock`] DI): a
27/// server-set create timestamp is wall-clock provenance, not domain time a test
28/// rewinds. Chrono-free — the conversion is Howard Hinnant's civil-from-days
29/// algorithm — so the leanest crate in the tree stays dependency-light (mirroring
30/// jerrycan-db's deliberately chrono-free timestamp).
31pub fn now_rfc3339() -> String {
32    let secs = SystemTime::now()
33        .duration_since(UNIX_EPOCH)
34        .map(|d| d.as_secs())
35        .unwrap_or(0);
36    let (year, month, day, hour, min, sec) = civil_from_unix_secs(secs);
37    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
38}
39
40/// Convert Unix seconds (>= 0) to civil `(year, month, day, hour, minute, second)`
41/// in UTC. `days_from_civil`'s exact inverse (Howard Hinnant,
42/// <http://howardhinnant.github.io/date_algorithms.html>) — valid for any date in
43/// the proleptic Gregorian calendar; a jerrycan `now` is always post-1970, so the
44/// non-negative path is all that runs.
45fn civil_from_unix_secs(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
46    let days = (secs / 86_400) as i64;
47    let rem = secs % 86_400;
48    let (hour, min, sec) = (
49        (rem / 3600) as u32,
50        ((rem % 3600) / 60) as u32,
51        (rem % 60) as u32,
52    );
53    // days -> civil date, shifting the era so March is month 0 (leap day last).
54    let z = days + 719_468;
55    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
56    let doe = z - era * 146_097; // day of era [0, 146096]
57    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
58    let y = yoe + era * 400;
59    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
60    let mp = (5 * doy + 2) / 153; // month shifted [0, 11] (0 = March)
61    let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
62    let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12]
63    let year = if month <= 2 { y + 1 } else { y };
64    (year, month, day, hour, min, sec)
65}
66
67/// An injectable source of "now". Cloning is cheap and, for a test clock,
68/// shares the same controllable offset — so a handle handed to a test moves
69/// in lockstep with the clock the handler resolves.
70#[derive(Clone)]
71pub struct Clock(Inner);
72
73#[derive(Clone)]
74enum Inner {
75    /// Reads the real system clock on every `now()`.
76    System,
77    /// Test clock: a fixed base plus a controllable offset in milliseconds.
78    /// The offset lives behind an `Arc<AtomicU64>` so every clone — including
79    /// the one resolved inside a handler and the one held by `TestApp` —
80    /// observes the same advances.
81    Test {
82        base: SystemTime,
83        offset_ms: Arc<AtomicU64>,
84    },
85}
86
87impl Clock {
88    /// The real clock: `now()` reads `SystemTime::now()` each call. This is
89    /// what `App::new()` provides by default.
90    pub fn system() -> Self {
91        Clock(Inner::System)
92    }
93
94    /// A controllable test clock. The base is `SystemTime::now()` at creation
95    /// — tests assert on *movement* (`advance`/`set`), not on an absolute base,
96    /// so a realistic starting instant is fine and avoids surprising callers
97    /// that subtract `UNIX_EPOCH`.
98    pub fn test() -> Self {
99        Clock(Inner::Test {
100            base: SystemTime::now(),
101            offset_ms: Arc::new(AtomicU64::new(0)),
102        })
103    }
104
105    /// The current instant. For [`Clock::system`] this is the live system
106    /// clock; for [`Clock::test`] it is `base + accumulated offset`.
107    pub fn now(&self) -> SystemTime {
108        match &self.0 {
109            Inner::System => SystemTime::now(),
110            Inner::Test { base, offset_ms } => {
111                *base + Duration::from_millis(offset_ms.load(Ordering::SeqCst))
112            }
113        }
114    }
115
116    /// Move a test clock forward by `d`. Panics on a system clock — advancing
117    /// real time is meaningless, and silently ignoring it would hide a test bug.
118    pub fn advance(&self, d: Duration) {
119        match &self.0 {
120            Inner::Test { offset_ms, .. } => {
121                // Saturate rather than wrap: a test that advances past ~584M
122                // years is a bug, but wrapping would be a *silent* one.
123                offset_ms.fetch_add(d.as_millis().min(u64::MAX as u128) as u64, Ordering::SeqCst);
124            }
125            Inner::System => {
126                panic!("Clock::advance() is test-only — build the app with into_test()")
127            }
128        }
129    }
130
131    /// Pin a test clock to an absolute instant. Panics on a system clock for
132    /// the same reason as [`advance`](Clock::advance). Useful for cron/expiry
133    /// tests that need a specific wall-clock time rather than a delta.
134    pub fn set(&self, when: SystemTime) {
135        match &self.0 {
136            Inner::Test { base, offset_ms } => {
137                // Express `when` as an offset from the fixed base. Times at or
138                // before the base clamp to zero (the test clock never runs
139                // backwards before its own base).
140                let delta = when.duration_since(*base).unwrap_or_default();
141                offset_ms.store(
142                    delta.as_millis().min(u64::MAX as u128) as u64,
143                    Ordering::SeqCst,
144                );
145            }
146            Inner::System => {
147                panic!("Clock::set() is test-only — build the app with into_test()")
148            }
149        }
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::prelude::*;
157
158    #[tokio::test]
159    async fn clock_is_injectable_and_test_controllable() {
160        async fn now_ms(clock: Dep<Clock>) -> Result<Json<u128>> {
161            Ok(Json(
162                clock
163                    .now()
164                    .duration_since(std::time::UNIX_EPOCH)
165                    .unwrap()
166                    .as_millis(),
167            ))
168        }
169        let t = App::new().route("/now", get(now_ms)).into_test();
170        let t0: u128 = t.get("/now").await.json();
171        t.clock().advance(std::time::Duration::from_secs(3600));
172        let t1: u128 = t.get("/now").await.json();
173        assert!(
174            t1 >= t0 + 3_600_000,
175            "advance moved the injected clock: {t0} -> {t1}"
176        );
177    }
178
179    #[test]
180    fn real_clock_tracks_system_time() {
181        let c = Clock::system();
182        let a = c.now();
183        let b = std::time::SystemTime::now();
184        assert!(b.duration_since(a).unwrap() < std::time::Duration::from_secs(1));
185    }
186
187    #[tokio::test]
188    async fn clock_resolves_in_task_contexts_too() {
189        let built = crate::App::new().build().unwrap();
190        let mut ctx = built.task_context();
191        let clock = ctx.resolve::<Clock>().await.unwrap();
192        let _ = clock.now(); // resolvable outside requests — jobs need this
193    }
194
195    #[test]
196    fn test_clock_clones_share_one_offset() {
197        // The handle TestApp keeps and the Arc a handler resolves must move
198        // together — that is the whole point of an injectable test clock.
199        let c = Clock::test();
200        let clone = c.clone();
201        let before = clone.now();
202        c.advance(Duration::from_secs(10));
203        let after = clone.now();
204        assert_eq!(
205            after.duration_since(before).unwrap(),
206            Duration::from_secs(10)
207        );
208    }
209
210    #[test]
211    fn set_pins_test_clock_to_an_absolute_instant() {
212        let c = Clock::test();
213        let target = SystemTime::now() + Duration::from_secs(86_400);
214        c.set(target);
215        // Within a millisecond of the requested instant (we store ms offsets).
216        let drift = c
217            .now()
218            .duration_since(target)
219            .unwrap_or_else(|e| e.duration());
220        assert!(
221            drift < Duration::from_millis(2),
222            "set pinned now() to target"
223        );
224    }
225
226    #[test]
227    #[should_panic(expected = "test-only")]
228    fn advancing_a_system_clock_panics_loudly() {
229        Clock::system().advance(Duration::from_secs(1));
230    }
231
232    /// The civil conversion is exact for known epoch anchors — a wrong date here
233    /// would plant a garbage `created_at` on every `default:"now"` row (#110), so
234    /// pin it against timestamps whose UTC calendar date is unambiguous.
235    #[test]
236    fn civil_from_unix_secs_matches_known_anchors() {
237        assert_eq!(super::civil_from_unix_secs(0), (1970, 1, 1, 0, 0, 0));
238        // 2000-01-01T00:00:00Z = 946684800 (crosses the 1900/2000 century rule).
239        assert_eq!(
240            super::civil_from_unix_secs(946_684_800),
241            (2000, 1, 1, 0, 0, 0)
242        );
243        // 2020-02-29T23:59:59Z = 1583020799 (a leap day, last second).
244        assert_eq!(
245            super::civil_from_unix_secs(1_583_020_799),
246            (2020, 2, 29, 23, 59, 59)
247        );
248        // 2026-07-26T12:34:56Z = 1785069296.
249        assert_eq!(
250            super::civil_from_unix_secs(1_785_069_296),
251            (2026, 7, 26, 12, 34, 56)
252        );
253    }
254
255    /// The formatted string is RFC3339 UTC with seconds precision and a `Z` —
256    /// byte-for-byte the shape jerrycan `datetime` fixtures/OpenAPI use, so a
257    /// server-set `now` value round-trips through the same datetime contract.
258    #[test]
259    fn now_rfc3339_is_seconds_precision_utc_with_z() {
260        let s = now_rfc3339();
261        assert_eq!(s.len(), 20, "YYYY-MM-DDTHH:MM:SSZ is 20 chars: {s}");
262        assert!(s.ends_with('Z'), "UTC `Z` suffix, not an offset: {s}");
263        assert_eq!(&s[4..5], "-");
264        assert_eq!(&s[10..11], "T");
265        assert!(!s.contains('.'), "no fractional seconds: {s}");
266        // A plausible current year (four leading digits, >= 2024).
267        let year: i64 = s[..4].parse().expect("leading 4-digit year");
268        assert!(year >= 2024, "reads the real clock: {s}");
269    }
270
271    /// Formatting agrees with the civil conversion for a fixed instant — the
272    /// zero-padding and field order are what makes the value a valid `datetime`.
273    #[test]
274    fn format_zero_pads_every_field() {
275        let (y, mo, d, h, mi, s) = super::civil_from_unix_secs(946_684_800);
276        assert_eq!(
277            format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"),
278            "2000-01-01T00:00:00Z"
279        );
280    }
281}