Skip to main content

ppoppo_clock/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![doc = include_str!("../README.md")]
3#![deny(rust_2018_idioms)]
4#![deny(missing_docs)]
5#![warn(missing_debug_implementations)]
6
7// Standards: `STS_TIME_MECHANICS.md` §"Universal client + server time port".
8// Inside the ppoppo workspace the xtask CLOCK-PORT rule refuses an ambient
9// time source outside this crate.
10
11use jiff::civil::Date;
12use jiff::tz::TimeZone;
13use jiff::{Timestamp, Zoned};
14use std::pin::Pin;
15use std::sync::Arc;
16
17/// A boxed future — the one future alias of the ppoppo family. `Send` on
18/// every target but `wasm32`, where the browser runs one thread and a
19/// `JsFuture` is never `Send`; a port held as `dyn` returns it, so one
20/// signature serves both targets.
21#[cfg(not(target_arch = "wasm32"))]
22pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
23/// A boxed future — the one future alias of the ppoppo family. `Send` on
24/// every target but `wasm32`, where the browser runs one thread and a
25/// `JsFuture` is never `Send`; a port held as `dyn` returns it, so one
26/// signature serves both targets.
27#[cfg(target_arch = "wasm32")]
28pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
29use std::time::Duration;
30
31/// The port's value types — `Timestamp`, `Zoned`, `tz::TimeZone`,
32/// `civil::Date` — re-exported so a consumer names the exact jiff version the
33/// port speaks. The one sanctioned whole-crate foreign door in the family.
34pub use jiff;
35
36/// Deterministic test arm: a frozen clock, a settable clock, and a timer
37/// whose sleeps resolve only when that clock is advanced.
38#[cfg(feature = "mock")]
39pub mod mock;
40/// Tokio arm: the system wall clock and a Tokio-backed timer.
41#[cfg(feature = "native")]
42pub mod native;
43/// Browser arm: `Date.now()` as the clock and `Window.setTimeout` as the
44/// timer. Compiled on `wasm32` only — the browser is its one substrate, and
45/// its futures are the non-`Send` [`BoxFuture`] of that target.
46#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
47pub mod wasm;
48
49/// Wall-clock readouts. Single port for "what time is it?" across all surfaces.
50///
51/// `now()` is the only required method; the zone-aware readouts derive from it
52/// via jiff (tzdb available on every arm, including wasm via the bundled db).
53pub trait Clock: Send + Sync + 'static {
54    /// The current instant. The one method an arm implements.
55    fn now(&self) -> Timestamp;
56
57    /// The current instant as a wall-clock reading in `tz`.
58    fn now_in(&self, tz: &TimeZone) -> Zoned {
59        self.now().to_zoned(tz.clone())
60    }
61
62    /// Today's calendar date in `tz` — the date a person in that zone reads
63    /// off their calendar, which differs from the UTC date near midnight.
64    fn today_in(&self, tz: &TimeZone) -> Date {
65        self.now_in(tz).date()
66    }
67
68    /// The current instant as Unix epoch milliseconds.
69    fn now_unix_millis(&self) -> i64 {
70        self.now().as_millisecond()
71    }
72}
73
74/// Async scheduling primitive. Single port for "wait N ms" / "yield event-loop tick".
75///
76/// Both methods return [`BoxFuture`], so the port is `Send` on native targets
77/// and needs no `Send` bridge in the browser.
78pub trait Timer: Send + Sync + 'static {
79    /// A future that completes once `dur` has elapsed on this timer.
80    fn sleep(&self, dur: Duration) -> BoxFuture<'static, ()>;
81    /// A future that completes on the runtime's next scheduling turn — a
82    /// yield, not a delay.
83    fn next_tick(&self) -> BoxFuture<'static, ()>;
84}
85
86/// A shared [`Clock`] — the shape every ppoppo constructor takes.
87pub type ArcClock = Arc<dyn Clock>;
88/// A shared [`Timer`] — the shape every ppoppo constructor takes.
89pub type ArcTimer = Arc<dyn Timer>;
90
91#[cfg(test)]
92// Outer, not the usual inner `#![allow(..)]`: ARCH-BOUNDARY pins the exact
93// inner-attribute block of a published-tier `lib.rs` and scans every line,
94// so an indented inner attribute inside this module trips it too.
95#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
96mod tests {
97    use super::*;
98
99    /// Fixed-instant clock for exercising the provided methods without any
100    /// feature arm — zone math must be identical regardless of arm.
101    struct FixedClock(Timestamp);
102
103    impl Clock for FixedClock {
104        fn now(&self) -> Timestamp {
105            self.0
106        }
107    }
108
109    fn utc_midnight_2026_05_10() -> Timestamp {
110        "2026-05-10T00:00:00Z".parse().expect("valid RFC 3339")
111    }
112
113    #[test]
114    fn now_in_seoul_utc_midnight_is_hour_9() {
115        // UTC 2026-05-10 00:00 → KST 09:00 (UTC+9, no DST in Korea)
116        let clock = FixedClock(utc_midnight_2026_05_10());
117        let tz = TimeZone::get("Asia/Seoul").expect("tzdb has Seoul");
118        assert_eq!(clock.now_in(&tz).hour(), 9);
119    }
120
121    #[test]
122    fn today_in_crosses_date_line() {
123        // UTC 2026-05-10 23:30 is already 2026-05-11 in Seoul.
124        let clock = FixedClock("2026-05-10T23:30:00Z".parse().expect("valid"));
125        let tz = TimeZone::get("Asia/Seoul").expect("tzdb has Seoul");
126        assert_eq!(clock.today_in(&tz), jiff::civil::date(2026, 5, 11));
127        assert_eq!(
128            clock.today_in(&TimeZone::UTC),
129            jiff::civil::date(2026, 5, 10)
130        );
131    }
132
133    #[test]
134    fn dst_transition_new_york_2024() {
135        // America/New_York spring-forward 2024-03-10 02:00 → 03:00
136        // 06:59 UTC = 01:59 EST; 07:00 UTC = 03:00 EDT
137        let tz = TimeZone::get("America/New_York").expect("tzdb has NY");
138        let before = FixedClock("2024-03-10T06:59:00Z".parse().expect("valid"));
139        let after = FixedClock("2024-03-10T07:00:00Z".parse().expect("valid"));
140        assert_eq!(before.now_in(&tz).hour(), 1);
141        assert_eq!(after.now_in(&tz).hour(), 3);
142    }
143
144    #[test]
145    fn now_unix_millis_matches_timestamp() {
146        let clock = FixedClock("1970-01-01T00:00:01Z".parse().expect("valid"));
147        assert_eq!(clock.now_unix_millis(), 1_000);
148    }
149
150    #[test]
151    fn dow_monday0_convention_via_zoned() {
152        // 2026-05-10 is a Sunday → to_monday_zero_offset() == 6
153        let clock = FixedClock("2026-05-10T12:00:00Z".parse().expect("valid"));
154        let zdt = clock.now_in(&TimeZone::UTC);
155        assert_eq!(zdt.weekday().to_monday_zero_offset(), 6);
156    }
157}