Skip to main content

commons/
time.rs

1//! Time handling and duration utilities.
2
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5/// Get the current Unix timestamp in seconds
6#[must_use]
7pub fn unix_timestamp() -> u64 {
8    SystemTime::now()
9        .duration_since(UNIX_EPOCH)
10        .unwrap_or_default()
11        .as_secs()
12}
13
14/// Get the current Unix timestamp in milliseconds
15#[must_use]
16#[allow(clippy::cast_possible_truncation)]
17pub fn unix_timestamp_millis() -> u64 {
18    SystemTime::now()
19        .duration_since(UNIX_EPOCH)
20        .unwrap_or_default()
21        .as_millis() as u64
22}
23
24/// Format a duration in a human-readable way
25#[must_use]
26pub fn format_duration(duration: Duration) -> String {
27    let secs = duration.as_secs();
28    let millis = duration.subsec_millis();
29
30    if secs >= 86400 {
31        let days = secs / 86400;
32        let hours = (secs % 86400) / 3600;
33        format!("{days}d {hours}h")
34    } else if secs >= 3600 {
35        let hours = secs / 3600;
36        let minutes = (secs % 3600) / 60;
37        format!("{hours}h {minutes}m")
38    } else if secs >= 60 {
39        let minutes = secs / 60;
40        let seconds = secs % 60;
41        format!("{minutes}m {seconds}s")
42    } else if secs > 0 {
43        format!("{secs}.{millis:03}s")
44    } else {
45        format!("{millis}ms")
46    }
47}
48
49/// Parse a duration from a human-readable string.
50///
51/// Supports single units (`"100ms"`, `"5s"`, `"2m"`, `"1h"`, `"1d"`) as well
52/// as compound expressions separated by whitespace (`"1h 30m"`,
53/// `"2d 6h 30m 500ms"`). A bare number without a suffix is treated as
54/// fractional seconds.
55///
56/// # Errors
57///
58/// Returns an error if any chunk cannot be parsed.
59pub fn parse_duration(s: &str) -> Result<Duration, String> {
60    let s = s.trim();
61    if s.is_empty() {
62        return Err("Empty duration string".to_string());
63    }
64
65    let chunks: Vec<&str> = s.split_whitespace().collect();
66
67    // Fast path: single chunk (most common case, preserves fractional-second support)
68    if chunks.len() == 1 {
69        return parse_single_duration(chunks[0]);
70    }
71
72    let mut total = Duration::ZERO;
73    for chunk in chunks {
74        total += parse_single_duration(chunk)?;
75    }
76    Ok(total)
77}
78
79/// Parse a single duration chunk such as `"100ms"` or `"2h"`.
80fn parse_single_duration(s: &str) -> Result<Duration, String> {
81    if let Some(num_str) = s.strip_suffix("ms") {
82        let num: u64 = num_str
83            .parse()
84            .map_err(|_| format!("Invalid milliseconds value: {num_str}"))?;
85        Ok(Duration::from_millis(num))
86    } else if let Some(num_str) = s.strip_suffix('s') {
87        let num: f64 = num_str
88            .parse()
89            .map_err(|_| format!("Invalid seconds value: {num_str}"))?;
90        Ok(Duration::from_secs_f64(num))
91    } else if let Some(num_str) = s.strip_suffix('m') {
92        let num: u64 = num_str
93            .parse()
94            .map_err(|_| format!("Invalid minutes value: {num_str}"))?;
95        Ok(Duration::from_secs(num * 60))
96    } else if let Some(num_str) = s.strip_suffix('h') {
97        let num: u64 = num_str
98            .parse()
99            .map_err(|_| format!("Invalid hours value: {num_str}"))?;
100        Ok(Duration::from_secs(num * 3600))
101    } else if let Some(num_str) = s.strip_suffix('d') {
102        let num: u64 = num_str
103            .parse()
104            .map_err(|_| format!("Invalid days value: {num_str}"))?;
105        Ok(Duration::from_secs(num * 86400))
106    } else {
107        // Bare number — treat as fractional seconds
108        let num: f64 = s
109            .parse()
110            .map_err(|_| format!("Unknown duration chunk: {s}"))?;
111        Ok(Duration::from_secs_f64(num))
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_parse_duration() {
121        assert_eq!(parse_duration("100ms").unwrap(), Duration::from_millis(100));
122        assert_eq!(parse_duration("5s").unwrap(), Duration::from_secs(5));
123        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
124        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
125        assert_eq!(parse_duration("1d").unwrap(), Duration::from_secs(86400));
126    }
127
128    #[test]
129    fn test_parse_duration_compound() {
130        assert_eq!(
131            parse_duration("1h 30m").unwrap(),
132            Duration::from_secs(3600 + 1800)
133        );
134        assert_eq!(
135            parse_duration("2d 6h 30m 500ms").unwrap(),
136            Duration::from_secs(2 * 86400 + 6 * 3600 + 30 * 60) + Duration::from_millis(500)
137        );
138        assert_eq!(
139            parse_duration("  5s  200ms  ").unwrap(),
140            Duration::from_secs(5) + Duration::from_millis(200)
141        );
142    }
143
144    #[test]
145    fn test_parse_duration_errors() {
146        assert!(parse_duration("").is_err());
147        assert!(parse_duration("abc").is_err());
148        assert!(parse_duration("1h abc").is_err());
149    }
150
151    #[test]
152    fn test_format_duration() {
153        assert_eq!(format_duration(Duration::from_millis(500)), "500ms");
154        assert_eq!(format_duration(Duration::from_secs(5)), "5.000s");
155        assert_eq!(format_duration(Duration::from_secs(65)), "1m 5s");
156        assert_eq!(format_duration(Duration::from_secs(3665)), "1h 1m");
157    }
158}