Skip to main content

ferrule_config/
parse.rs

1//! Shared human-duration parsing (#56).
2//!
3//! One canonical parser feeds both the config layer (`[slow_log]
4//! threshold`) and CLI args (`ferrule history --since`), replacing the two
5//! near-identical inline parsers those call sites grew independently
6//! during the Query Telemetry Foundation sprint. It returns
7//! [`chrono::Duration`] so each caller maps to whatever unit it needs
8//! (milliseconds for the slow-log threshold, the raw delta for `--since`).
9//!
10//! Recognised units — the union of the two original alias sets:
11//!   - `ms`
12//!   - `s` / `sec` / `secs`
13//!   - `m` / `min` / `mins`
14//!   - `h` / `hr` / `hrs`
15//!   - `d` / `day` / `days`
16//!
17//! A unit suffix is **required**: a bare integer (`"500"`) is rejected
18//! here. Callers that assign a default unit to a bare integer (e.g.
19//! `[slow_log] threshold = "500"` → 500 ms) keep that quirk in their own
20//! thin wrapper, handled before delegating.
21//!
22//! Out of scope, per #56 (kept here so the boundary is explicit):
23//!   - The `humantime` crate — not worth the dependency at this caller
24//!     count; revisit past ~5 callers.
25//!   - Fractional units (`1.5h`, `0.5d`) — unused anywhere; YAGNI.
26//!   - A byte-size parser for `[slow_log] max_size` (#55) — same shape,
27//!     distinct unit class; share structure, not this signature.
28
29use chrono::Duration;
30
31/// Parse a human-readable duration like `250ms`, `30s`, `5m`, `2h`, or
32/// `7d` into a [`chrono::Duration`].
33///
34/// A unit suffix is required; a bare integer is an error (see the module
35/// docs for why, and which caller compensates). The error is a plain
36/// `String` so both the `String`-erroring config layer and the
37/// `CliError`-erroring CLI layer can wrap it without a shared error type.
38pub fn parse_duration(s: &str) -> Result<Duration, String> {
39    let s = s.trim();
40    if s.is_empty() {
41        return Err("duration is empty".into());
42    }
43    let split = s
44        .find(|c: char| !c.is_ascii_digit())
45        .ok_or_else(|| format!("duration '{s}' has no unit suffix (try 30s, 5m, 2h, 7d)"))?;
46    let (num, unit) = s.split_at(split);
47    if num.is_empty() {
48        return Err(format!("duration '{s}': missing number before unit"));
49    }
50    let n: i64 = num
51        .parse()
52        .map_err(|_| format!("duration '{s}': invalid number '{num}'"))?;
53    let unit = unit.trim();
54    let dur = match unit {
55        "ms" => Duration::milliseconds(n),
56        "s" | "sec" | "secs" => Duration::seconds(n),
57        "m" | "min" | "mins" => Duration::minutes(n),
58        "h" | "hr" | "hrs" => Duration::hours(n),
59        "d" | "day" | "days" => Duration::days(n),
60        other => return Err(format!("duration '{s}': unknown unit '{other}'")),
61    };
62    Ok(dur)
63}
64
65/// Parse a human-readable byte size like `1024`, `10MB`, or `5MiB`
66/// into a count of bytes.
67///
68/// Sibling of [`parse_duration`] (same trim -> split -> numeric-prefix ->
69/// unit-match shape) but a distinct unit class, kept separate per the
70/// module docs rather than folded into one over-general parser.
71///
72/// Recognised units:
73///   - bare integer (`"1024"`) -> bytes
74///   - `B` -> bytes
75///   - decimal SI (powers of 1000): `KB`, `MB`, `GB`
76///   - binary IEC (powers of 1024): `KiB`, `MiB`, `GiB`
77///
78/// The unit match is case-sensitive on the IEC `i` (so `KiB` is binary
79/// and `KB` is decimal). A leading `-` is non-digit, so negative inputs
80/// fall out the same way `parse_duration("-5m")` does (no number before
81/// the unit). The multiplier is applied with [`u64::checked_mul`] so an
82/// oversized input (`"99999999999GB"`) returns `Err` rather than
83/// overflowing -- this is a library crate and must not panic.
84///
85/// The error is a plain `String` so both the `String`-erroring config
86/// layer and the `CliError`-erroring CLI layer can wrap it without a
87/// shared error type.
88pub fn parse_size(s: &str) -> Result<u64, String> {
89    let s = s.trim();
90    if s.is_empty() {
91        return Err("size is empty".into());
92    }
93    let split = match s.find(|c: char| !c.is_ascii_digit()) {
94        // No unit suffix at all -> a bare integer is a byte count.
95        None => {
96            return s.parse().map_err(|_| format!("size '{s}': invalid number"));
97        }
98        Some(i) => i,
99    };
100    let (num, unit) = s.split_at(split);
101    if num.is_empty() {
102        return Err(format!("size '{s}': missing number before unit"));
103    }
104    let n: u64 = num
105        .parse()
106        .map_err(|_| format!("size '{s}': invalid number '{num}'"))?;
107    let unit = unit.trim();
108    let mul: u64 = match unit {
109        "B" => 1,
110        "KB" => 1_000,
111        "MB" => 1_000_000,
112        "GB" => 1_000_000_000,
113        "KiB" => 1_024,
114        "MiB" => 1_024 * 1_024,
115        "GiB" => 1_024 * 1_024 * 1_024,
116        other => return Err(format!("size '{s}': unknown unit '{other}'")),
117    };
118    n.checked_mul(mul)
119        .ok_or_else(|| format!("size '{s}': value overflows u64 bytes"))
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn parses_every_unit_and_alias() {
128        assert_eq!(
129            parse_duration("250ms").unwrap(),
130            Duration::milliseconds(250)
131        );
132        assert_eq!(parse_duration("30s").unwrap(), Duration::seconds(30));
133        assert_eq!(parse_duration("30sec").unwrap(), Duration::seconds(30));
134        assert_eq!(parse_duration("30secs").unwrap(), Duration::seconds(30));
135        assert_eq!(parse_duration("5m").unwrap(), Duration::minutes(5));
136        assert_eq!(parse_duration("5min").unwrap(), Duration::minutes(5));
137        assert_eq!(parse_duration("90mins").unwrap(), Duration::minutes(90));
138        assert_eq!(parse_duration("2h").unwrap(), Duration::hours(2));
139        assert_eq!(parse_duration("2hr").unwrap(), Duration::hours(2));
140        assert_eq!(parse_duration("2hrs").unwrap(), Duration::hours(2));
141        assert_eq!(parse_duration("7d").unwrap(), Duration::days(7));
142        assert_eq!(parse_duration("7day").unwrap(), Duration::days(7));
143        assert_eq!(parse_duration("7days").unwrap(), Duration::days(7));
144    }
145
146    #[test]
147    fn trims_surrounding_whitespace() {
148        assert_eq!(parse_duration("  5m  ").unwrap(), Duration::minutes(5));
149    }
150
151    #[test]
152    fn rejects_bare_integer() {
153        // The canonical parser requires a unit; bare-integer defaulting is
154        // a caller-specific quirk (see `SlowLogConfig::threshold_ms`).
155        assert!(parse_duration("10").is_err());
156    }
157
158    #[test]
159    fn rejects_empty_unit_and_unknown_inputs() {
160        assert!(parse_duration("").is_err());
161        assert!(parse_duration("   ").is_err());
162        assert!(parse_duration("hour").is_err()); // no leading number
163        assert!(parse_duration("ms").is_err()); // unit only, no number
164        assert!(parse_duration("10x").is_err()); // unknown unit
165        assert!(parse_duration("-5m").is_err()); // leading '-' is non-digit → no number
166    }
167
168    #[test]
169    fn unit_whitespace_is_tolerated() {
170        // The original `parse_threshold_ms` trimmed the unit, so `"5 m"`
171        // resolves to 5 minutes. Preserved here for the slow-log path.
172        assert_eq!(parse_duration("5 m").unwrap(), Duration::minutes(5));
173    }
174
175    #[test]
176    fn error_messages_name_the_input() {
177        let err = parse_duration("10x").unwrap_err();
178        assert!(err.contains("10x"), "error should echo the input: {err}");
179        assert!(
180            err.contains("unknown unit"),
181            "error should name the fault: {err}"
182        );
183    }
184
185    #[test]
186    fn parse_size_bare_integer_is_bytes() {
187        assert_eq!(parse_size("0").unwrap(), 0);
188        assert_eq!(parse_size("1024").unwrap(), 1024);
189    }
190
191    #[test]
192    fn parse_size_decimal_si_units() {
193        assert_eq!(parse_size("1B").unwrap(), 1);
194        assert_eq!(parse_size("1KB").unwrap(), 1_000);
195        assert_eq!(parse_size("1MB").unwrap(), 1_000_000);
196        assert_eq!(parse_size("2GB").unwrap(), 2_000_000_000);
197    }
198
199    #[test]
200    fn parse_size_binary_iec_units() {
201        assert_eq!(parse_size("1KiB").unwrap(), 1_024);
202        assert_eq!(parse_size("1MiB").unwrap(), 1_048_576);
203        assert_eq!(parse_size("1GiB").unwrap(), 1_073_741_824);
204    }
205
206    #[test]
207    fn parse_size_trims_surrounding_whitespace() {
208        assert_eq!(parse_size("  5MB ").unwrap(), 5_000_000);
209    }
210
211    #[test]
212    fn parse_size_rejects_bad_inputs() {
213        assert!(parse_size("").is_err());
214        assert!(parse_size("   ").is_err());
215        assert!(parse_size("abc").is_err()); // no leading number
216        assert!(parse_size("5x").is_err()); // unknown unit
217        assert!(parse_size("-5MB").is_err()); // leading '-' is non-digit -> no number
218    }
219
220    #[test]
221    fn parse_size_oversized_value_errors_without_panic() {
222        // checked_mul: 99999999999 * 1_000_000_000 overflows u64.
223        let err = parse_size("99999999999GB").unwrap_err();
224        assert!(
225            err.contains("99999999999GB"),
226            "error should echo input: {err}"
227        );
228        assert!(
229            err.contains("overflow"),
230            "error should name overflow: {err}"
231        );
232    }
233
234    #[test]
235    fn parse_size_error_messages_name_the_input() {
236        let err = parse_size("5x").unwrap_err();
237        assert!(err.contains("5x"), "error should echo the input: {err}");
238        assert!(
239            err.contains("unknown unit"),
240            "error should name the fault: {err}"
241        );
242    }
243}