1use jiff::Timestamp;
11use jiff::tz::TimeZone;
12
13#[doc(hidden)]
15pub trait Clock: Send + Sync + std::fmt::Debug {
16 fn now(&self) -> i64;
18
19 fn now_millis(&self) -> i64;
22}
23
24#[derive(Debug, Clone, Copy)]
26pub(crate) struct SystemClock;
27
28impl Clock for SystemClock {
29 fn now(&self) -> i64 {
30 Timestamp::now().as_second()
31 }
32
33 fn now_millis(&self) -> i64 {
34 Timestamp::now().as_millisecond()
35 }
36}
37
38#[cfg(any(test, feature = "test-support"))]
41#[doc(hidden)]
42#[derive(Debug)]
43pub struct FixedClock(std::sync::atomic::AtomicI64);
44
45#[cfg(any(test, feature = "test-support"))]
46impl FixedClock {
47 pub fn at(epoch_seconds: i64) -> FixedClock {
48 FixedClock(std::sync::atomic::AtomicI64::new(epoch_seconds))
49 }
50
51 pub fn advance(&self, seconds: i64) {
53 self.0
54 .fetch_add(seconds, std::sync::atomic::Ordering::Relaxed);
55 }
56}
57
58#[cfg(any(test, feature = "test-support"))]
59impl Clock for FixedClock {
60 fn now(&self) -> i64 {
61 self.0.load(std::sync::atomic::Ordering::Relaxed)
62 }
63
64 fn now_millis(&self) -> i64 {
65 self.now() * 1000
66 }
67}
68
69pub fn parse(text: &str) -> Option<i64> {
72 text.parse::<Timestamp>().ok().map(Timestamp::as_second)
73}
74
75pub fn local(epoch: i64, pattern: &str) -> String {
77 Timestamp::from_second(epoch)
78 .map(|t| t.to_zoned(TimeZone::system()).strftime(pattern).to_string())
79 .unwrap_or_default()
80}
81
82pub fn moment(epoch: i64, now: i64) -> String {
84 let pattern = if local(epoch, "%F") == local(now, "%F") {
85 "%H:%M"
86 } else {
87 "%b %-d %H:%M"
88 };
89 local(epoch, pattern)
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn parses_the_shapes_the_usage_api_actually_emits() {
98 assert_eq!(parse("2026-09-20T22:20:00.095287+00:00"), Some(1789942800));
100 assert_eq!(parse("2026-09-27T02:00:00.095306+00:00"), Some(1790474400));
101 assert_eq!(parse("2026-09-20T22:20:00Z"), Some(1789942800));
102 assert_eq!(parse("2026-09-21T05:20:00+07:00"), Some(1789942800));
103 }
104
105 #[test]
106 fn refuses_rather_than_guesses() {
107 for bad in [
108 "",
109 "not a date",
110 "2026-09-20",
111 "2026-09-20T22:20:00",
112 "2026/09/20T22:20:00Z",
113 "2026-13-01T00:00:00Z",
114 "2026-01-32T00:00:00Z",
115 "2026-01-01T24:00:00Z",
116 ] {
117 assert_eq!(parse(bad), None, "should refuse {bad:?}");
118 }
119 }
120
121 #[test]
122 fn a_moment_today_is_just_its_time() {
123 let now = SystemClock.now();
124 assert_eq!(moment(now, now).len(), 5);
125 assert!(moment(now - 3 * 86_400, now).len() > 5);
126 }
127
128 #[test]
129 fn formats_in_the_local_zone() {
130 assert_eq!(local(0, "%Y").len(), 4);
131 assert_eq!(
132 local(i64::MAX, "%Y"),
133 "",
134 "out of range is empty, not a panic"
135 );
136 }
137}