lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

unit.rs (10023B)


      1 //! Unit codes, dimensions, parsing, and checked deterministic conversion.
      2 
      3 use core::fmt;
      4 use core::str::FromStr;
      5 
      6 #[cfg(all(feature = "serde", not(feature = "std")))]
      7 use alloc::string::String;
      8 #[cfg(feature = "serde")]
      9 #[cfg(feature = "std")]
     10 use std::string::String;
     11 
     12 #[cfg(feature = "serde")]
     13 use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeError};
     14 
     15 use crate::Decimal;
     16 
     17 const GRAMS_PER_OUNCE: Decimal = Decimal::from_parts(2_579_719_349, 6, 0, 9);
     18 const GRAMS_PER_POUND: Decimal = Decimal::from_parts(45_359_237, 0, 0, 5);
     19 
     20 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
     21 #[cfg_attr(all(test, feature = "std"), dto(export))]
     22 #[cfg_attr(all(test, feature = "std"), dto(as = "string_enum"))]
     23 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
     24 pub enum UnitDimension {
     25     #[cfg_attr(all(test, feature = "std"), dto(rename = "count"))]
     26     Count,
     27     #[cfg_attr(all(test, feature = "std"), dto(rename = "mass"))]
     28     Mass,
     29     #[cfg_attr(all(test, feature = "std"), dto(rename = "volume"))]
     30     Volume,
     31 }
     32 
     33 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
     34 #[cfg_attr(all(test, feature = "std"), dto(export))]
     35 #[cfg_attr(all(test, feature = "std"), dto(as = "string_enum"))]
     36 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
     37 pub enum Unit {
     38     #[cfg_attr(all(test, feature = "std"), dto(rename = "each"))]
     39     Each,
     40     #[cfg_attr(all(test, feature = "std"), dto(rename = "kg"))]
     41     MassKg,
     42     #[cfg_attr(all(test, feature = "std"), dto(rename = "g"))]
     43     MassG,
     44     #[cfg_attr(all(test, feature = "std"), dto(rename = "oz"))]
     45     MassOz,
     46     #[cfg_attr(all(test, feature = "std"), dto(rename = "lb"))]
     47     MassLb,
     48     #[cfg_attr(all(test, feature = "std"), dto(rename = "l"))]
     49     VolumeL,
     50     #[cfg_attr(all(test, feature = "std"), dto(rename = "ml"))]
     51     VolumeMl,
     52 }
     53 
     54 impl Unit {
     55     #[inline]
     56     pub fn code(&self) -> &'static str {
     57         match self {
     58             Self::Each => "each",
     59             Self::MassKg => "kg",
     60             Self::MassG => "g",
     61             Self::MassOz => "oz",
     62             Self::MassLb => "lb",
     63             Self::VolumeL => "l",
     64             Self::VolumeMl => "ml",
     65         }
     66     }
     67 
     68     pub fn same_dimension(a: Self, b: Self) -> bool {
     69         a.dimension() == b.dimension()
     70     }
     71 
     72     #[inline]
     73     pub fn dimension(&self) -> UnitDimension {
     74         match self {
     75             Self::Each => UnitDimension::Count,
     76             Self::MassKg | Self::MassG | Self::MassOz | Self::MassLb => UnitDimension::Mass,
     77             Self::VolumeL | Self::VolumeMl => UnitDimension::Volume,
     78         }
     79     }
     80 
     81     #[inline]
     82     pub fn canonical_unit(&self) -> Self {
     83         match self.dimension() {
     84             UnitDimension::Count => Self::Each,
     85             UnitDimension::Mass => Self::MassG,
     86             UnitDimension::Volume => Self::VolumeMl,
     87         }
     88     }
     89 
     90     #[inline]
     91     pub fn is_volume(&self) -> bool {
     92         matches!(self, Self::VolumeL | Self::VolumeMl)
     93     }
     94 
     95     #[inline]
     96     pub fn is_mass(&self) -> bool {
     97         matches!(
     98             self,
     99             Self::MassKg | Self::MassG | Self::MassOz | Self::MassLb
    100         )
    101     }
    102 
    103     #[inline]
    104     pub fn is_count(&self) -> bool {
    105         matches!(self, Self::Each)
    106     }
    107 }
    108 
    109 impl fmt::Display for Unit {
    110     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    111         f.write_str(self.code())
    112     }
    113 }
    114 
    115 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    116 pub enum ParseError {
    117     UnknownUnit,
    118     NotAMassUnit,
    119     NotAVolumeUnit,
    120 }
    121 
    122 impl fmt::Display for ParseError {
    123     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    124         match self {
    125             Self::UnknownUnit => write!(f, "unknown unit string"),
    126             Self::NotAMassUnit => write!(f, "unit is not a mass unit"),
    127             Self::NotAVolumeUnit => write!(f, "unit is not a volume unit"),
    128         }
    129     }
    130 }
    131 
    132 #[cfg(feature = "std")]
    133 impl std::error::Error for ParseError {}
    134 
    135 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    136 pub enum ConvertError {
    137     NotMassUnit { from: Unit, to: Unit },
    138     NotVolumeUnit { from: Unit, to: Unit },
    139     NotConvertibleUnits { from: Unit, to: Unit },
    140     ArithmeticOverflow { from: Unit, to: Unit },
    141 }
    142 
    143 impl fmt::Display for ConvertError {
    144     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    145         match self {
    146             ConvertError::NotMassUnit { from, to } => {
    147                 write!(f, "unit conversion requires mass units: {from} -> {to}")
    148             }
    149             ConvertError::NotVolumeUnit { from, to } => {
    150                 write!(f, "unit conversion requires volume units: {from} -> {to}")
    151             }
    152             ConvertError::NotConvertibleUnits { from, to } => {
    153                 write!(
    154                     f,
    155                     "unit conversion requires matching dimensions: {from} -> {to}"
    156                 )
    157             }
    158             ConvertError::ArithmeticOverflow { from, to } => {
    159                 write!(f, "unit conversion arithmetic overflow: {from} -> {to}")
    160             }
    161         }
    162     }
    163 }
    164 
    165 #[cfg(feature = "std")]
    166 impl std::error::Error for ConvertError {}
    167 
    168 impl FromStr for Unit {
    169     type Err = ParseError;
    170 
    171     fn from_str(s: &str) -> Result<Self, Self::Err> {
    172         let s = s.trim().to_ascii_lowercase();
    173         match s.as_str() {
    174             "each" | "ea" | "count" => Ok(Unit::Each),
    175             "kg" | "kilogram" | "kilograms" => Ok(Unit::MassKg),
    176             "g" | "gram" | "grams" => Ok(Unit::MassG),
    177             "oz" | "ounce" | "ounces" => Ok(Unit::MassOz),
    178             "lb" | "pound" | "pounds" => Ok(Unit::MassLb),
    179             "l" | "liter" | "litre" | "liters" | "litres" => Ok(Unit::VolumeL),
    180             "ml" | "milliliter" | "millilitre" | "milliliters" | "millilitres" => {
    181                 Ok(Unit::VolumeMl)
    182             }
    183             _ => Err(ParseError::UnknownUnit),
    184         }
    185     }
    186 }
    187 
    188 #[cfg(feature = "serde")]
    189 impl Serialize for Unit {
    190     fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
    191         ser.serialize_str(self.code())
    192     }
    193 }
    194 
    195 #[cfg(feature = "serde")]
    196 impl<'de> Deserialize<'de> for Unit {
    197     fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
    198         let s = String::deserialize(de)?;
    199         s.parse().map_err(D::Error::custom)
    200     }
    201 }
    202 
    203 #[inline]
    204 pub fn parse_mass_unit(s: &str) -> Result<Unit, ParseError> {
    205     let u: Unit = Unit::from_str(s)?;
    206     if u.is_mass() {
    207         Ok(u)
    208     } else {
    209         Err(ParseError::NotAMassUnit)
    210     }
    211 }
    212 
    213 #[inline]
    214 pub fn parse_volume_unit(s: &str) -> Result<Unit, ParseError> {
    215     let u: Unit = Unit::from_str(s)?;
    216     if u.is_volume() {
    217         Ok(u)
    218     } else {
    219         Err(ParseError::NotAVolumeUnit)
    220     }
    221 }
    222 
    223 #[inline]
    224 /// Converts mass using exact decimal factors expressed in grams.
    225 ///
    226 /// The pound and ounce factors are exact definitions. Arithmetic is checked;
    227 /// division uses the decimal backend's deterministic precision and performs no
    228 /// additional application-level rounding.
    229 pub fn convert_mass_decimal(
    230     amount: Decimal,
    231     from: Unit,
    232     to: Unit,
    233 ) -> Result<Decimal, ConvertError> {
    234     let arithmetic_error = || ConvertError::ArithmeticOverflow { from, to };
    235     let amount_g = match from {
    236         Unit::MassG => amount,
    237         Unit::MassKg => amount
    238             .checked_mul(Decimal::from(1000u32))
    239             .map_err(|_| arithmetic_error())?,
    240         Unit::MassOz => amount
    241             .checked_mul(GRAMS_PER_OUNCE)
    242             .map_err(|_| arithmetic_error())?,
    243         Unit::MassLb => amount
    244             .checked_mul(GRAMS_PER_POUND)
    245             .map_err(|_| arithmetic_error())?,
    246         _ => {
    247             return Err(ConvertError::NotMassUnit { from, to });
    248         }
    249     };
    250 
    251     let to_factor = match to {
    252         Unit::MassG => Decimal::ONE,
    253         Unit::MassKg => Decimal::from(1000u32),
    254         Unit::MassOz => GRAMS_PER_OUNCE,
    255         Unit::MassLb => GRAMS_PER_POUND,
    256         _ => {
    257             return Err(ConvertError::NotMassUnit { from, to });
    258         }
    259     };
    260 
    261     amount_g
    262         .checked_div(to_factor)
    263         .map_err(|_| arithmetic_error())
    264 }
    265 
    266 #[inline]
    267 /// Converts volume using the exact relation `1 L = 1000 mL`.
    268 ///
    269 /// Arithmetic is checked and no application-level rounding is applied.
    270 pub fn convert_volume_decimal(
    271     amount: Decimal,
    272     from: Unit,
    273     to: Unit,
    274 ) -> Result<Decimal, ConvertError> {
    275     let arithmetic_error = || ConvertError::ArithmeticOverflow { from, to };
    276     let amount_ml = match from {
    277         Unit::VolumeMl => amount,
    278         Unit::VolumeL => amount
    279             .checked_mul(Decimal::from(1000u32))
    280             .map_err(|_| arithmetic_error())?,
    281         _ => {
    282             return Err(ConvertError::NotVolumeUnit { from, to });
    283         }
    284     };
    285 
    286     let to_factor = match to {
    287         Unit::VolumeMl => Decimal::ONE,
    288         Unit::VolumeL => Decimal::from(1000u32),
    289         _ => {
    290             return Err(ConvertError::NotVolumeUnit { from, to });
    291         }
    292     };
    293 
    294     amount_ml
    295         .checked_div(to_factor)
    296         .map_err(|_| arithmetic_error())
    297 }
    298 
    299 #[inline]
    300 pub fn convert_unit_decimal(
    301     amount: Decimal,
    302     from: Unit,
    303     to: Unit,
    304 ) -> Result<Decimal, ConvertError> {
    305     if !Unit::same_dimension(from, to) {
    306         return Err(ConvertError::NotConvertibleUnits { from, to });
    307     }
    308     match from.dimension() {
    309         UnitDimension::Count => Ok(amount),
    310         UnitDimension::Mass => convert_mass_decimal(amount, from, to),
    311         UnitDimension::Volume => convert_volume_decimal(amount, from, to),
    312     }
    313 }
    314 
    315 #[cfg(test)]
    316 mod tests {
    317     use super::*;
    318 
    319     #[test]
    320     fn convert_paths_cover_unit_branches() {
    321         assert_eq!(
    322             convert_mass_decimal(Decimal::ONE, Unit::Each, Unit::MassG),
    323             Err(ConvertError::NotMassUnit {
    324                 from: Unit::Each,
    325                 to: Unit::MassG
    326             })
    327         );
    328         assert_eq!(
    329             convert_volume_decimal(Decimal::ONE, Unit::Each, Unit::VolumeMl),
    330             Err(ConvertError::NotVolumeUnit {
    331                 from: Unit::Each,
    332                 to: Unit::VolumeMl
    333             })
    334         );
    335     }
    336 }