pixelcoords_core/
duration.rs1use 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
29pub 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 _ 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 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 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 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 assert!(matches!(
173 parse_duration("18446744073709551m").unwrap_err(),
174 DurationError::Overflow { .. }
175 ));
176 }
177}