Skip to main content

ppoppo_clock/
lib.rs

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