1use std::future::Future;
2use std::path::PathBuf;
3use std::pin::Pin;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use time::OffsetDateTime;
7use time::format_description::well_known::Rfc3339;
8
9pub trait Clock: Send + Sync {
10 fn now_ms(&self) -> i64;
11 fn local_minute(&self, timestamp_ms: i64) -> u16;
12 fn sleep_until(&self, deadline_ms: i64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
13}
14
15#[derive(Debug, Default)]
16pub struct SystemClock;
17
18impl Clock for SystemClock {
19 fn now_ms(&self) -> i64 {
20 SystemTime::now()
21 .duration_since(UNIX_EPOCH)
22 .map(|elapsed| elapsed.as_millis() as i64)
23 .unwrap_or(0)
24 }
25
26 fn local_minute(&self, timestamp_ms: i64) -> u16 {
27 local_minute(timestamp_ms)
28 }
29
30 fn sleep_until(&self, deadline_ms: i64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
31 Box::pin(async move {
32 loop {
33 let delay_ms = deadline_ms.saturating_sub(self.now_ms());
34 if delay_ms <= 0 {
35 return;
36 }
37 tokio::time::sleep(Duration::from_millis(delay_ms.min(30_000) as u64)).await;
40 }
41 })
42 }
43}
44
45#[derive(Debug)]
48pub struct FileClock {
49 path: PathBuf,
50}
51
52impl FileClock {
53 pub fn new(path: PathBuf) -> Self {
54 Self { path }
55 }
56
57 fn read_now_ms(&self) -> i64 {
58 std::fs::read_to_string(&self.path)
59 .ok()
60 .and_then(|value| value.trim().parse().ok())
61 .unwrap_or(0)
62 }
63}
64
65impl Clock for FileClock {
66 fn now_ms(&self) -> i64 {
67 self.read_now_ms()
68 }
69
70 fn local_minute(&self, timestamp_ms: i64) -> u16 {
71 local_minute(timestamp_ms)
72 }
73
74 fn sleep_until(&self, deadline_ms: i64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
75 Box::pin(async move {
76 while self.now_ms() < deadline_ms {
77 tokio::time::sleep(Duration::from_millis(10)).await;
78 }
79 })
80 }
81}
82
83pub fn format_timestamp(timestamp_ms: i64) -> Option<String> {
84 OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp_ms) * 1_000_000)
85 .ok()?
86 .format(&Rfc3339)
87 .ok()
88}
89
90pub fn parse_timestamp(text: &str) -> Option<i64> {
94 let at = OffsetDateTime::parse(text, &Rfc3339).ok()?;
95 i64::try_from(at.unix_timestamp_nanos() / 1_000_000).ok()
96}
97
98pub fn next_local_minute_ms(clock: &dyn Clock, now_ms: i64, minute: u16) -> Option<i64> {
102 let mut candidate = now_ms
103 .div_euclid(60_000)
104 .checked_add(1)?
105 .checked_mul(60_000)?;
106 for _ in 0..=(49 * 60) {
107 if clock.local_minute(candidate) == minute {
108 return Some(candidate);
109 }
110 candidate = candidate.checked_add(60_000)?;
111 }
112 None
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct LocalTime {
121 pub year: i32,
122 pub month: u8,
123 pub day: u8,
124 pub hour: u8,
125 pub minute: u8,
126}
127
128impl LocalTime {
129 pub fn clock(&self) -> String {
132 format!("{:02}:{:02}", self.hour, self.minute)
133 }
134
135 pub fn dated(&self) -> String {
138 format!(
139 "{:02}-{:02} {:02}:{:02}",
140 self.month, self.day, self.hour, self.minute
141 )
142 }
143
144 pub fn same_day(&self, other: &Self) -> bool {
147 (self.year, self.month, self.day) == (other.year, other.month, other.day)
148 }
149}
150
151pub fn local_time(timestamp_ms: i64) -> Option<LocalTime> {
155 let seconds = timestamp_ms.div_euclid(1_000) as libc::time_t;
156 let mut local = unsafe { std::mem::zeroed::<libc::tm>() };
157 let result = unsafe { libc::localtime_r(&seconds, &mut local) };
158 if result.is_null() {
159 return None;
160 }
161 Some(LocalTime {
162 year: local.tm_year + 1900,
163 month: (local.tm_mon + 1) as u8,
164 day: local.tm_mday as u8,
165 hour: local.tm_hour as u8,
166 minute: local.tm_min as u8,
167 })
168}
169
170fn local_minute(timestamp_ms: i64) -> u16 {
171 let seconds = timestamp_ms.div_euclid(1_000) as libc::time_t;
172 let mut local = unsafe { std::mem::zeroed::<libc::tm>() };
173 let result = unsafe { libc::localtime_r(&seconds, &mut local) };
174 if result.is_null() {
175 return 0;
176 }
177 (local.tm_hour as u16) * 60 + local.tm_min as u16
178}
179
180#[cfg(test)]
181mod tests {
182 use super::parse_timestamp;
183
184 #[test]
185 fn a_whole_second_instant_parses_to_its_epoch_millisecond() {
186 assert_eq!(
187 parse_timestamp("2026-07-15T22:00:00Z"),
188 Some(1_784_152_800_000)
189 );
190 }
191
192 #[test]
195 fn a_fractional_second_instant_keeps_its_milliseconds() {
196 assert_eq!(
197 parse_timestamp("2026-07-26T11:03:39.687Z"),
198 Some(1_785_063_819_687)
199 );
200 }
201
202 #[test]
203 fn a_numeric_offset_resolves_to_the_same_instant_as_its_z_form() {
204 assert_eq!(
205 parse_timestamp("2026-07-15T22:00:00+10:00"),
206 parse_timestamp("2026-07-15T12:00:00Z")
207 );
208 }
209
210 #[test]
211 fn anything_that_is_not_rfc3339_is_none() {
212 assert_eq!(parse_timestamp("not-a-time"), None);
213 assert_eq!(parse_timestamp(""), None);
214 assert_eq!(parse_timestamp("2026-07-15"), None);
215 }
216}