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}