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    let (amount, unit) = trimmed.split_at(split);
45    Some((amount, unit.trim().to_ascii_lowercase()))
46}
47
48/// Canonical milliseconds-per-unit for the duration vocabulary shared across
49/// the codebase. `""` (no suffix) is intentionally excluded — callers decide
50/// the default unit themselves. Returns `None` for an unknown unit.
51pub(crate) fn unit_to_millis(unit: &str) -> Option<u64> {
52    Some(match unit {
53        "ms" => 1,
54        "s" => 1_000,
55        "m" => 60_000,
56        "h" => 3_600_000,
57        "d" => 86_400_000,
58        "w" => 604_800_000,
59        _ => return None,
60    })
61}
62
63/// Why a duration string could not be interpreted.
64///
65/// Structured rather than stringly-typed so each caller can attach its own
66/// wording — the messages are user-facing and differ per surface.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum DurationParseError {
69    /// Input was empty or entirely whitespace.
70    Empty,
71    /// Input had no leading digit run (e.g. `"abc"`, `"-5s"`).
72    NoDigits,
73    /// Input was a bare number, which this grammar does not accept.
74    MissingUnit,
75    /// The digit run did not fit in `u64`.
76    AmountOverflow,
77    /// The suffix is not one of `ms`, `s`, `m`, `h`, `d`, `w`.
78    UnknownUnit(String),
79    /// The millisecond product did not fit in `u64`.
80    TooLarge,
81}
82
83/// Parse a `<number><unit>` duration string into milliseconds.
84///
85/// See the module docs for the grammar. Units are matched case-insensitively
86/// and tolerate whitespace before the suffix.
87pub fn parse_millis(raw: &str) -> Result<u64, DurationParseError> {
88    let Some((digits, unit)) = split_amount_unit(raw) else {
89        return Err(if raw.trim().is_empty() {
90            DurationParseError::Empty
91        } else {
92            DurationParseError::NoDigits
93        });
94    };
95    if unit.is_empty() {
96        return Err(DurationParseError::MissingUnit);
97    }
98    let amount: u64 = digits
99        .parse()
100        .map_err(|_| DurationParseError::AmountOverflow)?;
101    let multiplier = unit_to_millis(&unit).ok_or(DurationParseError::UnknownUnit(unit))?;
102    amount
103        .checked_mul(multiplier)
104        .ok_or(DurationParseError::TooLarge)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn a_unit_suffix_is_required() {
113        // Previously the two config-file parsers read this as 30ms while the
114        // CLI rejected it. It is now uniformly an error.
115        assert_eq!(parse_millis("30"), Err(DurationParseError::MissingUnit));
116    }
117
118    #[test]
119    fn every_unit_through_weeks_is_accepted() {
120        assert_eq!(parse_millis("200ms"), Ok(200));
121        assert_eq!(parse_millis("5s"), Ok(5_000));
122        assert_eq!(parse_millis("5m"), Ok(300_000));
123        assert_eq!(parse_millis("2h"), Ok(7_200_000));
124        assert_eq!(parse_millis("7d"), Ok(7 * 86_400_000));
125        assert_eq!(parse_millis("2w"), Ok(2 * 604_800_000));
126    }
127
128    #[test]
129    fn overflow_is_reported_never_clamped() {
130        // `trigger_register` used to saturate this to u64::MAX, presenting to
131        // the user as a hang rather than an error.
132        assert_eq!(
133            parse_millis("99999999999999999h"),
134            Err(DurationParseError::TooLarge)
135        );
136        assert_eq!(
137            parse_millis("99999999999999999999999s"),
138            Err(DurationParseError::AmountOverflow)
139        );
140    }
141
142    #[test]
143    fn blank_digitless_and_unknown_units_are_distinguishable() {
144        assert_eq!(parse_millis("  "), Err(DurationParseError::Empty));
145        assert_eq!(parse_millis("abc"), Err(DurationParseError::NoDigits));
146        assert_eq!(parse_millis("-5s"), Err(DurationParseError::NoDigits));
147        assert_eq!(
148            parse_millis("5y"),
149            Err(DurationParseError::UnknownUnit("y".to_string()))
150        );
151    }
152
153    #[test]
154    fn units_are_case_insensitive_and_tolerate_internal_space() {
155        assert_eq!(parse_millis("5 M"), Ok(300_000));
156        assert_eq!(parse_millis("1H"), Ok(3_600_000));
157    }
158
159    #[test]
160    fn splits_number_and_unit() {
161        assert_eq!(split_amount_unit("5m"), Some(("5", "m".to_string())));
162        assert_eq!(
163            split_amount_unit("  200 ms "),
164            Some(("200", "ms".to_string()))
165        );
166        assert_eq!(split_amount_unit("30"), Some(("30", String::new())));
167        assert_eq!(split_amount_unit("1H"), Some(("1", "h".to_string())));
168    }
169
170    #[test]
171    fn rejects_blank_and_unitless_prefix() {
172        assert_eq!(split_amount_unit(""), None);
173        assert_eq!(split_amount_unit("   "), None);
174        assert_eq!(split_amount_unit("abc"), None);
175    }
176
177    #[test]
178    fn unit_table_is_canonical() {
179        assert_eq!(unit_to_millis("ms"), Some(1));
180        assert_eq!(unit_to_millis("s"), Some(1_000));
181        assert_eq!(unit_to_millis("m"), Some(60_000));
182        assert_eq!(unit_to_millis("h"), Some(3_600_000));
183        assert_eq!(unit_to_millis("d"), Some(86_400_000));
184        assert_eq!(unit_to_millis("w"), Some(604_800_000));
185        assert_eq!(unit_to_millis(""), None);
186        assert_eq!(unit_to_millis("y"), None);
187    }
188}