Skip to main content

pixelcoords_core/
duration.rs

1//! Human durations on the CLI: `500ms`, `30s`, `2m`.
2//!
3//! One integer and one unit, and nothing else. A bare number is refused
4//! because `--timeout 30` reads as seconds to one person and
5//! milliseconds to another, and a tool that picks silently will be wrong
6//! for half its users. Decimals, compounds (`1m30s`), uppercase, and
7//! internal spaces are refused for the same reason `config` refuses
8//! unknown keys: a value this program cannot honour exactly is an error
9//! with an actionable message, never a guess.
10
11use std::time::Duration;
12
13use thiserror::Error;
14
15#[derive(Debug, Error, PartialEq, Eq)]
16pub enum DurationError {
17    #[error("a duration cannot be empty — write it as 30s, 500ms, or 2m")]
18    Empty,
19    #[error("{input:?} has no unit — write 30s, 500ms, or 2m; a bare number is ambiguous")]
20    NoUnit { input: String },
21    #[error("{unit:?} in {input:?} is not a duration unit — use ms, s, or m")]
22    UnknownUnit { unit: String, input: String },
23    #[error("{digits:?} in {input:?} is not a whole number — durations are integers, not 1.5s")]
24    NotAnInteger { digits: String, input: String },
25    #[error("{input:?} is too large to represent")]
26    Overflow { input: String },
27}
28
29/// Parse `<integer><unit>` where unit is `ms`, `s`, or `m`.
30///
31/// Zero parses (`0s`): this owns the syntax, and whether a command
32/// accepts a zero timeout or interval is that command's rule to state.
33pub fn parse_duration(text: &str) -> Result<Duration, DurationError> {
34    if text.is_empty() {
35        return Err(DurationError::Empty);
36    }
37    let split = text
38        .find(|c: char| !c.is_ascii_digit())
39        .ok_or_else(|| DurationError::NoUnit {
40            input: text.to_string(),
41        })?;
42    let (digits, unit) = text.split_at(split);
43    if digits.is_empty() {
44        return Err(DurationError::NotAnInteger {
45            digits: digits.to_string(),
46            input: text.to_string(),
47        });
48    }
49    let millis_per = match unit {
50        "ms" => 1u64,
51        "s" => 1_000,
52        "m" => 60_000,
53        // A decimal point lands here as part of the "unit", which reads
54        // worse than saying the number is the problem.
55        _ if unit.starts_with('.') => {
56            return Err(DurationError::NotAnInteger {
57                digits: text.to_string(),
58                input: text.to_string(),
59            });
60        }
61        _ => {
62            return Err(DurationError::UnknownUnit {
63                unit: unit.to_string(),
64                input: text.to_string(),
65            });
66        }
67    };
68    let value: u64 = digits.parse().map_err(|_| DurationError::Overflow {
69        input: text.to_string(),
70    })?;
71    // overflow-checks is on even in release, so a wrap here would be a
72    // panic in a fullscreen tool rather than a wrong number.
73    let millis = value
74        .checked_mul(millis_per)
75        .ok_or_else(|| DurationError::Overflow {
76            input: text.to_string(),
77        })?;
78    Ok(Duration::from_millis(millis))
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn the_three_units_parse() {
87        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
88        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
89        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
90    }
91
92    #[test]
93    fn zero_is_syntax_not_policy() {
94        // The parser owns the grammar; `wait` decides whether a zero
95        // interval is a sensible thing to ask for.
96        assert_eq!(parse_duration("0s").unwrap(), Duration::ZERO);
97        assert_eq!(parse_duration("0ms").unwrap(), Duration::ZERO);
98    }
99
100    #[test]
101    fn a_bare_number_is_refused_rather_than_assumed() {
102        assert_eq!(
103            parse_duration("30").unwrap_err(),
104            DurationError::NoUnit { input: "30".into() },
105            "seconds to one reader, milliseconds to another"
106        );
107    }
108
109    #[test]
110    fn an_empty_string_says_what_to_write() {
111        assert_eq!(parse_duration("").unwrap_err(), DurationError::Empty);
112        let text = DurationError::Empty.to_string();
113        assert!(text.contains("30s") && text.contains("500ms"));
114    }
115
116    #[test]
117    fn decimals_are_named_as_a_number_problem_not_a_unit_one() {
118        assert_eq!(
119            parse_duration("1.5s").unwrap_err(),
120            DurationError::NotAnInteger {
121                digits: "1.5s".into(),
122                input: "1.5s".into(),
123            },
124            "the unit is fine; the quantity is not"
125        );
126    }
127
128    #[test]
129    fn unknown_units_list_the_ones_that_work() {
130        for bad in ["30sec", "2h", "5d", "30S", "1M"] {
131            let err = parse_duration(bad).unwrap_err();
132            assert!(
133                matches!(err, DurationError::UnknownUnit { .. }),
134                "{bad}: {err}"
135            );
136            assert!(err.to_string().contains("ms, s, or m"));
137        }
138    }
139
140    #[test]
141    fn compounds_and_whitespace_are_refused() {
142        // `1m30s` would need a grammar, and a half-parsed duration is
143        // worse than a rejected one.
144        assert!(parse_duration("1m30s").is_err());
145        assert!(parse_duration("30 s").is_err());
146        assert!(parse_duration(" 30s").is_err());
147        assert!(parse_duration("30s ").is_err());
148    }
149
150    #[test]
151    fn a_leading_unit_or_sign_is_not_a_duration() {
152        assert!(matches!(
153            parse_duration("ms").unwrap_err(),
154            DurationError::NotAnInteger { .. }
155        ));
156        assert!(
157            parse_duration("-5s").is_err(),
158            "durations do not run backwards"
159        );
160    }
161
162    #[test]
163    fn values_that_cannot_be_represented_are_refused_not_wrapped() {
164        assert_eq!(
165            parse_duration("99999999999999999999s").unwrap_err(),
166            DurationError::Overflow {
167                input: "99999999999999999999s".into()
168            },
169            "too large for u64 before the unit is even applied"
170        );
171        // Fits u64 as a number, overflows once scaled to milliseconds.
172        assert!(matches!(
173            parse_duration("18446744073709551m").unwrap_err(),
174            DurationError::Overflow { .. }
175        ));
176    }
177}