use std::time::Duration;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ParseError {
#[error("invalid number: {0}")]
InvalidNumber(String),
#[error("number out of range: {0}")]
OutOfRange(String),
#[error("invalid format: {0}")]
InvalidFormat(String),
#[error("unknown unit: {0}")]
UnknownUnit(String),
}
pub type Result<T> = std::result::Result<T, ParseError>;
pub fn get_u8(s: &str) -> Result<u8> {
parse_int(s)
}
pub fn get_u16(s: &str) -> Result<u16> {
parse_int(s)
}
pub fn get_u32(s: &str) -> Result<u32> {
parse_int(s)
}
pub fn get_u64(s: &str) -> Result<u64> {
parse_int(s)
}
pub fn get_i32(s: &str) -> Result<i32> {
parse_int(s)
}
fn parse_int<T: std::str::FromStr + TryFrom<u64>>(s: &str) -> Result<T>
where
<T as std::str::FromStr>::Err: std::fmt::Display,
<T as TryFrom<u64>>::Error: std::fmt::Display,
{
let s = s.trim();
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
let val =
u64::from_str_radix(hex, 16).map_err(|e| ParseError::InvalidNumber(e.to_string()))?;
return T::try_from(val).map_err(|e| ParseError::OutOfRange(e.to_string()));
}
if s.starts_with('0') && s.len() > 1 && (s.as_bytes()[1] as char).is_ascii_digit() {
let val = u64::from_str_radix(&s[1..], 8)
.map_err(|e| ParseError::InvalidNumber(e.to_string()))?;
return T::try_from(val).map_err(|e| ParseError::OutOfRange(e.to_string()));
}
s.parse()
.map_err(|e| ParseError::InvalidNumber(format!("{}", e)))
}
pub fn get_rate(s: &str) -> Result<u64> {
let s = s.trim().to_lowercase();
let (num_str, unit) = split_number_unit(&s);
let num = parse_non_negative(num_str, &s)?;
let multiplier: u64 = match unit {
"" | "bit" => 1,
"kbit" | "k" => 1000,
"mbit" | "m" => 1_000_000,
"gbit" | "g" => 1_000_000_000,
"tbit" | "t" => 1_000_000_000_000,
"kibit" => 1024,
"mibit" => 1024 * 1024,
"gibit" => 1024 * 1024 * 1024,
"tibit" => 1024u64 * 1024 * 1024 * 1024,
"bps" => 8,
"kbps" => 8 * 1000,
"mbps" => 8 * 1_000_000,
"gbps" => 8 * 1_000_000_000,
"tbps" => 8 * 1_000_000_000_000,
"kibps" => 8 * 1024,
"mibps" => 8 * 1024 * 1024,
"gibps" => 8 * 1024 * 1024 * 1024,
"tibps" => 8u64 * 1024 * 1024 * 1024 * 1024,
_ => return Err(ParseError::UnknownUnit(unit.to_string())),
};
Ok((num * multiplier as f64) as u64)
}
pub fn get_size(s: &str) -> Result<u64> {
let s = s.trim().to_lowercase();
let (num_str, unit) = split_number_unit(&s);
let num = parse_non_negative(num_str, &s)?;
let multiplier: u64 = match unit {
"" | "b" => 1,
"k" | "kb" | "kib" => 1024,
"m" | "mb" | "mib" => 1024 * 1024,
"g" | "gb" | "gib" => 1024 * 1024 * 1024,
"t" | "tb" | "tib" => 1024u64 * 1024 * 1024 * 1024,
"kbit" => 1000 / 8,
"mbit" => 1_000_000 / 8,
"gbit" => 1_000_000_000 / 8,
"tbit" => 1_000_000_000_000 / 8,
_ => return Err(ParseError::UnknownUnit(unit.to_string())),
};
Ok((num * multiplier as f64) as u64)
}
pub fn get_time(s: &str) -> Result<Duration> {
let s = s.trim().to_lowercase();
let (num_str, unit) = split_number_unit(&s);
let num = parse_non_negative(num_str, &s)?;
let duration = match unit {
"" | "us" | "usec" | "usecs" => Duration::from_secs_f64(num / 1_000_000.0),
"s" | "sec" | "secs" => Duration::from_secs_f64(num),
"ms" | "msec" | "msecs" => Duration::from_secs_f64(num / 1000.0),
"ns" | "nsec" | "nsecs" => Duration::from_nanos(num as u64),
_ => return Err(ParseError::UnknownUnit(unit.to_string())),
};
Ok(duration)
}
fn parse_non_negative(num_str: &str, full: &str) -> Result<f64> {
let num: f64 = num_str
.parse()
.map_err(|_| ParseError::InvalidNumber(num_str.to_string()))?;
if !num.is_finite() || num < 0.0 {
return Err(ParseError::OutOfRange(format!(
"`{full}` is negative or not finite (expected a non-negative value)"
)));
}
Ok(num)
}
pub fn get_percent(s: &str) -> Result<f64> {
let s = s.trim();
let s = s.strip_suffix('%').unwrap_or(s);
let val: f64 = s
.parse()
.map_err(|_| ParseError::InvalidNumber(s.to_string()))?;
if !(0.0..=100.0).contains(&val) {
return Err(ParseError::OutOfRange(format!(
"{} not in range 0-100",
val
)));
}
Ok(val / 100.0)
}
fn split_number_unit(s: &str) -> (&str, &str) {
let idx = s
.find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
.unwrap_or(s.len());
(&s[..idx], &s[idx..])
}
pub fn get_bool(s: &str) -> Result<bool> {
match s.to_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(ParseError::InvalidFormat(format!(
"expected boolean, got '{}'",
s
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_u32() {
assert_eq!(get_u32("123").unwrap(), 123);
assert_eq!(get_u32("0x1a").unwrap(), 26);
assert_eq!(get_u32("0777").unwrap(), 511);
}
#[test]
fn test_get_rate() {
assert_eq!(get_rate("1000").unwrap(), 1000);
assert_eq!(get_rate("1kbit").unwrap(), 1000);
assert_eq!(get_rate("1mbit").unwrap(), 1_000_000);
assert_eq!(get_rate("1.5mbit").unwrap(), 1_500_000);
}
#[test]
fn test_get_size() {
assert_eq!(get_size("1024").unwrap(), 1024);
assert_eq!(get_size("1k").unwrap(), 1024);
assert_eq!(get_size("1kb").unwrap(), 1024);
assert_eq!(get_size("1m").unwrap(), 1024 * 1024);
}
#[test]
fn test_get_time() {
assert_eq!(get_time("1s").unwrap(), Duration::from_secs(1));
assert_eq!(get_time("100ms").unwrap(), Duration::from_millis(100));
assert_eq!(get_time("1000us").unwrap(), Duration::from_micros(1000));
}
#[test]
fn the_bps_family_is_bytes_per_second() {
assert_eq!(get_rate("1bps").unwrap(), 8);
assert_eq!(get_rate("1kbps").unwrap(), 8_000);
assert_eq!(get_rate("10mbps").unwrap(), 80_000_000);
assert_eq!(get_rate("1gbps").unwrap(), 8_000_000_000);
assert_eq!(get_rate("1kibps").unwrap(), 8 * 1024);
assert_eq!(get_rate("10mbit").unwrap(), 10_000_000);
assert_eq!(
get_rate("1mbps").unwrap(),
8 * get_rate("1mbit").unwrap(),
"mbps must be exactly 8x mbit",
);
}
#[test]
fn rate_suffixes_are_case_insensitive() {
assert_eq!(get_rate("10MBps").unwrap(), get_rate("10mbps").unwrap());
assert_eq!(get_rate("10MBit").unwrap(), get_rate("10mbit").unwrap());
assert_eq!(get_rate("1GBit").unwrap(), 1_000_000_000);
}
#[test]
fn a_bare_time_is_microseconds() {
assert_eq!(get_time("100").unwrap(), Duration::from_micros(100));
assert_eq!(get_time("1000").unwrap(), Duration::from_millis(1));
assert_eq!(get_time("100s").unwrap(), Duration::from_secs(100));
}
#[test]
fn minutes_and_hours_are_not_time_suffixes() {
assert!(get_time("5m").is_err());
assert!(get_time("5min").is_err());
assert!(get_time("1h").is_err());
}
#[test]
fn negative_values_are_rejected_not_silently_zeroed() {
assert!(get_rate("-5mbit").is_err());
assert!(get_size("-1k").is_err());
assert!(get_time("-1s").is_err());
}
#[test]
fn get_size_accepts_the_binary_ib_spellings() {
assert_eq!(get_size("32kib").unwrap(), 32 * 1024);
assert_eq!(get_size("1mib").unwrap(), 1024 * 1024);
assert_eq!(get_size("1gib").unwrap(), 1024 * 1024 * 1024);
}
}