ppoppo_clock/lib.rs
1//! **NOT a stable public API.** Engine-tier time port — **3rd parties** reach
2//! it via the SDK-family re-exports (e.g. `ppoppo_pas_external::clock::*`) and never
3//! name this crate directly. 1st-party clients outside this workspace do name
4//! it: the standalone CCC repo (RFC_202607180403) depends on it from
5//! crates.io, and must pin the same family version `ppoppo-chat-client`
6//! requires or cargo resolves two copies and the shared `Clock` trait stops
7//! typechecking across them. Do not build a 3rd-party integration on this
8//! crate's API expecting stability.
9//!
10//! Universal Clock + Timer port for the ppoppo workspace.
11//!
12//! Single deep-module port hiding the time-source substrate from every consumer.
13//! Three impl arms gated by feature: `native` (Tokio), `wasm` (js_sys::Date +
14//! Intl tz-name probe + Window.setTimeout), `mock` (FrozenClock + MockClock +
15//! AdvanceableTimer). Zone math is arm-independent: jiff's bundled tzdb makes
16//! `now_in`/`today_in` *provided* methods — each arm implements only `now()`.
17//!
18//! # SSOT relationship
19//!
20//! Standards: `STS_TIME_MECHANICS.md` §"Universal client + server time port"
21//! (jiff surface per RFC_202607130309_jiff-migration). Engine precedent:
22//! `ppoppo-token` (1st-party-consumed primitive, surface via SDK re-export).
23//! External Developer Apps consume via `ppoppo_pas_external::clock::*` — never
24//! path-dep this crate.
25//!
26//! # Temporal alignment
27//!
28//! jiff is the Rust realization of the TC39 Temporal model (`Timestamp` ≙
29//! `Temporal.Instant`, `Zoned` ≙ `Temporal.ZonedDateTime`, `civil::Date` ≙
30//! `Temporal.PlainDate`, `tz::TimeZone` ≙ `Temporal.TimeZone`). The pre-jiff
31//! plan to swap the wasm arm onto `js_sys::Temporal` is obsolete — the model
32//! already lives in-process, identically on every arm.
33
34#![deny(rust_2018_idioms)]
35#![warn(missing_debug_implementations)]
36
37use futures::future::BoxFuture;
38use jiff::civil::Date;
39use jiff::tz::TimeZone;
40use jiff::{Timestamp, Zoned};
41use std::sync::Arc;
42use std::time::Duration;
43
44/// Port vocabulary re-export — consumers name jiff types through the port
45/// crate (and through `ppoppo_pas_external::clock::jiff` on the SDK surface).
46pub use jiff;
47
48#[cfg(feature = "mock")]
49pub mod mock;
50#[cfg(feature = "native")]
51pub mod native;
52#[cfg(feature = "wasm")]
53pub mod wasm;
54
55/// Wall-clock readouts. Single port for "what time is it?" across all surfaces.
56///
57/// `now()` is the only required method; the zone-aware readouts derive from it
58/// via jiff (tzdb available on every arm, including wasm via the bundled db).
59pub trait Clock: Send + Sync + 'static {
60 fn now(&self) -> Timestamp;
61
62 fn now_in(&self, tz: &TimeZone) -> Zoned {
63 self.now().to_zoned(tz.clone())
64 }
65
66 fn today_in(&self, tz: &TimeZone) -> Date {
67 self.now_in(tz).date()
68 }
69
70 fn now_unix_millis(&self) -> i64 {
71 self.now().as_millisecond()
72 }
73}
74
75/// Async scheduling primitive. Single port for "wait N ms" / "yield event-loop tick".
76pub trait Timer: Send + Sync + 'static {
77 fn sleep(&self, dur: Duration) -> BoxFuture<'static, ()>;
78 fn next_tick(&self) -> BoxFuture<'static, ()>;
79}
80
81/// Convenience aliases for the common injection shape.
82pub type ArcClock = Arc<dyn Clock>;
83pub type ArcTimer = Arc<dyn Timer>;
84
85#[cfg(test)]
86// Outer, not the usual inner `#![allow(..)]`: ARCH-BOUNDARY pins the exact
87// inner-attribute block of a published-tier `lib.rs` and scans every line,
88// so an indented inner attribute inside this module trips it too.
89#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
90mod tests {
91 use super::*;
92
93 /// Fixed-instant clock for exercising the provided methods without any
94 /// feature arm — zone math must be identical regardless of arm.
95 struct FixedClock(Timestamp);
96
97 impl Clock for FixedClock {
98 fn now(&self) -> Timestamp {
99 self.0
100 }
101 }
102
103 fn utc_midnight_2026_05_10() -> Timestamp {
104 "2026-05-10T00:00:00Z".parse().expect("valid RFC 3339")
105 }
106
107 #[test]
108 fn now_in_seoul_utc_midnight_is_hour_9() {
109 // UTC 2026-05-10 00:00 → KST 09:00 (UTC+9, no DST in Korea)
110 let clock = FixedClock(utc_midnight_2026_05_10());
111 let tz = TimeZone::get("Asia/Seoul").expect("tzdb has Seoul");
112 assert_eq!(clock.now_in(&tz).hour(), 9);
113 }
114
115 #[test]
116 fn today_in_crosses_date_line() {
117 // UTC 2026-05-10 23:30 is already 2026-05-11 in Seoul.
118 let clock = FixedClock("2026-05-10T23:30:00Z".parse().expect("valid"));
119 let tz = TimeZone::get("Asia/Seoul").expect("tzdb has Seoul");
120 assert_eq!(clock.today_in(&tz), jiff::civil::date(2026, 5, 11));
121 assert_eq!(
122 clock.today_in(&TimeZone::UTC),
123 jiff::civil::date(2026, 5, 10)
124 );
125 }
126
127 #[test]
128 fn dst_transition_new_york_2024() {
129 // America/New_York spring-forward 2024-03-10 02:00 → 03:00
130 // 06:59 UTC = 01:59 EST; 07:00 UTC = 03:00 EDT
131 let tz = TimeZone::get("America/New_York").expect("tzdb has NY");
132 let before = FixedClock("2024-03-10T06:59:00Z".parse().expect("valid"));
133 let after = FixedClock("2024-03-10T07:00:00Z".parse().expect("valid"));
134 assert_eq!(before.now_in(&tz).hour(), 1);
135 assert_eq!(after.now_in(&tz).hour(), 3);
136 }
137
138 #[test]
139 fn now_unix_millis_matches_timestamp() {
140 let clock = FixedClock("1970-01-01T00:00:01Z".parse().expect("valid"));
141 assert_eq!(clock.now_unix_millis(), 1_000);
142 }
143
144 #[test]
145 fn dow_monday0_convention_via_zoned() {
146 // 2026-05-10 is a Sunday → to_monday_zero_offset() == 6
147 let clock = FixedClock("2026-05-10T12:00:00Z".parse().expect("valid"));
148 let zdt = clock.now_in(&TimeZone::UTC);
149 assert_eq!(zdt.weekday().to_monday_zero_offset(), 6);
150 }
151}