From 351e5e92445d84ed00c1d38f4453e8ed4becdfc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Tue, 21 Apr 2026 16:09:30 +0200 Subject: [PATCH] Add measurement module (v0.9.0) Length, Weight, Temperature, Volume, Area, Speed, Pressure, Energy, Power, Frequency. Each type stores value + unit; no conversions. Temperature validated against absolute zero per unit. Closes #56 Closes #57 Closes #58 Closes #59 Closes #60 Closes #61 Closes #62 Closes #63 Closes #64 Closes #65 --- Cargo.toml | 2 + README.md | 4 +- ROADMAP.md | 24 ++-- docs/measurement.md | 163 +++++++++++++++++++++++++++ src/lib.rs | 3 + src/measurement/area.rs | 149 +++++++++++++++++++++++++ src/measurement/energy.rs | 147 +++++++++++++++++++++++++ src/measurement/frequency.rs | 146 ++++++++++++++++++++++++ src/measurement/length.rs | 164 +++++++++++++++++++++++++++ src/measurement/mod.rs | 21 ++++ src/measurement/power.rs | 143 ++++++++++++++++++++++++ src/measurement/pressure.rs | 150 +++++++++++++++++++++++++ src/measurement/speed.rs | 143 ++++++++++++++++++++++++ src/measurement/temperature.rs | 195 +++++++++++++++++++++++++++++++++ src/measurement/volume.rs | 145 ++++++++++++++++++++++++ src/measurement/weight.rs | 149 +++++++++++++++++++++++++ 16 files changed, 1735 insertions(+), 13 deletions(-) create mode 100644 docs/measurement.md create mode 100644 src/measurement/area.rs create mode 100644 src/measurement/energy.rs create mode 100644 src/measurement/frequency.rs create mode 100644 src/measurement/length.rs create mode 100644 src/measurement/mod.rs create mode 100644 src/measurement/power.rs create mode 100644 src/measurement/pressure.rs create mode 100644 src/measurement/speed.rs create mode 100644 src/measurement/temperature.rs create mode 100644 src/measurement/volume.rs create mode 100644 src/measurement/weight.rs diff --git a/Cargo.toml b/Cargo.toml index 7cf9a81..0b27881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ default = [] contact = ["dep:once_cell", "dep:regex", "dep:url"] finance = ["dep:rust_decimal", "dep:chrono"] geo = [] +measurement = [] net = ["dep:url"] identifiers = [] primitives = ["dep:rust_decimal", "dep:base64"] @@ -36,6 +37,7 @@ full = [ "finance", "geo", "identifiers", + "measurement", "net", "primitives", "temporal", diff --git a/README.md b/README.md index 8ac8f05..9a4535a 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ let email: EmailAddress = "user@example.com".try_into()?; | [docs/contact.md](docs/contact.md) | Reference for all `contact` module types | | [docs/finance.md](docs/finance.md) | Reference for all `finance` module types | | [docs/geo.md](docs/geo.md) | Reference for all `geo` module types | +| [docs/measurement.md](docs/measurement.md) | Reference for all `measurement` module types | | [docs/net.md](docs/net.md) | Reference for all `net` module types | | [docs/identifiers.md](docs/identifiers.md) | Reference for all `identifiers` module types | | [docs/primitives.md](docs/primitives.md) | Reference for all `primitives` module types | @@ -72,6 +73,7 @@ Enable only the modules you need — unused features add zero dependencies. | `contact` | `EmailAddress`, `CountryCode`, `PhoneNumber`, `PostalAddress`, `Website` | `once_cell`, `regex`, `url` | | `finance` | `Money`, `CurrencyCode`, `Iban`, `Bic`, `VatNumber`, `Percentage`, `ExchangeRate`, `CreditCardNumber`, `CardExpiryDate` | `rust_decimal`, `chrono` | | `geo` | `Latitude`, `Longitude`, `Coordinate`, `BoundingBox`, `TimeZone`, `CountryRegion` | — | +| `measurement` | `Length`, `Weight`, `Temperature`, `Volume`, `Area`, `Speed`, `Pressure`, `Energy`, `Power`, `Frequency` | — | | `net` | `Url`, `Domain`, `IpV4Address`, `IpV6Address`, `IpAddress`, `Port`, `MacAddress`, `MimeType`, `HttpStatusCode`, `ApiKey` | `url` | | `identifiers` | `Slug`, `Ean13`, `Ean8`, `Isbn13`, `Isbn10`, `Issn`, `Vin` | — | | `primitives` | `NonEmptyString`, `BoundedString`, `PositiveInt`, `NonNegativeInt`, `PositiveDecimal`, `NonNegativeDecimal`, `Probability`, `HexColor`, `Locale`, `Base64String` | `rust_decimal`, `base64` | @@ -213,7 +215,7 @@ let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?; | `temporal` | `UnixTimestamp`, `BirthDate`, `ExpiryDate`, `TimeRange`, `BusinessHours` | 5 | 5 / 5 ✅ | | `geo` | `Latitude`, `Longitude`, `Coordinate`, `BoundingBox`, `TimeZone`, `CountryRegion` | 6 | 6 / 6 ✅ | | `net` | `Url`, `Domain`, `IpV4Address`, `IpV6Address`, `IpAddress`, `Port`, `MacAddress`, `MimeType`, `HttpStatusCode`, `ApiKey` | 10 | 10 / 10 ✅ | -| `measurement` | `Length`, `Weight`, `Temperature`, `Speed` ⚠️ needs unit conversion design | 10 | 0 / 10 | +| `measurement` | `Length`, `Weight`, `Temperature`, `Volume`, `Area`, `Speed`, `Pressure`, `Energy`, `Power`, `Frequency` | 10 | 10 / 10 ✅ | | `primitives` | `NonEmptyString`, `BoundedString`, `Locale`, `HexColor` | 10 | 10 / 10 ✅ | → Full details and design rationale in [ROADMAP.md](ROADMAP.md) diff --git a/ROADMAP.md b/ROADMAP.md index a1099a5..a47d690 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -100,16 +100,16 @@ | Type | Status | Notes | |---|---|---| -| `Length` | ⬜ | non-negative `f64` with unit (m, cm, mm, in, ft) | -| `Weight` | ⬜ | non-negative `f64` with unit (kg, g, lb, oz) | -| `Temperature` | ⬜ | `f64` with unit (°C, °F, K); Kelvin must be ≥ 0 | -| `Volume` | ⬜ | non-negative `f64` with unit (l, ml, m³, fl oz) | -| `Area` | ⬜ | non-negative `f64` with unit (m², cm², ft²) | -| `Speed` | ⬜ | non-negative `f64` with unit (m/s, km/h, mph) | -| `Pressure` | ⬜ | non-negative `f64` with unit (Pa, bar, psi) | -| `Energy` | ⬜ | non-negative `f64` with unit (J, kWh, cal) | -| `Power` | ⬜ | non-negative `f64` with unit (W, kW, hp) | -| `Frequency` | ⬜ | positive `f64` with unit (Hz, kHz, MHz) | +| `Length` | ✅ | non-negative `f64` with unit (mm, cm, m, km, in, ft) | +| `Weight` | ✅ | non-negative `f64` with unit (mg, g, kg, t, oz, lb) | +| `Temperature` | ✅ | `f64` with unit (°C, °F, K); validated against absolute zero | +| `Volume` | ✅ | non-negative `f64` with unit (ml, l, m³, fl oz, gal) | +| `Area` | ✅ | non-negative `f64` with unit (mm², cm², m², km², in², ft², ha) | +| `Speed` | ✅ | non-negative `f64` with unit (m/s, km/h, mph, kn) | +| `Pressure` | ✅ | non-negative `f64` with unit (Pa, kPa, MPa, bar, psi, atm) | +| `Energy` | ✅ | non-negative `f64` with unit (J, kJ, MJ, kWh, cal, kcal) | +| `Power` | ✅ | non-negative `f64` with unit (W, kW, MW, hp) | +| `Frequency` | ✅ | positive `f64` with unit (Hz, kHz, MHz, GHz) | --- @@ -140,6 +140,6 @@ | `temporal` | 5 | 5 | 0 | | `geo` | 6 | 6 | 0 | | `net` | 10 | 10 | 0 | -| `measurement` | 10 | 0 | 10 | +| `measurement` | 10 | 10 | 0 | | `primitives` | 10 | 10 | 0 | -| **Total** | **62** | **52** | **10** | +| **Total** | **62** | **62** | **0** | diff --git a/docs/measurement.md b/docs/measurement.md new file mode 100644 index 0000000..e3f4ac6 --- /dev/null +++ b/docs/measurement.md @@ -0,0 +1,163 @@ +# measurement module + +Feature flag: `measurement` + +```toml +[dependencies] +arvo = { version = "0.9", features = ["measurement"] } +``` + +All measurement types share the same pattern: `XxxInput { value: f64, unit: XxxUnit }`. +`value()` returns a canonical string like `"75 kg"`. No unit conversion is provided — the unit is metadata. + +--- + +## Length + +**Validation:** finite, non-negative. **Units:** `Mm`, `Cm`, `M`, `Km`, `In`, `Ft`. + +```rust,ignore +use arvo::measurement::{Length, LengthInput, LengthUnit}; +use arvo::traits::ValueObject; + +let len = Length::new(LengthInput { value: 1.80, unit: LengthUnit::M })?; +assert_eq!(len.value(), "1.8 m"); +assert_eq!(len.amount(), 1.80); +``` + +| Method | Returns | +|---|---| +| `value()` | `&str` — e.g. `"1.8 m"` | +| `amount()` | `f64` | +| `unit()` | `&LengthUnit` | + +--- + +## Weight + +**Validation:** finite, non-negative. **Units:** `Mg`, `G`, `Kg`, `T`, `Oz`, `Lb`. + +```rust,ignore +use arvo::measurement::{Weight, WeightInput, WeightUnit}; +use arvo::traits::ValueObject; + +let w = Weight::new(WeightInput { value: 75.0, unit: WeightUnit::Kg })?; +assert_eq!(w.value(), "75 kg"); +``` + +--- + +## Temperature + +**Validation:** finite; minimum depends on unit — Kelvin ≥ 0, Celsius ≥ −273.15, Fahrenheit ≥ −459.67. +**Units:** `Celsius`, `Fahrenheit`, `Kelvin`. + +```rust,ignore +use arvo::measurement::{Temperature, TemperatureInput, TemperatureUnit}; +use arvo::traits::ValueObject; + +let t = Temperature::new(TemperatureInput { value: 100.0, unit: TemperatureUnit::Celsius })?; +assert_eq!(t.value(), "100 °C"); + +assert!(Temperature::new(TemperatureInput { value: -274.0, unit: TemperatureUnit::Celsius }).is_err()); +``` + +--- + +## Volume + +**Validation:** finite, non-negative. **Units:** `Ml`, `L`, `M3`, `FlOz`, `Gal`. + +```rust,ignore +use arvo::measurement::{Volume, VolumeInput, VolumeUnit}; +use arvo::traits::ValueObject; + +let v = Volume::new(VolumeInput { value: 1.5, unit: VolumeUnit::L })?; +assert_eq!(v.value(), "1.5 l"); +``` + +--- + +## Area + +**Validation:** finite, non-negative. **Units:** `Mm2`, `Cm2`, `M2`, `Km2`, `In2`, `Ft2`, `Ha`. + +```rust,ignore +use arvo::measurement::{Area, AreaInput, AreaUnit}; +use arvo::traits::ValueObject; + +let a = Area::new(AreaInput { value: 50.0, unit: AreaUnit::M2 })?; +assert_eq!(a.value(), "50 m²"); +``` + +--- + +## Speed + +**Validation:** finite, non-negative. **Units:** `Ms` (m/s), `Kmh` (km/h), `Mph`, `Kn` (knots). + +```rust,ignore +use arvo::measurement::{Speed, SpeedInput, SpeedUnit}; +use arvo::traits::ValueObject; + +let s = Speed::new(SpeedInput { value: 120.0, unit: SpeedUnit::Kmh })?; +assert_eq!(s.value(), "120 km/h"); +``` + +--- + +## Pressure + +**Validation:** finite, non-negative. **Units:** `Pa`, `KPa`, `MPa`, `Bar`, `Psi`, `Atm`. + +```rust,ignore +use arvo::measurement::{Pressure, PressureInput, PressureUnit}; +use arvo::traits::ValueObject; + +let p = Pressure::new(PressureInput { value: 101.325, unit: PressureUnit::KPa })?; +assert_eq!(p.value(), "101.325 kPa"); +``` + +--- + +## Energy + +**Validation:** finite, non-negative. **Units:** `J`, `KJ`, `MJ`, `KWh`, `Cal`, `Kcal`. + +```rust,ignore +use arvo::measurement::{Energy, EnergyInput, EnergyUnit}; +use arvo::traits::ValueObject; + +let e = Energy::new(EnergyInput { value: 500.0, unit: EnergyUnit::Kcal })?; +assert_eq!(e.value(), "500 kcal"); +``` + +--- + +## Power + +**Validation:** finite, non-negative. **Units:** `W`, `KW`, `MW`, `Hp`. + +```rust,ignore +use arvo::measurement::{Power, PowerInput, PowerUnit}; +use arvo::traits::ValueObject; + +let p = Power::new(PowerInput { value: 3.7, unit: PowerUnit::KW })?; +assert_eq!(p.value(), "3.7 kW"); +``` + +--- + +## Frequency + +**Validation:** finite, strictly positive (> 0). **Units:** `Hz`, `KHz`, `MHz`, `GHz`. + +```rust,ignore +use arvo::measurement::{Frequency, FrequencyInput, FrequencyUnit}; +use arvo::traits::ValueObject; + +let f = Frequency::new(FrequencyInput { value: 2.4, unit: FrequencyUnit::GHz })?; +assert_eq!(f.value(), "2.4 GHz"); + +assert!(Frequency::new(FrequencyInput { value: 0.0, unit: FrequencyUnit::Hz }).is_err()); +``` diff --git a/src/lib.rs b/src/lib.rs index 656fe77..2b76996 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,6 +50,9 @@ pub mod finance; #[cfg(feature = "geo")] pub mod geo; +#[cfg(feature = "measurement")] +pub mod measurement; + #[cfg(feature = "net")] pub mod net; diff --git a/src/measurement/area.rs b/src/measurement/area.rs new file mode 100644 index 0000000..03edf4d --- /dev/null +++ b/src/measurement/area.rs @@ -0,0 +1,149 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Unit of area. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum AreaUnit { + Mm2, + Cm2, + M2, + Km2, + In2, + Ft2, + Ha, +} + +impl std::fmt::Display for AreaUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AreaUnit::Mm2 => write!(f, "mm²"), + AreaUnit::Cm2 => write!(f, "cm²"), + AreaUnit::M2 => write!(f, "m²"), + AreaUnit::Km2 => write!(f, "km²"), + AreaUnit::In2 => write!(f, "in²"), + AreaUnit::Ft2 => write!(f, "ft²"), + AreaUnit::Ha => write!(f, "ha"), + } + } +} + +/// Input for [`Area`]. +#[derive(Debug, Clone, PartialEq)] +pub struct AreaInput { + pub value: f64, + pub unit: AreaUnit, +} + +/// A validated area measurement. +/// +/// **Validation:** value must be finite and non-negative. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::measurement::{Area, AreaInput, AreaUnit}; +/// use arvo::traits::ValueObject; +/// +/// let a = Area::new(AreaInput { value: 50.0, unit: AreaUnit::M2 })?; +/// assert_eq!(a.value(), "50 m²"); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Area { + value: f64, + unit: AreaUnit, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Area { + type Input = AreaInput; + type Output = str; + type Error = ValidationError; + + fn new(input: Self::Input) -> Result { + if !input.value.is_finite() || input.value < 0.0 { + return Err(ValidationError::invalid("Area", &input.value.to_string())); + } + let canonical = format!("{} {}", input.value, input.unit); + Ok(Self { + value: input.value, + unit: input.unit, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + fn into_inner(self) -> Self::Input { + AreaInput { + value: self.value, + unit: self.unit, + } + } +} + +impl Area { + pub fn amount(&self) -> f64 { + self.value + } + pub fn unit(&self) -> &AreaUnit { + &self.unit + } +} + +impl std::fmt::Display for Area { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid() { + let a = Area::new(AreaInput { + value: 50.0, + unit: AreaUnit::M2, + }) + .unwrap(); + assert_eq!(a.value(), "50 m²"); + } + + #[test] + fn accepts_zero() { + assert!( + Area::new(AreaInput { + value: 0.0, + unit: AreaUnit::M2 + }) + .is_ok() + ); + } + + #[test] + fn rejects_negative() { + assert!( + Area::new(AreaInput { + value: -1.0, + unit: AreaUnit::M2 + }) + .is_err() + ); + } + + #[test] + fn rejects_nan() { + assert!( + Area::new(AreaInput { + value: f64::NAN, + unit: AreaUnit::M2 + }) + .is_err() + ); + } +} diff --git a/src/measurement/energy.rs b/src/measurement/energy.rs new file mode 100644 index 0000000..79141ea --- /dev/null +++ b/src/measurement/energy.rs @@ -0,0 +1,147 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Unit of energy. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum EnergyUnit { + J, + KJ, + MJ, + KWh, + Cal, + Kcal, +} + +impl std::fmt::Display for EnergyUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EnergyUnit::J => write!(f, "J"), + EnergyUnit::KJ => write!(f, "kJ"), + EnergyUnit::MJ => write!(f, "MJ"), + EnergyUnit::KWh => write!(f, "kWh"), + EnergyUnit::Cal => write!(f, "cal"), + EnergyUnit::Kcal => write!(f, "kcal"), + } + } +} + +/// Input for [`Energy`]. +#[derive(Debug, Clone, PartialEq)] +pub struct EnergyInput { + pub value: f64, + pub unit: EnergyUnit, +} + +/// A validated energy measurement. +/// +/// **Validation:** value must be finite and non-negative. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::measurement::{Energy, EnergyInput, EnergyUnit}; +/// use arvo::traits::ValueObject; +/// +/// let e = Energy::new(EnergyInput { value: 500.0, unit: EnergyUnit::Kcal })?; +/// assert_eq!(e.value(), "500 kcal"); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Energy { + value: f64, + unit: EnergyUnit, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Energy { + type Input = EnergyInput; + type Output = str; + type Error = ValidationError; + + fn new(input: Self::Input) -> Result { + if !input.value.is_finite() || input.value < 0.0 { + return Err(ValidationError::invalid("Energy", &input.value.to_string())); + } + let canonical = format!("{} {}", input.value, input.unit); + Ok(Self { + value: input.value, + unit: input.unit, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + fn into_inner(self) -> Self::Input { + EnergyInput { + value: self.value, + unit: self.unit, + } + } +} + +impl Energy { + pub fn amount(&self) -> f64 { + self.value + } + pub fn unit(&self) -> &EnergyUnit { + &self.unit + } +} + +impl std::fmt::Display for Energy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid() { + let e = Energy::new(EnergyInput { + value: 500.0, + unit: EnergyUnit::Kcal, + }) + .unwrap(); + assert_eq!(e.value(), "500 kcal"); + } + + #[test] + fn accepts_zero() { + assert!( + Energy::new(EnergyInput { + value: 0.0, + unit: EnergyUnit::J + }) + .is_ok() + ); + } + + #[test] + fn rejects_negative() { + assert!( + Energy::new(EnergyInput { + value: -1.0, + unit: EnergyUnit::J + }) + .is_err() + ); + } + + #[test] + fn rejects_nan() { + assert!( + Energy::new(EnergyInput { + value: f64::NAN, + unit: EnergyUnit::J + }) + .is_err() + ); + } +} diff --git a/src/measurement/frequency.rs b/src/measurement/frequency.rs new file mode 100644 index 0000000..a791d19 --- /dev/null +++ b/src/measurement/frequency.rs @@ -0,0 +1,146 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Unit of frequency. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FrequencyUnit { + Hz, + KHz, + MHz, + GHz, +} + +impl std::fmt::Display for FrequencyUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrequencyUnit::Hz => write!(f, "Hz"), + FrequencyUnit::KHz => write!(f, "kHz"), + FrequencyUnit::MHz => write!(f, "MHz"), + FrequencyUnit::GHz => write!(f, "GHz"), + } + } +} + +/// Input for [`Frequency`]. +#[derive(Debug, Clone, PartialEq)] +pub struct FrequencyInput { + pub value: f64, + pub unit: FrequencyUnit, +} + +/// A validated frequency measurement. +/// +/// **Validation:** value must be finite and strictly positive (> 0). +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::measurement::{Frequency, FrequencyInput, FrequencyUnit}; +/// use arvo::traits::ValueObject; +/// +/// let f = Frequency::new(FrequencyInput { value: 2.4, unit: FrequencyUnit::GHz })?; +/// assert_eq!(f.value(), "2.4 GHz"); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Frequency { + value: f64, + unit: FrequencyUnit, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Frequency { + type Input = FrequencyInput; + type Output = str; + type Error = ValidationError; + + fn new(input: Self::Input) -> Result { + if !input.value.is_finite() || input.value <= 0.0 { + return Err(ValidationError::invalid( + "Frequency", + &input.value.to_string(), + )); + } + let canonical = format!("{} {}", input.value, input.unit); + Ok(Self { + value: input.value, + unit: input.unit, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + fn into_inner(self) -> Self::Input { + FrequencyInput { + value: self.value, + unit: self.unit, + } + } +} + +impl Frequency { + pub fn amount(&self) -> f64 { + self.value + } + pub fn unit(&self) -> &FrequencyUnit { + &self.unit + } +} + +impl std::fmt::Display for Frequency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid() { + let f = Frequency::new(FrequencyInput { + value: 2.4, + unit: FrequencyUnit::GHz, + }) + .unwrap(); + assert_eq!(f.value(), "2.4 GHz"); + } + + #[test] + fn rejects_zero() { + assert!( + Frequency::new(FrequencyInput { + value: 0.0, + unit: FrequencyUnit::Hz + }) + .is_err() + ); + } + + #[test] + fn rejects_negative() { + assert!( + Frequency::new(FrequencyInput { + value: -1.0, + unit: FrequencyUnit::Hz + }) + .is_err() + ); + } + + #[test] + fn rejects_nan() { + assert!( + Frequency::new(FrequencyInput { + value: f64::NAN, + unit: FrequencyUnit::Hz + }) + .is_err() + ); + } +} diff --git a/src/measurement/length.rs b/src/measurement/length.rs new file mode 100644 index 0000000..b230bf1 --- /dev/null +++ b/src/measurement/length.rs @@ -0,0 +1,164 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Unit of length. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum LengthUnit { + Mm, + Cm, + M, + Km, + In, + Ft, +} + +impl std::fmt::Display for LengthUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LengthUnit::Mm => write!(f, "mm"), + LengthUnit::Cm => write!(f, "cm"), + LengthUnit::M => write!(f, "m"), + LengthUnit::Km => write!(f, "km"), + LengthUnit::In => write!(f, "in"), + LengthUnit::Ft => write!(f, "ft"), + } + } +} + +/// Input for [`Length`]. +#[derive(Debug, Clone, PartialEq)] +pub struct LengthInput { + pub value: f64, + pub unit: LengthUnit, +} + +/// A validated length measurement. +/// +/// **Validation:** value must be finite and non-negative. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::measurement::{Length, LengthInput, LengthUnit}; +/// use arvo::traits::ValueObject; +/// +/// let len = Length::new(LengthInput { value: 1.80, unit: LengthUnit::M })?; +/// assert_eq!(len.value(), "1.80 m"); +/// assert_eq!(len.amount(), 1.80); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Length { + value: f64, + unit: LengthUnit, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Length { + type Input = LengthInput; + type Output = str; + type Error = ValidationError; + + fn new(input: Self::Input) -> Result { + if !input.value.is_finite() || input.value < 0.0 { + return Err(ValidationError::invalid("Length", &input.value.to_string())); + } + let canonical = format!("{} {}", input.value, input.unit); + Ok(Self { + value: input.value, + unit: input.unit, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + + fn into_inner(self) -> Self::Input { + LengthInput { + value: self.value, + unit: self.unit, + } + } +} + +impl Length { + pub fn amount(&self) -> f64 { + self.value + } + pub fn unit(&self) -> &LengthUnit { + &self.unit + } +} + +impl std::fmt::Display for Length { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid() { + let l = Length::new(LengthInput { + value: 1.80, + unit: LengthUnit::M, + }) + .unwrap(); + assert_eq!(l.value(), "1.8 m"); + assert_eq!(l.amount(), 1.80); + } + + #[test] + fn accepts_zero() { + assert!( + Length::new(LengthInput { + value: 0.0, + unit: LengthUnit::Cm + }) + .is_ok() + ); + } + + #[test] + fn rejects_negative() { + assert!( + Length::new(LengthInput { + value: -1.0, + unit: LengthUnit::M + }) + .is_err() + ); + } + + #[test] + fn rejects_nan() { + assert!( + Length::new(LengthInput { + value: f64::NAN, + unit: LengthUnit::M + }) + .is_err() + ); + } + + #[test] + fn all_units_display() { + for unit in [ + LengthUnit::Mm, + LengthUnit::Cm, + LengthUnit::M, + LengthUnit::Km, + LengthUnit::In, + LengthUnit::Ft, + ] { + assert!(Length::new(LengthInput { value: 1.0, unit }).is_ok()); + } + } +} diff --git a/src/measurement/mod.rs b/src/measurement/mod.rs new file mode 100644 index 0000000..ade3d27 --- /dev/null +++ b/src/measurement/mod.rs @@ -0,0 +1,21 @@ +mod area; +mod energy; +mod frequency; +mod length; +mod power; +mod pressure; +mod speed; +mod temperature; +mod volume; +mod weight; + +pub use area::{Area, AreaInput, AreaUnit}; +pub use energy::{Energy, EnergyInput, EnergyUnit}; +pub use frequency::{Frequency, FrequencyInput, FrequencyUnit}; +pub use length::{Length, LengthInput, LengthUnit}; +pub use power::{Power, PowerInput, PowerUnit}; +pub use pressure::{Pressure, PressureInput, PressureUnit}; +pub use speed::{Speed, SpeedInput, SpeedUnit}; +pub use temperature::{Temperature, TemperatureInput, TemperatureUnit}; +pub use volume::{Volume, VolumeInput, VolumeUnit}; +pub use weight::{Weight, WeightInput, WeightUnit}; diff --git a/src/measurement/power.rs b/src/measurement/power.rs new file mode 100644 index 0000000..aea0305 --- /dev/null +++ b/src/measurement/power.rs @@ -0,0 +1,143 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Unit of power. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum PowerUnit { + W, + KW, + MW, + Hp, +} + +impl std::fmt::Display for PowerUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PowerUnit::W => write!(f, "W"), + PowerUnit::KW => write!(f, "kW"), + PowerUnit::MW => write!(f, "MW"), + PowerUnit::Hp => write!(f, "hp"), + } + } +} + +/// Input for [`Power`]. +#[derive(Debug, Clone, PartialEq)] +pub struct PowerInput { + pub value: f64, + pub unit: PowerUnit, +} + +/// A validated power measurement. +/// +/// **Validation:** value must be finite and non-negative. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::measurement::{Power, PowerInput, PowerUnit}; +/// use arvo::traits::ValueObject; +/// +/// let p = Power::new(PowerInput { value: 3.7, unit: PowerUnit::KW })?; +/// assert_eq!(p.value(), "3.7 kW"); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Power { + value: f64, + unit: PowerUnit, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Power { + type Input = PowerInput; + type Output = str; + type Error = ValidationError; + + fn new(input: Self::Input) -> Result { + if !input.value.is_finite() || input.value < 0.0 { + return Err(ValidationError::invalid("Power", &input.value.to_string())); + } + let canonical = format!("{} {}", input.value, input.unit); + Ok(Self { + value: input.value, + unit: input.unit, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + fn into_inner(self) -> Self::Input { + PowerInput { + value: self.value, + unit: self.unit, + } + } +} + +impl Power { + pub fn amount(&self) -> f64 { + self.value + } + pub fn unit(&self) -> &PowerUnit { + &self.unit + } +} + +impl std::fmt::Display for Power { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid() { + let p = Power::new(PowerInput { + value: 3.7, + unit: PowerUnit::KW, + }) + .unwrap(); + assert_eq!(p.value(), "3.7 kW"); + } + + #[test] + fn accepts_zero() { + assert!( + Power::new(PowerInput { + value: 0.0, + unit: PowerUnit::W + }) + .is_ok() + ); + } + + #[test] + fn rejects_negative() { + assert!( + Power::new(PowerInput { + value: -1.0, + unit: PowerUnit::W + }) + .is_err() + ); + } + + #[test] + fn rejects_nan() { + assert!( + Power::new(PowerInput { + value: f64::NAN, + unit: PowerUnit::W + }) + .is_err() + ); + } +} diff --git a/src/measurement/pressure.rs b/src/measurement/pressure.rs new file mode 100644 index 0000000..d883061 --- /dev/null +++ b/src/measurement/pressure.rs @@ -0,0 +1,150 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Unit of pressure. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum PressureUnit { + Pa, + KPa, + MPa, + Bar, + Psi, + Atm, +} + +impl std::fmt::Display for PressureUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PressureUnit::Pa => write!(f, "Pa"), + PressureUnit::KPa => write!(f, "kPa"), + PressureUnit::MPa => write!(f, "MPa"), + PressureUnit::Bar => write!(f, "bar"), + PressureUnit::Psi => write!(f, "psi"), + PressureUnit::Atm => write!(f, "atm"), + } + } +} + +/// Input for [`Pressure`]. +#[derive(Debug, Clone, PartialEq)] +pub struct PressureInput { + pub value: f64, + pub unit: PressureUnit, +} + +/// A validated pressure measurement. +/// +/// **Validation:** value must be finite and non-negative. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::measurement::{Pressure, PressureInput, PressureUnit}; +/// use arvo::traits::ValueObject; +/// +/// let p = Pressure::new(PressureInput { value: 101.325, unit: PressureUnit::KPa })?; +/// assert_eq!(p.value(), "101.325 kPa"); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Pressure { + value: f64, + unit: PressureUnit, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Pressure { + type Input = PressureInput; + type Output = str; + type Error = ValidationError; + + fn new(input: Self::Input) -> Result { + if !input.value.is_finite() || input.value < 0.0 { + return Err(ValidationError::invalid( + "Pressure", + &input.value.to_string(), + )); + } + let canonical = format!("{} {}", input.value, input.unit); + Ok(Self { + value: input.value, + unit: input.unit, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + fn into_inner(self) -> Self::Input { + PressureInput { + value: self.value, + unit: self.unit, + } + } +} + +impl Pressure { + pub fn amount(&self) -> f64 { + self.value + } + pub fn unit(&self) -> &PressureUnit { + &self.unit + } +} + +impl std::fmt::Display for Pressure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid() { + let p = Pressure::new(PressureInput { + value: 101.325, + unit: PressureUnit::KPa, + }) + .unwrap(); + assert_eq!(p.value(), "101.325 kPa"); + } + + #[test] + fn accepts_zero() { + assert!( + Pressure::new(PressureInput { + value: 0.0, + unit: PressureUnit::Pa + }) + .is_ok() + ); + } + + #[test] + fn rejects_negative() { + assert!( + Pressure::new(PressureInput { + value: -1.0, + unit: PressureUnit::Pa + }) + .is_err() + ); + } + + #[test] + fn rejects_nan() { + assert!( + Pressure::new(PressureInput { + value: f64::NAN, + unit: PressureUnit::Pa + }) + .is_err() + ); + } +} diff --git a/src/measurement/speed.rs b/src/measurement/speed.rs new file mode 100644 index 0000000..1d38f33 --- /dev/null +++ b/src/measurement/speed.rs @@ -0,0 +1,143 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Unit of speed. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum SpeedUnit { + Ms, + Kmh, + Mph, + Kn, +} + +impl std::fmt::Display for SpeedUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SpeedUnit::Ms => write!(f, "m/s"), + SpeedUnit::Kmh => write!(f, "km/h"), + SpeedUnit::Mph => write!(f, "mph"), + SpeedUnit::Kn => write!(f, "kn"), + } + } +} + +/// Input for [`Speed`]. +#[derive(Debug, Clone, PartialEq)] +pub struct SpeedInput { + pub value: f64, + pub unit: SpeedUnit, +} + +/// A validated speed measurement. +/// +/// **Validation:** value must be finite and non-negative. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::measurement::{Speed, SpeedInput, SpeedUnit}; +/// use arvo::traits::ValueObject; +/// +/// let s = Speed::new(SpeedInput { value: 120.0, unit: SpeedUnit::Kmh })?; +/// assert_eq!(s.value(), "120 km/h"); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Speed { + value: f64, + unit: SpeedUnit, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Speed { + type Input = SpeedInput; + type Output = str; + type Error = ValidationError; + + fn new(input: Self::Input) -> Result { + if !input.value.is_finite() || input.value < 0.0 { + return Err(ValidationError::invalid("Speed", &input.value.to_string())); + } + let canonical = format!("{} {}", input.value, input.unit); + Ok(Self { + value: input.value, + unit: input.unit, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + fn into_inner(self) -> Self::Input { + SpeedInput { + value: self.value, + unit: self.unit, + } + } +} + +impl Speed { + pub fn amount(&self) -> f64 { + self.value + } + pub fn unit(&self) -> &SpeedUnit { + &self.unit + } +} + +impl std::fmt::Display for Speed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid() { + let s = Speed::new(SpeedInput { + value: 120.0, + unit: SpeedUnit::Kmh, + }) + .unwrap(); + assert_eq!(s.value(), "120 km/h"); + } + + #[test] + fn accepts_zero() { + assert!( + Speed::new(SpeedInput { + value: 0.0, + unit: SpeedUnit::Ms + }) + .is_ok() + ); + } + + #[test] + fn rejects_negative() { + assert!( + Speed::new(SpeedInput { + value: -1.0, + unit: SpeedUnit::Ms + }) + .is_err() + ); + } + + #[test] + fn rejects_nan() { + assert!( + Speed::new(SpeedInput { + value: f64::NAN, + unit: SpeedUnit::Ms + }) + .is_err() + ); + } +} diff --git a/src/measurement/temperature.rs b/src/measurement/temperature.rs new file mode 100644 index 0000000..078e2b1 --- /dev/null +++ b/src/measurement/temperature.rs @@ -0,0 +1,195 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Unit of temperature. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum TemperatureUnit { + Celsius, + Fahrenheit, + Kelvin, +} + +impl std::fmt::Display for TemperatureUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TemperatureUnit::Celsius => write!(f, "°C"), + TemperatureUnit::Fahrenheit => write!(f, "°F"), + TemperatureUnit::Kelvin => write!(f, "K"), + } + } +} + +/// Input for [`Temperature`]. +#[derive(Debug, Clone, PartialEq)] +pub struct TemperatureInput { + pub value: f64, + pub unit: TemperatureUnit, +} + +/// A validated temperature measurement. +/// +/// **Validation:** value must be finite and above absolute zero for the given unit: +/// - Kelvin: `>= 0.0` +/// - Celsius: `>= -273.15` +/// - Fahrenheit: `>= -459.67` +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::measurement::{Temperature, TemperatureInput, TemperatureUnit}; +/// use arvo::traits::ValueObject; +/// +/// let t = Temperature::new(TemperatureInput { value: 100.0, unit: TemperatureUnit::Celsius })?; +/// assert_eq!(t.value(), "100 °C"); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Temperature { + value: f64, + unit: TemperatureUnit, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Temperature { + type Input = TemperatureInput; + type Output = str; + type Error = ValidationError; + + fn new(input: Self::Input) -> Result { + if !input.value.is_finite() { + return Err(ValidationError::invalid( + "Temperature", + &input.value.to_string(), + )); + } + + let min = match input.unit { + TemperatureUnit::Kelvin => 0.0, + TemperatureUnit::Celsius => -273.15, + TemperatureUnit::Fahrenheit => -459.67, + }; + + if input.value < min { + return Err(ValidationError::invalid( + "Temperature", + &input.value.to_string(), + )); + } + + let canonical = format!("{} {}", input.value, input.unit); + Ok(Self { + value: input.value, + unit: input.unit, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + + fn into_inner(self) -> Self::Input { + TemperatureInput { + value: self.value, + unit: self.unit, + } + } +} + +impl Temperature { + pub fn amount(&self) -> f64 { + self.value + } + pub fn unit(&self) -> &TemperatureUnit { + &self.unit + } +} + +impl std::fmt::Display for Temperature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_celsius() { + let t = Temperature::new(TemperatureInput { + value: 100.0, + unit: TemperatureUnit::Celsius, + }) + .unwrap(); + assert_eq!(t.value(), "100 °C"); + } + + #[test] + fn accepts_absolute_zero_kelvin() { + assert!( + Temperature::new(TemperatureInput { + value: 0.0, + unit: TemperatureUnit::Kelvin + }) + .is_ok() + ); + } + + #[test] + fn accepts_absolute_zero_celsius() { + assert!( + Temperature::new(TemperatureInput { + value: -273.15, + unit: TemperatureUnit::Celsius + }) + .is_ok() + ); + } + + #[test] + fn rejects_below_absolute_zero_kelvin() { + assert!( + Temperature::new(TemperatureInput { + value: -0.01, + unit: TemperatureUnit::Kelvin + }) + .is_err() + ); + } + + #[test] + fn rejects_below_absolute_zero_celsius() { + assert!( + Temperature::new(TemperatureInput { + value: -273.16, + unit: TemperatureUnit::Celsius + }) + .is_err() + ); + } + + #[test] + fn rejects_below_absolute_zero_fahrenheit() { + assert!( + Temperature::new(TemperatureInput { + value: -459.68, + unit: TemperatureUnit::Fahrenheit + }) + .is_err() + ); + } + + #[test] + fn rejects_nan() { + assert!( + Temperature::new(TemperatureInput { + value: f64::NAN, + unit: TemperatureUnit::Celsius + }) + .is_err() + ); + } +} diff --git a/src/measurement/volume.rs b/src/measurement/volume.rs new file mode 100644 index 0000000..f5948c5 --- /dev/null +++ b/src/measurement/volume.rs @@ -0,0 +1,145 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Unit of volume. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum VolumeUnit { + Ml, + L, + M3, + FlOz, + Gal, +} + +impl std::fmt::Display for VolumeUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VolumeUnit::Ml => write!(f, "ml"), + VolumeUnit::L => write!(f, "l"), + VolumeUnit::M3 => write!(f, "m³"), + VolumeUnit::FlOz => write!(f, "fl oz"), + VolumeUnit::Gal => write!(f, "gal"), + } + } +} + +/// Input for [`Volume`]. +#[derive(Debug, Clone, PartialEq)] +pub struct VolumeInput { + pub value: f64, + pub unit: VolumeUnit, +} + +/// A validated volume measurement. +/// +/// **Validation:** value must be finite and non-negative. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::measurement::{Volume, VolumeInput, VolumeUnit}; +/// use arvo::traits::ValueObject; +/// +/// let v = Volume::new(VolumeInput { value: 1.5, unit: VolumeUnit::L })?; +/// assert_eq!(v.value(), "1.5 l"); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Volume { + value: f64, + unit: VolumeUnit, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Volume { + type Input = VolumeInput; + type Output = str; + type Error = ValidationError; + + fn new(input: Self::Input) -> Result { + if !input.value.is_finite() || input.value < 0.0 { + return Err(ValidationError::invalid("Volume", &input.value.to_string())); + } + let canonical = format!("{} {}", input.value, input.unit); + Ok(Self { + value: input.value, + unit: input.unit, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + fn into_inner(self) -> Self::Input { + VolumeInput { + value: self.value, + unit: self.unit, + } + } +} + +impl Volume { + pub fn amount(&self) -> f64 { + self.value + } + pub fn unit(&self) -> &VolumeUnit { + &self.unit + } +} + +impl std::fmt::Display for Volume { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid() { + let v = Volume::new(VolumeInput { + value: 1.5, + unit: VolumeUnit::L, + }) + .unwrap(); + assert_eq!(v.value(), "1.5 l"); + } + + #[test] + fn accepts_zero() { + assert!( + Volume::new(VolumeInput { + value: 0.0, + unit: VolumeUnit::Ml + }) + .is_ok() + ); + } + + #[test] + fn rejects_negative() { + assert!( + Volume::new(VolumeInput { + value: -1.0, + unit: VolumeUnit::L + }) + .is_err() + ); + } + + #[test] + fn rejects_nan() { + assert!( + Volume::new(VolumeInput { + value: f64::NAN, + unit: VolumeUnit::L + }) + .is_err() + ); + } +} diff --git a/src/measurement/weight.rs b/src/measurement/weight.rs new file mode 100644 index 0000000..66bc956 --- /dev/null +++ b/src/measurement/weight.rs @@ -0,0 +1,149 @@ +use crate::errors::ValidationError; +use crate::traits::ValueObject; + +/// Unit of weight/mass. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum WeightUnit { + Mg, + G, + Kg, + T, + Oz, + Lb, +} + +impl std::fmt::Display for WeightUnit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WeightUnit::Mg => write!(f, "mg"), + WeightUnit::G => write!(f, "g"), + WeightUnit::Kg => write!(f, "kg"), + WeightUnit::T => write!(f, "t"), + WeightUnit::Oz => write!(f, "oz"), + WeightUnit::Lb => write!(f, "lb"), + } + } +} + +/// Input for [`Weight`]. +#[derive(Debug, Clone, PartialEq)] +pub struct WeightInput { + pub value: f64, + pub unit: WeightUnit, +} + +/// A validated weight/mass measurement. +/// +/// **Validation:** value must be finite and non-negative. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::measurement::{Weight, WeightInput, WeightUnit}; +/// use arvo::traits::ValueObject; +/// +/// let w = Weight::new(WeightInput { value: 75.0, unit: WeightUnit::Kg })?; +/// assert_eq!(w.value(), "75 kg"); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Weight { + value: f64, + unit: WeightUnit, + #[cfg_attr(feature = "serde", serde(skip))] + canonical: String, +} + +impl ValueObject for Weight { + type Input = WeightInput; + type Output = str; + type Error = ValidationError; + + fn new(input: Self::Input) -> Result { + if !input.value.is_finite() || input.value < 0.0 { + return Err(ValidationError::invalid("Weight", &input.value.to_string())); + } + let canonical = format!("{} {}", input.value, input.unit); + Ok(Self { + value: input.value, + unit: input.unit, + canonical, + }) + } + + fn value(&self) -> &Self::Output { + &self.canonical + } + + fn into_inner(self) -> Self::Input { + WeightInput { + value: self.value, + unit: self.unit, + } + } +} + +impl Weight { + pub fn amount(&self) -> f64 { + self.value + } + pub fn unit(&self) -> &WeightUnit { + &self.unit + } +} + +impl std::fmt::Display for Weight { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_valid() { + let w = Weight::new(WeightInput { + value: 75.0, + unit: WeightUnit::Kg, + }) + .unwrap(); + assert_eq!(w.value(), "75 kg"); + assert_eq!(w.amount(), 75.0); + } + + #[test] + fn accepts_zero() { + assert!( + Weight::new(WeightInput { + value: 0.0, + unit: WeightUnit::G + }) + .is_ok() + ); + } + + #[test] + fn rejects_negative() { + assert!( + Weight::new(WeightInput { + value: -1.0, + unit: WeightUnit::Kg + }) + .is_err() + ); + } + + #[test] + fn rejects_nan() { + assert!( + Weight::new(WeightInput { + value: f64::NAN, + unit: WeightUnit::Kg + }) + .is_err() + ); + } +}