tuwunel_core/utils/time.rs
1//! Wall-clock conversion, parsing, and duration-formatting utilities.
2//!
3//! The helpers convert between `SystemTime` and the Unix epoch, parse
4//! human-readable durations, and choose display units. These clocks are not
5//! monotonic and can be affected by system-time changes.
6
7pub mod exponential_backoff;
8
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11use crate::{Result, err};
12
13/// Returns the current wall-clock time as whole milliseconds since the Unix
14/// epoch.
15///
16/// The submillisecond remainder is discarded, and counts above `u64::MAX`
17/// retain only their low 64 bits. The function panics if the current system
18/// clock is earlier than the epoch.
19#[inline]
20#[must_use]
21#[expect(clippy::as_conversions, clippy::cast_possible_truncation)]
22pub fn now_millis() -> u64 { now().as_millis() as u64 }
23
24/// Returns the current wall-clock time as whole seconds since the Unix epoch.
25///
26/// The subsecond remainder is discarded. The function panics if the current
27/// system clock is earlier than the epoch.
28#[inline]
29#[must_use]
30pub fn now_secs() -> u64 { now().as_secs() }
31
32/// Returns the current wall-clock duration since the Unix epoch.
33///
34/// The value comes from `SystemTime` and is not monotonic. The function panics
35/// if the current system clock is earlier than the epoch.
36#[inline]
37#[must_use]
38pub fn now() -> Duration {
39 UNIX_EPOCH
40 .elapsed()
41 .expect("positive duration after epoch")
42}
43
44/// Converts a system time into a nonnegative duration since the Unix epoch.
45///
46/// Times before the epoch saturate to [`Duration::ZERO`]. Times at or after the
47/// epoch preserve their full representable duration.
48#[inline]
49#[must_use]
50pub fn duration_since_epoch(timepoint: SystemTime) -> Duration {
51 timepoint
52 .duration_since(UNIX_EPOCH)
53 .unwrap_or(Duration::ZERO)
54}
55
56/// Adds a duration to the Unix epoch using checked arithmetic.
57///
58/// The resulting system time is returned when representable. An arithmetic
59/// error is returned when the duration exceeds the platform's `SystemTime`
60/// range.
61#[inline]
62pub fn timepoint_from_epoch(duration: Duration) -> Result<SystemTime> {
63 UNIX_EPOCH
64 .checked_add(duration)
65 .ok_or_else(|| err!(Arithmetic("Duration {duration:?} from epoch is too large")))
66}
67
68/// Adds a duration to the current wall-clock time using checked arithmetic.
69///
70/// The current time is sampled once for the calculation. An arithmetic error is
71/// returned when the result exceeds the platform's `SystemTime` range.
72#[inline]
73pub fn timepoint_from_now(duration: Duration) -> Result<SystemTime> {
74 SystemTime::now()
75 .checked_add(duration)
76 .ok_or_else(|| err!(Arithmetic("Duration {duration:?} from now is too large")))
77}
78
79/// Subtracts a duration from the current wall-clock time using checked
80/// arithmetic.
81///
82/// The current time is sampled once for the calculation. An arithmetic error is
83/// returned when the result precedes the platform's `SystemTime` range.
84#[inline]
85pub fn timepoint_ago(duration: Duration) -> Result<SystemTime> {
86 SystemTime::now()
87 .checked_sub(duration)
88 .ok_or_else(|| err!(Arithmetic("Duration {duration:?} ago is too large")))
89}
90
91/// Parses a duration and returns the wall-clock time that far in the past.
92///
93/// Input syntax is delegated to [`parse_duration`]. Parsing and
94/// checked-subtraction errors are propagated.
95#[inline]
96pub fn parse_timepoint_ago(ago: &str) -> Result<SystemTime> {
97 timepoint_ago(parse_duration(ago)?)
98}
99
100/// Parses a human-readable duration with the `cyborgtime` parser.
101///
102/// Successful input is returned as a standard [`Duration`]. Parser failures are
103/// wrapped with the original input for context.
104#[inline]
105pub fn parse_duration(duration: &str) -> Result<Duration> {
106 cyborgtime::parse_duration(duration)
107 .map_err(|error| err!("'{duration:?}' is not a valid duration string: {error:?}"))
108}
109
110/// Checks whether a system time is at or before the current wall-clock time.
111///
112/// Equality is considered passed. A time later than the sampled current time
113/// returns `false`.
114#[inline]
115#[must_use]
116pub fn timepoint_has_passed(timepoint: SystemTime) -> bool {
117 SystemTime::now()
118 .duration_since(timepoint)
119 .is_ok()
120}
121
122/// Formats a signed Unix timestamp as RFC 2822 text in UTC.
123///
124/// Timestamps outside Chrono's supported range use its default UTC date and
125/// time before formatting.
126#[must_use]
127pub fn rfc2822_from_seconds(epoch: i64) -> String {
128 use chrono::{DateTime, Utc};
129
130 DateTime::<Utc>::from_timestamp(epoch, 0)
131 .unwrap_or_default()
132 .to_rfc2822()
133}
134
135/// Formats a system time in UTC with a Chrono format string.
136///
137/// The pattern is passed to Chrono without modification. The rendered value is
138/// returned as an owned string.
139#[must_use]
140pub fn format(ts: SystemTime, str: &str) -> String {
141 use chrono::{DateTime, Utc};
142
143 let dt: DateTime<Utc> = ts.into();
144 dt.format(str).to_string()
145}
146
147/// Formats a duration with one plural human-readable unit.
148///
149/// The unit and scale component come from [`whole_and_frac`]. Output has the
150/// form `{whole}.{scaled} {unit}`, where `scaled` is the component multiplied
151/// by 100, truncated to an integer, and emitted without zero padding.
152#[must_use]
153#[expect(
154 clippy::as_conversions,
155 clippy::cast_possible_truncation,
156 clippy::cast_sign_loss
157)]
158pub fn pretty(d: Duration) -> String {
159 use Unit::*;
160
161 let fmt = |w, f, u| format!("{w}.{f} {u}");
162 let gen64 = |w, f, u| fmt(w, (f * 100.0) as u32, u);
163 let gen128 = |w, f, u| gen64(u64::try_from(w).expect("u128 to u64"), f, u);
164 match whole_and_frac(d) {
165 | (Days(whole), frac) => gen64(whole, frac, "days"),
166 | (Hours(whole), frac) => gen64(whole, frac, "hours"),
167 | (Mins(whole), frac) => gen64(whole, frac, "minutes"),
168 | (Secs(whole), frac) => gen64(whole, frac, "seconds"),
169 | (Millis(whole), frac) => gen128(whole, frac, "milliseconds"),
170 | (Micros(whole), frac) => gen128(whole, frac, "microseconds"),
171 | (Nanos(whole), frac) => gen128(whole, frac, "nanoseconds"),
172 }
173}
174
175/// Pairs a duration's selected whole unit with a floating-point scale
176/// component.
177///
178/// For days through minutes, the second value is a whole-second remainder
179/// divided by the selected unit, so subsecond data is discarded. Seconds use
180/// milliseconds within the current second, discarding submillisecond data;
181/// milliseconds use microseconds, discarding nanoseconds; microseconds use
182/// nanoseconds; nanoseconds return `0.0`.
183#[must_use]
184#[expect(clippy::as_conversions, clippy::cast_precision_loss)]
185pub fn whole_and_frac(d: Duration) -> (Unit, f64) {
186 use Unit::*;
187
188 let whole = whole_unit(d);
189 (whole, match whole {
190 | Days(_) => (d.as_secs() % 86_400) as f64 / 86_400.0,
191 | Hours(_) => (d.as_secs() % 3_600) as f64 / 3_600.0,
192 | Mins(_) => (d.as_secs() % 60) as f64 / 60.0,
193 | Secs(_) => f64::from(d.subsec_millis()) / 1000.0,
194 | Millis(_) => f64::from(d.subsec_micros()) / 1000.0,
195 | Micros(_) => f64::from(d.subsec_nanos()) / 1000.0,
196 | Nanos(_) => 0.0,
197 })
198}
199
200/// Selects the largest integral unit represented by a duration.
201///
202/// The stored value is rounded down to a whole unit. A zero duration is
203/// represented as `Unit::Nanos(0)`.
204#[must_use]
205pub fn whole_unit(d: Duration) -> Unit {
206 use Unit::*;
207
208 match d.as_secs() {
209 | 86_400.. => Days(d.as_secs() / 86_400),
210 | 3_600..=86_399 => Hours(d.as_secs() / 3_600),
211 | 60..=3_599 => Mins(d.as_secs() / 60),
212 | _ => match d.as_micros() {
213 | 1_000_000.. => Secs(d.as_secs()),
214 | 1_000..=999_999 => Millis(d.subsec_millis().into()),
215 | _ => match d.as_nanos() {
216 | 1_000.. => Micros(d.subsec_micros().into()),
217 | _ => Nanos(d.subsec_nanos().into()),
218 },
219 },
220 }
221}
222
223/// Represents an integral duration in one selected unit.
224///
225/// Each variant stores the whole count for its named unit. [`whole_unit`]
226/// selects the largest unit with a nonzero count, except that zero is
227/// represented in nanoseconds.
228#[derive(Eq, PartialEq, Clone, Copy, Debug)]
229pub enum Unit {
230 /// A duration measured in whole 86,400-second days.
231 ///
232 /// [`whole_unit`] selects this variant for durations of at least one day.
233 Days(u64),
234
235 /// A duration measured in whole hours.
236 ///
237 /// [`whole_unit`] selects this variant below one day and at or above one
238 /// hour.
239 Hours(u64),
240
241 /// A duration measured in whole minutes.
242 ///
243 /// [`whole_unit`] selects this variant below one hour and at or above one
244 /// minute.
245 Mins(u64),
246
247 /// A duration measured in whole seconds.
248 ///
249 /// [`whole_unit`] selects this variant below one minute and at or above one
250 /// second.
251 Secs(u64),
252
253 /// A duration measured in whole milliseconds.
254 ///
255 /// [`whole_unit`] selects this variant below one second and at or above one
256 /// millisecond.
257 Millis(u128),
258
259 /// A duration measured in whole microseconds.
260 ///
261 /// [`whole_unit`] selects this variant below one millisecond and at or
262 /// above one microsecond.
263 Micros(u128),
264
265 /// A duration measured in whole nanoseconds.
266 ///
267 /// [`whole_unit`] selects this variant below one microsecond, including for
268 /// zero.
269 Nanos(u128),
270}