vta_cli_common/
duration.rs1use std::time::{SystemTime, UNIX_EPOCH};
13
14use chrono::{DateTime, Local, Utc};
15
16pub 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
46pub 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
54pub fn now_unix() -> u64 {
56 SystemTime::now()
57 .duration_since(UNIX_EPOCH)
58 .map(|d| d.as_secs())
59 .unwrap_or(0)
60}
61
62pub 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
73pub 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
83pub 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
106pub 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 assert!(s.contains(':'));
214 }
215}