#![allow(clippy::module_name_repetitions)]
use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::str::FromStr;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Quantity {
Parsed {
milli: i128,
suffix: Suffix,
},
Other(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Suffix {
None,
Milli,
DecimalSi(u8),
BinarySi(u8),
}
impl Quantity {
#[must_use]
pub fn from_millis(milli: i128) -> Self {
Self::Parsed {
milli,
suffix: if milli % 1000 == 0 { Suffix::None } else { Suffix::Milli },
}
}
#[must_use]
pub fn milli_value(&self) -> Option<i128> {
match self {
Self::Parsed { milli, .. } => Some(*milli),
Self::Other(_) => None,
}
}
#[must_use]
pub fn integer_value(&self) -> Option<i128> {
Some(self.milli_value()? / 1000)
}
}
impl FromStr for Quantity {
type Err = QuantityParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.is_empty() {
return Err(QuantityParseError::Empty);
}
let s = s.trim();
let suffix_start = s
.char_indices()
.find(|(_, c)| !c.is_ascii_digit() && *c != '.' && *c != '-' && *c != '+')
.map(|(i, _)| i)
.unwrap_or(s.len());
let (num_part, suffix_part) = s.split_at(suffix_start);
if num_part.is_empty() {
return Err(QuantityParseError::NoNumeric);
}
let suffix = Suffix::from_str(suffix_part)?;
let milli = parse_decimal_to_milli(num_part)?;
let scaled = suffix.apply_to_milli(milli);
Ok(Self::Parsed { milli: scaled, suffix })
}
}
impl fmt::Display for Quantity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Other(s) => f.write_str(s),
Self::Parsed { milli, suffix } => {
let unscaled = suffix.un_apply_from_milli(*milli);
let suffix_str = suffix.to_str();
if unscaled % 1000 == 0 {
write!(f, "{}{}", unscaled / 1000, suffix_str)
} else {
let int_part = unscaled / 1000;
let frac_part = (unscaled % 1000).abs();
if frac_part % 100 == 0 {
write!(f, "{}.{}{}", int_part, frac_part / 100, suffix_str)
} else if frac_part % 10 == 0 {
write!(f, "{}.{:02}{}", int_part, frac_part / 10, suffix_str)
} else {
write!(f, "{}.{:03}{}", int_part, frac_part, suffix_str)
}
}
}
}
}
}
impl Suffix {
fn from_str(s: &str) -> Result<Self, QuantityParseError> {
Ok(match s {
"" => Self::None,
"m" => Self::Milli,
"k" => Self::DecimalSi(1),
"M" => Self::DecimalSi(2),
"G" => Self::DecimalSi(3),
"T" => Self::DecimalSi(4),
"P" => Self::DecimalSi(5),
"E" => Self::DecimalSi(6),
"Ki" => Self::BinarySi(1),
"Mi" => Self::BinarySi(2),
"Gi" => Self::BinarySi(3),
"Ti" => Self::BinarySi(4),
"Pi" => Self::BinarySi(5),
"Ei" => Self::BinarySi(6),
other => return Err(QuantityParseError::UnknownSuffix(other.to_string())),
})
}
fn to_str(self) -> &'static str {
match self {
Self::None => "",
Self::Milli => "m",
Self::DecimalSi(1) => "k",
Self::DecimalSi(2) => "M",
Self::DecimalSi(3) => "G",
Self::DecimalSi(4) => "T",
Self::DecimalSi(5) => "P",
Self::DecimalSi(6) => "E",
Self::BinarySi(1) => "Ki",
Self::BinarySi(2) => "Mi",
Self::BinarySi(3) => "Gi",
Self::BinarySi(4) => "Ti",
Self::BinarySi(5) => "Pi",
Self::BinarySi(6) => "Ei",
Self::DecimalSi(_) | Self::BinarySi(_) => "?",
}
}
fn apply_to_milli(self, milli: i128) -> i128 {
match self {
Self::None => milli,
Self::Milli => milli / 1000,
Self::DecimalSi(exp) => milli.saturating_mul(10i128.pow(u32::from(exp) * 3)),
Self::BinarySi(exp) => milli.saturating_mul(1i128 << (u32::from(exp) * 10)),
}
}
fn un_apply_from_milli(self, scaled: i128) -> i128 {
match self {
Self::None => scaled,
Self::Milli => scaled * 1000,
Self::DecimalSi(exp) => scaled / 10i128.pow(u32::from(exp) * 3),
Self::BinarySi(exp) => scaled / (1i128 << (u32::from(exp) * 10)),
}
}
}
fn parse_decimal_to_milli(s: &str) -> Result<i128, QuantityParseError> {
let (int_part, frac_part) = match s.split_once('.') {
Some((i, f)) => (i, f),
None => (s, ""),
};
let int_val: i128 = int_part
.parse()
.map_err(|_| QuantityParseError::NonNumeric(s.to_string()))?;
if frac_part.is_empty() {
return Ok(int_val.saturating_mul(1000));
}
let trimmed = if frac_part.len() > 3 {
&frac_part[..3]
} else {
frac_part
};
let mut frac_val: i128 = trimmed
.parse()
.map_err(|_| QuantityParseError::NonNumeric(s.to_string()))?;
for _ in 0..(3 - trimmed.len()) {
frac_val *= 10;
}
let sign = if int_val < 0 || s.starts_with('-') { -1 } else { 1 };
Ok(int_val.saturating_mul(1000) + (frac_val * sign))
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum QuantityParseError {
#[error("empty quantity")]
Empty,
#[error("no numeric portion in quantity")]
NoNumeric,
#[error("could not parse numeric portion: {0:?}")]
NonNumeric(String),
#[error("unknown quantity suffix: {0:?}")]
UnknownSuffix(String),
}
impl Serialize for Quantity {
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
ser.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Quantity {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = String::deserialize(de)?;
Ok(Self::from_str(&s).unwrap_or_else(|_| Self::Other(s)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn parse(s: &str) -> Quantity {
Quantity::from_str(s).unwrap_or_else(|e| panic!("parse {s:?}: {e}"))
}
#[test]
fn parses_unitless_integers() {
assert_eq!(parse("4").milli_value(), Some(4000));
assert_eq!(parse("0").milli_value(), Some(0));
assert_eq!(parse("42").integer_value(), Some(42));
}
#[test]
fn parses_decimal_values() {
assert_eq!(parse("2.5").milli_value(), Some(2500));
assert_eq!(parse("0.001").milli_value(), Some(1));
assert_eq!(parse("1.234").milli_value(), Some(1234));
}
#[test]
fn parses_milli_suffix() {
assert_eq!(parse("100m").milli_value(), Some(100));
assert_eq!(parse("250m").integer_value(), Some(0));
assert_eq!(parse("1000m").milli_value(), Some(1000));
}
#[test]
fn parses_decimal_si_suffixes() {
assert_eq!(parse("1k").milli_value(), Some(1_000 * 1000));
assert_eq!(parse("1M").milli_value(), Some(1_000_000 * 1000));
assert_eq!(parse("5G").milli_value(), Some(5_000_000_000 * 1000));
}
#[test]
fn parses_binary_si_suffixes() {
assert_eq!(parse("1Ki").milli_value(), Some(1024 * 1000));
assert_eq!(parse("1Mi").milli_value(), Some(1024 * 1024 * 1000));
assert_eq!(parse("1Gi").milli_value(), Some((1i128 << 30) * 1000));
assert_eq!(parse("8Gi").integer_value(), Some(8 * (1i128 << 30)));
}
#[test]
fn display_round_trips_canonical_inputs() {
let cases = [
"0",
"1",
"42",
"100m",
"2.5",
"1k",
"5G",
"1Ki",
"1Mi",
"1Gi",
"8Gi",
"100Mi",
];
for input in cases {
let q = parse(input);
let displayed = q.to_string();
assert_eq!(displayed, input, "round-trip mismatch: {input}");
}
}
#[test]
fn other_variant_round_trips_verbatim() {
let q = Quantity::Other("3.14159e+15".into());
assert_eq!(q.to_string(), "3.14159e+15");
assert_eq!(q.milli_value(), None);
}
#[test]
fn unknown_suffix_returns_typed_error() {
let err = Quantity::from_str("100Xi").unwrap_err();
assert!(matches!(err, QuantityParseError::UnknownSuffix(_)));
}
#[test]
fn serde_round_trip_via_json() {
let original = parse("8Gi");
let json = serde_json::to_string(&original).unwrap();
assert_eq!(json, "\"8Gi\"");
let back: Quantity = serde_json::from_str(&json).unwrap();
assert_eq!(back, original);
}
#[test]
fn ord_compares_by_canonical_milli() {
assert!(parse("1Gi") > parse("500Mi"));
assert!(parse("100m") < parse("1"));
assert_eq!(parse("1k").milli_value(), parse("1000").milli_value());
}
proptest! {
#[test]
fn arb_int_plus_suffix_round_trips(
int_val in 0i64..1_000_000,
suffix_idx in 0usize..13,
) {
let suffixes = ["", "m", "k", "M", "G", "T", "P", "E", "Ki", "Mi", "Gi", "Ti", "Pi"];
let input = format!("{int_val}{}", suffixes[suffix_idx]);
let q = Quantity::from_str(&input).unwrap();
let displayed = q.to_string();
prop_assert_eq!(displayed, input);
}
#[test]
fn arb_milli_value_ordering_matches_partial_ord(
a_val in 1i64..1_000_000,
b_val in 1i64..1_000_000,
) {
let a = Quantity::from_str(&format!("{a_val}m")).unwrap();
let b = Quantity::from_str(&format!("{b_val}m")).unwrap();
let by_milli = a.milli_value().cmp(&b.milli_value());
let by_partial_ord = a.cmp(&b);
prop_assert_eq!(by_milli, by_partial_ord);
}
}
#[test]
fn from_millis_picks_natural_suffix() {
let q = Quantity::from_millis(4000);
assert_eq!(q.to_string(), "4");
let q = Quantity::from_millis(100);
assert_eq!(q.to_string(), "100m");
}
}