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 }