monitrs_core/units/
duration.rs1use core::fmt::Write as _;
8use core::time::Duration;
9
10use thiserror::Error;
11
12#[derive(Clone, Debug, Eq, Error, PartialEq)]
14pub enum DurationParseError {
15 #[error("expected a number followed by a unit (ms, s, m, h), got {input:?}")]
17 Empty {
18 input: String,
20 },
21 #[error("{value:?} is not a whole number in {input:?}")]
23 NotANumber {
24 input: String,
26 value: String,
28 },
29 #[error("unknown duration unit {unit:?} in {input:?}; expected one of ms, s, m, h")]
31 UnknownUnit {
32 input: String,
34 unit: String,
36 },
37 #[error("duration {input:?} is too large")]
39 Overflow {
40 input: String,
42 },
43}
44
45pub fn parse_duration(input: &str) -> Result<Duration, DurationParseError> {
51 let trimmed = input.trim();
52 let split = trimmed
53 .char_indices()
54 .find(|(_, c)| !c.is_ascii_digit() && *c != '_')
55 .map_or(trimmed.len(), |(index, _)| index);
56
57 let (digits, unit) = trimmed.split_at(split);
58 let digits: String = digits.chars().filter(|c| *c != '_').collect();
59 if digits.is_empty() {
60 return Err(DurationParseError::Empty {
61 input: trimmed.to_owned(),
62 });
63 }
64 let amount: u64 = digits.parse().map_err(|_| DurationParseError::NotANumber {
65 input: trimmed.to_owned(),
66 value: digits.clone(),
67 })?;
68
69 let unit = unit.trim();
70 let millis = match unit {
71 "ms" => Some(amount),
72 "s" | "sec" | "secs" => amount.checked_mul(1_000),
73 "m" | "min" | "mins" => amount.checked_mul(60_000),
74 "h" | "hr" | "hrs" => amount.checked_mul(3_600_000),
75 "" => {
76 return Err(DurationParseError::UnknownUnit {
77 input: trimmed.to_owned(),
78 unit: String::new(),
79 });
80 }
81 other => {
82 return Err(DurationParseError::UnknownUnit {
83 input: trimmed.to_owned(),
84 unit: other.to_owned(),
85 });
86 }
87 };
88
89 millis
90 .map(Duration::from_millis)
91 .ok_or(DurationParseError::Overflow {
92 input: trimmed.to_owned(),
93 })
94}
95
96#[must_use]
101pub fn format_duration(duration: Duration) -> String {
102 let millis = duration.as_millis();
103 if millis == 0 {
104 return "0ms".to_owned();
105 }
106 if millis.is_multiple_of(3_600_000) {
107 format!("{}h", millis / 3_600_000)
108 } else if millis.is_multiple_of(60_000) {
109 format!("{}m", millis / 60_000)
110 } else if millis.is_multiple_of(1_000) {
111 format!("{}s", millis / 1_000)
112 } else {
113 format!("{millis}ms")
114 }
115}
116
117#[must_use]
122pub fn format_age(age: Duration) -> String {
123 let total = age.as_secs();
124 let days = total / 86_400;
125 let hours = (total % 86_400) / 3_600;
126 let minutes = (total % 3_600) / 60;
127 let seconds = total % 60;
128
129 let mut out = String::with_capacity(8);
130 if days > 0 {
131 let _ = write!(out, "{days}d");
133 } else if hours > 0 {
134 let _ = write!(out, "{hours:02}:{minutes:02}:{seconds:02}");
135 } else {
136 let _ = write!(out, "{minutes:02}:{seconds:02}");
137 }
138 out
139}
140
141#[must_use]
144pub fn format_history_offset(offset: Duration) -> String {
145 if offset.is_zero() {
146 return "LIVE".to_owned();
147 }
148 format!("-{}", format_age(offset))
149}
150
151#[must_use]
153pub fn format_uptime(uptime: Duration) -> String {
154 let total = uptime.as_secs();
155 let days = total / 86_400;
156 let hours = (total % 86_400) / 3_600;
157 let minutes = (total % 3_600) / 60;
158 let mut out = String::with_capacity(12);
159 if days > 0 {
160 let _ = write!(out, "{days}d {hours:02}:{minutes:02}");
161 } else {
162 let _ = write!(out, "{hours:02}:{minutes:02}");
163 }
164 out
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn parses_every_documented_unit() {
173 assert_eq!(parse_duration("250ms"), Ok(Duration::from_millis(250)));
174 assert_eq!(parse_duration("1s"), Ok(Duration::from_secs(1)));
175 assert_eq!(parse_duration("5m"), Ok(Duration::from_secs(300)));
176 assert_eq!(parse_duration("1h"), Ok(Duration::from_secs(3_600)));
177 assert_eq!(parse_duration(" 30s "), Ok(Duration::from_secs(30)));
178 assert_eq!(parse_duration("1_000ms"), Ok(Duration::from_secs(1)));
179 }
180
181 #[test]
182 fn a_bare_number_is_rejected_as_ambiguous() {
183 let err = parse_duration("1").expect_err("bare numbers are ambiguous");
184 assert!(matches!(err, DurationParseError::UnknownUnit { .. }));
185 }
186
187 #[test]
188 fn errors_quote_the_offending_input() {
189 let err = parse_duration("5 fortnights").expect_err("bad unit");
190 let message = err.to_string();
191 assert!(message.contains("fortnights"), "{message}");
192 assert!(matches!(
193 parse_duration("ms"),
194 Err(DurationParseError::Empty { .. })
195 ));
196 assert!(matches!(
197 parse_duration("99999999999999999999h"),
198 Err(DurationParseError::NotANumber { .. })
199 ));
200 assert!(matches!(
202 parse_duration("9999999999999h"),
203 Err(DurationParseError::Overflow { .. })
204 ));
205 assert!(parse_duration("999999999999h").is_ok());
207 }
208
209 #[test]
210 fn formatting_round_trips_through_parsing() {
211 for input in ["250ms", "1s", "30s", "5m", "60s", "1h"] {
212 let parsed = parse_duration(input).expect("valid");
213 let rendered = format_duration(parsed);
214 assert_eq!(
215 parse_duration(&rendered).expect("re-parse"),
216 parsed,
217 "{input} rendered as {rendered}"
218 );
219 }
220 }
221
222 #[test]
223 fn age_uses_fixed_width_forms() {
224 assert_eq!(format_age(Duration::from_secs(43)), "00:43");
225 assert_eq!(format_age(Duration::from_secs(138)), "02:18");
226 assert_eq!(format_age(Duration::from_secs(11_564)), "03:12:44");
227 assert_eq!(format_age(Duration::from_secs(86_400 * 12)), "12d");
228 }
229
230 #[test]
231 fn history_offset_distinguishes_live_from_a_seek() {
232 assert_eq!(format_history_offset(Duration::ZERO), "LIVE");
233 assert_eq!(format_history_offset(Duration::from_secs(37)), "-00:37");
234 }
235
236 #[test]
237 fn uptime_matches_the_header_mockup() {
238 assert_eq!(
239 format_uptime(Duration::from_secs(86_400 * 3 + 4 * 3_600 + 12 * 60)),
240 "3d 04:12"
241 );
242 assert_eq!(format_uptime(Duration::from_secs(3_600)), "01:00");
243 }
244}