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