Skip to main content

monitrs_core/units/
duration.rs

1//! Duration parsing and display.
2//!
3//! Parsing is implemented locally rather than pulled from a crate so that the
4//! accepted grammar is exactly what the configuration documents, and so that
5//! bounds violations can name the offending key (§12).
6
7use core::fmt::Write as _;
8use core::time::Duration;
9
10use thiserror::Error;
11
12/// Why a duration string could not be accepted.
13#[derive(Clone, Debug, Eq, Error, PartialEq)]
14pub enum DurationParseError {
15    /// The input contained no digits.
16    #[error("expected a number followed by a unit (ms, s, m, h), got {input:?}")]
17    Empty {
18        /// The rejected input.
19        input: String,
20    },
21    /// The numeric part was not a valid integer.
22    #[error("{value:?} is not a whole number in {input:?}")]
23    NotANumber {
24        /// The rejected input.
25        input: String,
26        /// The portion that failed to parse.
27        value: String,
28    },
29    /// The unit suffix was not recognised.
30    #[error("unknown duration unit {unit:?} in {input:?}; expected one of ms, s, m, h")]
31    UnknownUnit {
32        /// The rejected input.
33        input: String,
34        /// The unrecognised suffix.
35        unit: String,
36    },
37    /// The value overflowed.
38    #[error("duration {input:?} is too large")]
39    Overflow {
40        /// The rejected input.
41        input: String,
42    },
43}
44
45/// Parses a duration such as `250ms`, `1s`, `5m`, or `1h`.
46///
47/// A bare number is rejected on purpose: `interval = 1` is ambiguous between
48/// one second and one millisecond, and §12 requires that invalid values point at
49/// the exact key rather than guess.
50pub 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/// Renders a duration in the canonical form [`parse_duration`] accepts.
97///
98/// Used by `config init`, `--help` text, and error messages so that every
99/// duration the application prints can be pasted back into configuration.
100#[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/// Renders a process or sample age in the fixed-width forms used by the
118/// `AGE` column: `00:43`, `03:12:44`, `12d`.
119///
120/// The forms are chosen so the column never needs to reflow (§5.4).
121#[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        // Beyond a day, sub-minute precision is noise.
132        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/// Renders a signed offset from live, such as `-00:37`, for the Time Lens
142/// header (§2.1).
143#[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/// Renders a system uptime such as `3d 04:12` (§5.5).
152#[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        // Fits in u64 as a number, but overflows when scaled to milliseconds.
201        assert!(matches!(
202            parse_duration("9999999999999h"),
203            Err(DurationParseError::Overflow { .. })
204        ));
205        // Large but representable: 999999999999h is ~3.6e18 ms, still under u64.
206        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}