Skip to main content

vta_cli_common/
duration.rs

1//! Short duration parsing for CLI flags like `--admin-expires 7d`, plus
2//! user-facing display helpers.
3//!
4//! Internally we always store and transmit times as **UTC unix-epoch
5//! seconds**. Anything shown to the operator is converted to their local
6//! timezone for readability.
7//!
8//! Duration parsing accepts `N[s|m|h|d|w]` — seconds, minutes, hours,
9//! days, weeks — or a plain integer which is interpreted as seconds.
10//! Whitespace is trimmed.
11
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use chrono::{DateTime, Local, Utc};
15
16/// Parse a duration string into seconds.
17///
18/// Recognised suffixes: `s` (seconds), `m` (minutes), `h` (hours),
19/// `d` (days), `w` (weeks). An unsuffixed value is seconds.
20///
21/// Examples: `"30s" → 30`, `"5m" → 300`, `"24h" → 86400`, `"7d" → 604800`,
22/// `"2w" → 1209600`, `"3600" → 3600`.
23pub fn parse_duration_secs(s: &str) -> Result<u64, Box<dyn std::error::Error>> {
24    let s = s.trim();
25    if s.is_empty() {
26        return Err("empty duration value".into());
27    }
28    let (num_str, mult) = match s.as_bytes().last().copied() {
29        Some(b's') => (&s[..s.len() - 1], 1u64),
30        Some(b'm') => (&s[..s.len() - 1], 60),
31        Some(b'h') => (&s[..s.len() - 1], 3600),
32        Some(b'd') => (&s[..s.len() - 1], 86_400),
33        Some(b'w') => (&s[..s.len() - 1], 604_800),
34        Some(c) if c.is_ascii_digit() => (s, 1),
35        _ => return Err(format!("invalid duration '{s}' (use N[s|m|h|d|w])").into()),
36    };
37    let n: u64 = num_str
38        .parse()
39        .map_err(|_| format!("invalid duration number in '{s}'"))?;
40    if n == 0 {
41        return Err("duration must be positive".into());
42    }
43    Ok(n.saturating_mul(mult))
44}
45
46/// Parse a duration and return the absolute unix-epoch expiry time
47/// (`now + duration`).
48pub fn duration_to_expires_at(s: &str) -> Result<u64, Box<dyn std::error::Error>> {
49    let secs = parse_duration_secs(s)?;
50    let now = now_unix();
51    Ok(now.saturating_add(secs))
52}
53
54/// Current unix-epoch seconds (UTC, monotonic with the system wall clock).
55pub fn now_unix() -> u64 {
56    SystemTime::now()
57        .duration_since(UNIX_EPOCH)
58        .map(|d| d.as_secs())
59        .unwrap_or(0)
60}
61
62/// Format a unix-epoch seconds value as a readable local-timezone string
63/// with an ISO-style offset, suitable for operator-facing output.
64///
65/// Example: `2026-04-20 15:32:17 +08:00`.
66pub fn format_local_time(unix_secs: u64) -> String {
67    match DateTime::from_timestamp(unix_secs as i64, 0) {
68        Some(utc) => format_local_datetime(utc),
69        None => unix_secs.to_string(),
70    }
71}
72
73/// Format a `DateTime<Utc>` (the protocol-level wire format) as a readable
74/// local-timezone string with an ISO-style offset.
75///
76/// Example: `2026-04-20 15:32:17 +08:00`.
77pub fn format_local_datetime(dt: DateTime<Utc>) -> String {
78    dt.with_timezone(&Local)
79        .format("%Y-%m-%d %H:%M:%S %:z")
80        .to_string()
81}
82
83/// Format a relative-time string showing how long until `unix_secs` from
84/// `now_unix()`. Handles both future (`in 1h 0m`) and past (`expired 5m
85/// ago`) cases. Picks the largest sensible unit pair and rounds toward
86/// the nearest integer in each.
87///
88/// Uses full seconds internally so small intervals (a few minutes or
89/// less) don't collapse to "0h" via truncating integer division.
90pub fn format_remaining(unix_secs: u64) -> String {
91    let now = now_unix();
92    let (secs, expired) = if unix_secs >= now {
93        (unix_secs - now, false)
94    } else {
95        (now - unix_secs, true)
96    };
97
98    let pretty = humanize_duration(secs);
99    if expired {
100        format!("expired {pretty} ago")
101    } else {
102        format!("in {pretty}")
103    }
104}
105
106/// Format a duration in seconds as a compact two-part human string:
107/// "1h 30m", "5d 12h", "45s", "0s". Picks the largest unit that fits
108/// and adds one smaller unit when it helps readability.
109pub fn humanize_duration(secs: u64) -> String {
110    const MIN: u64 = 60;
111    const HOUR: u64 = 60 * MIN;
112    const DAY: u64 = 24 * HOUR;
113    const WEEK: u64 = 7 * DAY;
114
115    if secs >= WEEK {
116        let weeks = secs / WEEK;
117        let days = (secs % WEEK) / DAY;
118        if days == 0 {
119            format!("{weeks}w")
120        } else {
121            format!("{weeks}w {days}d")
122        }
123    } else if secs >= DAY {
124        let days = secs / DAY;
125        let hours = (secs % DAY) / HOUR;
126        if hours == 0 {
127            format!("{days}d")
128        } else {
129            format!("{days}d {hours}h")
130        }
131    } else if secs >= HOUR {
132        let hours = secs / HOUR;
133        let mins = (secs % HOUR) / MIN;
134        if mins == 0 {
135            format!("{hours}h")
136        } else {
137            format!("{hours}h {mins}m")
138        }
139    } else if secs >= MIN {
140        let mins = secs / MIN;
141        let s = secs % MIN;
142        if s == 0 {
143            format!("{mins}m")
144        } else {
145            format!("{mins}m {s}s")
146        }
147    } else {
148        format!("{secs}s")
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn parses_each_unit() {
158        assert_eq!(parse_duration_secs("30s").unwrap(), 30);
159        assert_eq!(parse_duration_secs("5m").unwrap(), 300);
160        assert_eq!(parse_duration_secs("2h").unwrap(), 7200);
161        assert_eq!(parse_duration_secs("7d").unwrap(), 604_800);
162        assert_eq!(parse_duration_secs("2w").unwrap(), 1_209_600);
163        assert_eq!(parse_duration_secs("3600").unwrap(), 3600);
164        assert_eq!(parse_duration_secs("  24h  ").unwrap(), 86_400);
165    }
166
167    #[test]
168    fn rejects_garbage() {
169        assert!(parse_duration_secs("").is_err());
170        assert!(parse_duration_secs("abc").is_err());
171        assert!(parse_duration_secs("7x").is_err());
172        assert!(parse_duration_secs("0h").is_err());
173    }
174
175    #[test]
176    fn expiry_is_in_the_future() {
177        let before = SystemTime::now()
178            .duration_since(UNIX_EPOCH)
179            .unwrap()
180            .as_secs();
181        let expiry = duration_to_expires_at("60s").unwrap();
182        assert!(expiry >= before + 60);
183        assert!(expiry <= before + 65);
184    }
185
186    #[test]
187    fn humanize_picks_the_right_unit_pair() {
188        assert_eq!(humanize_duration(0), "0s");
189        assert_eq!(humanize_duration(45), "45s");
190        assert_eq!(humanize_duration(60), "1m");
191        assert_eq!(humanize_duration(125), "2m 5s");
192        assert_eq!(humanize_duration(3600), "1h");
193        assert_eq!(humanize_duration(3660), "1h 1m");
194        assert_eq!(humanize_duration(86400), "1d");
195        assert_eq!(humanize_duration(90000), "1d 1h");
196        assert_eq!(humanize_duration(604_800), "1w");
197        assert_eq!(humanize_duration(691_200), "1w 1d");
198    }
199
200    #[test]
201    fn format_remaining_handles_future_and_past() {
202        let now = now_unix();
203        assert!(format_remaining(now + 3600).starts_with("in "));
204        assert!(format_remaining(now - 3600).starts_with("expired "));
205        assert!(format_remaining(now - 3600).ends_with("ago"));
206    }
207
208    #[test]
209    fn format_local_time_is_nonempty() {
210        let s = format_local_time(1776600737);
211        assert!(s.len() > 10, "unexpectedly short: {s}");
212        // Should contain a colon from HH:MM:SS
213        assert!(s.contains(':'));
214    }
215}