Skip to main content

harn_vm/
duration_parse.rs

1//! The single duration grammar for `<number><unit>` strings.
2//!
3//! Every subsystem that accepts a human-written duration ("5m", "200ms",
4//! "1h") parses it here, under one set of rules:
5//!
6//! * A unit suffix is **required**. `"30"` is an error, not an implicit
7//!   millisecond count — a unitless number reads as seconds to most people
8//!   and as milliseconds to most APIs, so guessing silently misreads it.
9//! * The vocabulary is `ms`, `s`, `m`, `h`, `d`, `w`, matched
10//!   case-insensitively, tolerating whitespace before the suffix.
11//! * Arithmetic is **checked**. An oversized value is reported, never clamped:
12//!   a timeout that silently becomes `u64::MAX` presents to the user as a hang,
13//!   which is far harder to diagnose than a parse error.
14//!
15//! These rules were previously three forked copies that disagreed. Most
16//! sharply, `when_budget.timeout` was parsed by *both* the CLI manifest
17//! validator and the runtime `trigger_register`, which disagreed on overflow —
18//! the same string was rejected at validation but accepted and clamped at
19//! registration. One grammar removes that class of divergence.
20//!
21//! [`parse_millis`] owns the arithmetic and returns a structured
22//! [`DurationParseError`]; each caller maps that onto its own error type and
23//! wording, which stays the caller's business.
24//!
25//! The float/long-form cache-TTL parser (`llm::cache`) and the
26//! `OptionsParser` millis path (`stdlib::options`, which rejects unit strings
27//! outright) are deliberate outliers and do not use this module.
28
29/// Split `raw` into its leading ASCII-digit run and the trimmed, lowercased
30/// unit suffix. Returns `None` when `raw` is blank or has no digit prefix.
31/// An all-digits input yields an empty unit string; the caller maps that to
32/// its chosen default unit.
33pub(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; // no numeric prefix
43    }
44    Some((
45        &trimmed[..split],
46        trimmed[split..].trim().to_ascii_lowercase(),
47    ))
48}
49
50/// Canonical milliseconds-per-unit for the duration vocabulary shared across
51/// the codebase. `""` (no suffix) is intentionally excluded — callers decide
52/// the default unit themselves. Returns `None` for an unknown unit.
53pub(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/// Why a duration string could not be interpreted.
66///
67/// Structured rather than stringly-typed so each caller can attach its own
68/// wording — the messages are user-facing and differ per surface.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum DurationParseError {
71    /// Input was empty or entirely whitespace.
72    Empty,
73    /// Input had no leading digit run (e.g. `"abc"`, `"-5s"`).
74    NoDigits,
75    /// Input was a bare number, which this grammar does not accept.
76    MissingUnit,
77    /// The digit run did not fit in `u64`.
78    AmountOverflow,
79    /// The suffix is not one of `ms`, `s`, `m`, `h`, `d`, `w`.
80    UnknownUnit(String),
81    /// The millisecond product did not fit in `u64`.
82    TooLarge,
83}
84
85/// Parse a `<number><unit>` duration string into milliseconds.
86///
87/// See the module docs for the grammar. Units are matched case-insensitively
88/// and tolerate whitespace before the suffix.
89pub 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        // Previously the two config-file parsers read this as 30ms while the
116        // CLI rejected it. It is now uniformly an error.
117        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        // `trigger_register` used to saturate this to u64::MAX, presenting to
133        // the user as a hang rather than an error.
134        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}