lib

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

quantity.rs (7528B)


      1 //! Non-negative measured quantities and deterministic unit conversion.
      2 
      3 use core::fmt;
      4 
      5 use crate::Decimal;
      6 use crate::unit::{ConvertError, Unit, convert_unit_decimal};
      7 
      8 #[cfg(not(feature = "std"))]
      9 use alloc::string::String;
     10 #[cfg(feature = "std")]
     11 use std::string::String;
     12 
     13 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
     14 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
     15 #[cfg_attr(all(test, feature = "std"), dto(export))]
     16 #[derive(Clone, Debug, PartialEq, Eq)]
     17 pub struct Quantity {
     18     #[cfg_attr(feature = "serde", serde(with = "crate::serde_ext::decimal_str"))]
     19     #[cfg_attr(all(test, feature = "std"), dto(as = "string"))]
     20     amount: Decimal,
     21     unit: Unit,
     22     #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
     23     label: Option<String>,
     24 }
     25 
     26 impl Quantity {
     27     #[inline]
     28     pub fn try_new(amount: Decimal, unit: Unit) -> Result<Self, Error> {
     29         if amount.is_sign_negative() && !amount.is_zero() {
     30             return Err(Error::NegativeAmount);
     31         }
     32         Ok(Self {
     33             amount: if amount.is_zero() {
     34                 Decimal::ZERO
     35             } else {
     36                 amount
     37             },
     38             unit,
     39             label: None,
     40         })
     41     }
     42 
     43     #[inline]
     44     pub const fn amount(&self) -> Decimal {
     45         self.amount
     46     }
     47 
     48     #[inline]
     49     pub const fn unit(&self) -> Unit {
     50         self.unit
     51     }
     52 
     53     #[inline]
     54     pub fn label(&self) -> Option<&str> {
     55         self.label.as_deref()
     56     }
     57 
     58     #[inline]
     59     pub fn with_label<S: Into<String>>(mut self, label: S) -> Self {
     60         self.label = Some(label.into());
     61         self
     62     }
     63 
     64     #[inline]
     65     pub fn with_optional_label<S: Into<String>>(mut self, label: Option<S>) -> Self {
     66         self.label = label.map(|s| s.into());
     67         self
     68     }
     69 
     70     #[inline]
     71     pub fn clear_label(mut self) -> Self {
     72         self.label = None;
     73         self
     74     }
     75 
     76     #[inline]
     77     pub fn zero(unit: Unit) -> Self {
     78         Self {
     79             amount: Decimal::ZERO,
     80             unit,
     81             label: None,
     82         }
     83     }
     84 
     85     #[inline]
     86     pub fn is_zero(&self) -> bool {
     87         self.amount.is_zero()
     88     }
     89 
     90     #[inline]
     91     pub fn is_canonical(&self) -> bool {
     92         self.unit == self.unit.canonical_unit()
     93     }
     94 
     95     #[inline]
     96     pub fn canonical_unit(&self) -> Unit {
     97         self.unit.canonical_unit()
     98     }
     99 
    100     #[inline]
    101     pub fn try_convert_to(&self, unit: Unit) -> Result<Quantity, ConvertError> {
    102         if self.unit == unit {
    103             return Ok(self.clone());
    104         }
    105         let amount = convert_unit_decimal(self.amount, self.unit, unit)?;
    106         Ok(Quantity {
    107             amount,
    108             unit,
    109             label: self.label.clone(),
    110         })
    111     }
    112 
    113     #[inline]
    114     pub fn to_canonical(&self) -> Result<Quantity, ConvertError> {
    115         self.try_convert_to(self.unit.canonical_unit())
    116     }
    117 
    118     #[inline]
    119     pub(crate) fn ensure_non_negative(&self) -> Result<(), Error> {
    120         if self.amount.is_sign_negative() && !self.amount.is_zero() {
    121             return Err(Error::NegativeAmount);
    122         }
    123         Ok(())
    124     }
    125 
    126     #[inline]
    127     pub fn try_add(&self, rhs: &Quantity) -> Result<Quantity, Error> {
    128         if self.unit != rhs.unit {
    129             return Err(Error::UnitMismatch);
    130         }
    131         Ok(Quantity {
    132             amount: self.amount.checked_add(rhs.amount)?,
    133             unit: self.unit,
    134             label: self.label.clone(),
    135         })
    136     }
    137 
    138     #[inline]
    139     pub fn try_sub(&self, rhs: &Quantity) -> Result<Quantity, Error> {
    140         if self.unit != rhs.unit {
    141             return Err(Error::UnitMismatch);
    142         }
    143         let amount = self.amount.checked_sub(rhs.amount)?;
    144         if amount.is_sign_negative() {
    145             return Err(Error::NegativeAmount);
    146         }
    147         Ok(Quantity {
    148             amount,
    149             unit: self.unit,
    150             label: self.label.clone(),
    151         })
    152     }
    153 
    154     pub fn checked_add(&self, rhs: &Quantity) -> Option<Quantity> {
    155         self.try_add(rhs).ok()
    156     }
    157 
    158     pub fn checked_sub(&self, rhs: &Quantity) -> Option<Quantity> {
    159         self.try_sub(rhs).ok()
    160     }
    161 
    162     #[inline]
    163     pub fn checked_mul_decimal(&self, factor: Decimal) -> Result<Quantity, Error> {
    164         let amount = self.amount.checked_mul(factor)?;
    165         if amount.is_sign_negative() {
    166             return Err(Error::NegativeAmount);
    167         }
    168         Ok(Quantity {
    169             amount,
    170             unit: self.unit,
    171             label: self.label.clone(),
    172         })
    173     }
    174 
    175     #[inline]
    176     pub fn checked_div_decimal(&self, divisor: Decimal) -> Result<Quantity, Error> {
    177         let amount = self.amount.checked_div(divisor)?;
    178         if amount.is_sign_negative() {
    179             return Err(Error::NegativeAmount);
    180         }
    181         Ok(Quantity {
    182             amount,
    183             unit: self.unit,
    184             label: self.label.clone(),
    185         })
    186     }
    187 }
    188 
    189 #[cfg(test)]
    190 mod tests {
    191     use super::*;
    192 
    193     fn negative_zero() -> Decimal {
    194         Decimal::from_backend(rust_decimal::Decimal::from_parts(0, 0, 0, true, 0))
    195     }
    196 
    197     #[test]
    198     fn internal_nonnegative_invariant_covers_invalid_and_signed_zero_states() {
    199         let invalid = Quantity {
    200             amount: Decimal::from_backend(rust_decimal::Decimal::from_parts(1, 0, 0, true, 0)),
    201             unit: Unit::Each,
    202             label: None,
    203         };
    204         assert_eq!(invalid.ensure_non_negative(), Err(Error::NegativeAmount));
    205 
    206         let canonical = Quantity::try_new(negative_zero(), Unit::Each).unwrap();
    207         assert_eq!(canonical.amount(), Decimal::ZERO);
    208         assert_eq!(canonical.ensure_non_negative(), Ok(()));
    209     }
    210 }
    211 
    212 #[cfg(feature = "serde")]
    213 impl<'de> serde::Deserialize<'de> for Quantity {
    214     fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
    215         #[derive(serde::Deserialize)]
    216         struct Wire {
    217             #[serde(with = "crate::serde_ext::decimal_str")]
    218             amount: Decimal,
    219             unit: Unit,
    220             #[serde(default)]
    221             label: Option<String>,
    222         }
    223 
    224         let wire = Wire::deserialize(deserializer)?;
    225         Self::try_new(wire.amount, wire.unit)
    226             .map(|quantity| quantity.with_optional_label(wire.label))
    227             .map_err(serde::de::Error::custom)
    228     }
    229 }
    230 
    231 impl fmt::Display for Quantity {
    232     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    233         write!(f, "{} {}", self.amount.normalize(), self.unit)?;
    234         if let Some(label) = &self.label {
    235             write!(f, " ({label})")?;
    236         }
    237         Ok(())
    238     }
    239 }
    240 
    241 #[non_exhaustive]
    242 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    243 pub enum Error {
    244     NegativeAmount,
    245     UnitMismatch,
    246     ArithmeticOverflow,
    247     DivisionByZero,
    248 }
    249 
    250 impl fmt::Display for Error {
    251     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    252         match self {
    253             Error::NegativeAmount => {
    254                 write!(f, "quantity amount must be ≥ 0")
    255             }
    256             Error::UnitMismatch => {
    257                 write!(f, "quantity unit mismatch")
    258             }
    259             Error::ArithmeticOverflow => write!(f, "quantity arithmetic overflow"),
    260             Error::DivisionByZero => write!(f, "quantity division by zero"),
    261         }
    262     }
    263 }
    264 
    265 #[cfg(feature = "std")]
    266 impl std::error::Error for Error {}
    267 
    268 impl From<crate::decimal::Error> for Error {
    269     fn from(error: crate::decimal::Error) -> Self {
    270         match error {
    271             crate::decimal::Error::DivisionByZero => Self::DivisionByZero,
    272             _ => Self::ArithmeticOverflow,
    273         }
    274     }
    275 }