harn_vm/
duration_parse.rs1pub(crate) fn split_amount_unit(raw: &str) -> Option<(&str, String)> {
34 let trimmed = raw.trim();
35 if trimmed.is_empty() {
36 return None;
37 }
38 let split = trimmed
39 .find(|ch: char| !ch.is_ascii_digit())
40 .unwrap_or(trimmed.len());
41 if split == 0 {
42 return None; }
44 Some((
45 &trimmed[..split],
46 trimmed[split..].trim().to_ascii_lowercase(),
47 ))
48}
49
50pub(crate) fn unit_to_millis(unit: &str) -> Option<u64> {
54 Some(match unit {
55 "ms" => 1,
56 "s" => 1_000,
57 "m" => 60_000,
58 "h" => 3_600_000,
59 "d" => 86_400_000,
60 "w" => 604_800_000,
61 _ => return None,
62 })
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum DurationParseError {
71 Empty,
73 NoDigits,
75 MissingUnit,
77 AmountOverflow,
79 UnknownUnit(String),
81 TooLarge,
83}
84
85pub fn parse_millis(raw: &str) -> Result<u64, DurationParseError> {
90 let Some((digits, unit)) = split_amount_unit(raw) else {
91 return Err(if raw.trim().is_empty() {
92 DurationParseError::Empty
93 } else {
94 DurationParseError::NoDigits
95 });
96 };
97 if unit.is_empty() {
98 return Err(DurationParseError::MissingUnit);
99 }
100 let amount: u64 = digits
101 .parse()
102 .map_err(|_| DurationParseError::AmountOverflow)?;
103 let multiplier = unit_to_millis(&unit).ok_or(DurationParseError::UnknownUnit(unit))?;
104 amount
105 .checked_mul(multiplier)
106 .ok_or(DurationParseError::TooLarge)
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn a_unit_suffix_is_required() {
115 assert_eq!(parse_millis("30"), Err(DurationParseError::MissingUnit));
118 }
119
120 #[test]
121 fn every_unit_through_weeks_is_accepted() {
122 assert_eq!(parse_millis("200ms"), Ok(200));
123 assert_eq!(parse_millis("5s"), Ok(5_000));
124 assert_eq!(parse_millis("5m"), Ok(300_000));
125 assert_eq!(parse_millis("2h"), Ok(7_200_000));
126 assert_eq!(parse_millis("7d"), Ok(7 * 86_400_000));
127 assert_eq!(parse_millis("2w"), Ok(2 * 604_800_000));
128 }
129
130 #[test]
131 fn overflow_is_reported_never_clamped() {
132 assert_eq!(
135 parse_millis("99999999999999999h"),
136 Err(DurationParseError::TooLarge)
137 );
138 assert_eq!(
139 parse_millis("99999999999999999999999s"),
140 Err(DurationParseError::AmountOverflow)
141 );
142 }
143
144 #[test]
145 fn blank_digitless_and_unknown_units_are_distinguishable() {
146 assert_eq!(parse_millis(" "), Err(DurationParseError::Empty));
147 assert_eq!(parse_millis("abc"), Err(DurationParseError::NoDigits));
148 assert_eq!(parse_millis("-5s"), Err(DurationParseError::NoDigits));
149 assert_eq!(
150 parse_millis("5y"),
151 Err(DurationParseError::UnknownUnit("y".to_string()))
152 );
153 }
154
155 #[test]
156 fn units_are_case_insensitive_and_tolerate_internal_space() {
157 assert_eq!(parse_millis("5 M"), Ok(300_000));
158 assert_eq!(parse_millis("1H"), Ok(3_600_000));
159 }
160
161 #[test]
162 fn splits_number_and_unit() {
163 assert_eq!(split_amount_unit("5m"), Some(("5", "m".to_string())));
164 assert_eq!(
165 split_amount_unit(" 200 ms "),
166 Some(("200", "ms".to_string()))
167 );
168 assert_eq!(split_amount_unit("30"), Some(("30", String::new())));
169 assert_eq!(split_amount_unit("1H"), Some(("1", "h".to_string())));
170 }
171
172 #[test]
173 fn rejects_blank_and_unitless_prefix() {
174 assert_eq!(split_amount_unit(""), None);
175 assert_eq!(split_amount_unit(" "), None);
176 assert_eq!(split_amount_unit("abc"), None);
177 }
178
179 #[test]
180 fn unit_table_is_canonical() {
181 assert_eq!(unit_to_millis("ms"), Some(1));
182 assert_eq!(unit_to_millis("s"), Some(1_000));
183 assert_eq!(unit_to_millis("m"), Some(60_000));
184 assert_eq!(unit_to_millis("h"), Some(3_600_000));
185 assert_eq!(unit_to_millis("d"), Some(86_400_000));
186 assert_eq!(unit_to_millis("w"), Some(604_800_000));
187 assert_eq!(unit_to_millis(""), None);
188 assert_eq!(unit_to_millis("y"), None);
189 }
190}