arvo 1.0.0

Validated, immutable value objects for common domain types (email, money, identifiers, …)
Documentation
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,
}

#[cfg(feature = "serde")]
impl From<Temperature> for String {
    fn from(v: Temperature) -> String {
        v.canonical
    }
}

impl TryFrom<String> for Temperature {
    type Error = ValidationError;
    fn try_from(s: String) -> Result<Self, Self::Error> {
        Self::try_from(s.as_str())
    }
}

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))]
#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
pub struct Temperature {
    value: f64,
    unit: TemperatureUnit,
    canonical: String,
}

impl ValueObject for Temperature {
    type Input = TemperatureInput;
    type Error = ValidationError;

    fn new(input: Self::Input) -> Result<Self, Self::Error> {
        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 into_inner(self) -> Self::Input {
        TemperatureInput {
            value: self.value,
            unit: self.unit,
        }
    }
}

impl Temperature {
    pub fn value(&self) -> &str {
        &self.canonical
    }

    pub fn amount(&self) -> f64 {
        self.value
    }
    pub fn unit(&self) -> &TemperatureUnit {
        &self.unit
    }
}

impl TryFrom<&str> for Temperature {
    type Error = ValidationError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        let err = || ValidationError::invalid("Temperature", value);
        let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?;
        let val: f64 = val_str.trim().parse().map_err(|_| err())?;
        let unit = match unit_str.trim() {
            "°C" => TemperatureUnit::Celsius,
            "°F" => TemperatureUnit::Fahrenheit,
            "K" => TemperatureUnit::Kelvin,
            _ => return Err(err()),
        };
        Self::new(TemperatureInput { value: val, 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()
        );
    }

    #[test]
    fn try_from_parses_valid() {
        let t = Temperature::try_from("100 °C").unwrap();
        assert_eq!(t.value(), "100 °C");
    }

    #[test]
    fn try_from_rejects_no_space() {
        assert!(Temperature::try_from("100").is_err());
    }

    #[test]
    fn try_from_rejects_below_absolute_zero() {
        assert!(Temperature::try_from("-500 K").is_err());
    }

    #[cfg(feature = "serde")]
    #[test]
    fn serde_roundtrip() {
        let v = Temperature::try_from("100 °C").unwrap();
        let json = serde_json::to_string(&v).unwrap();
        let back: Temperature = serde_json::from_str(&json).unwrap();
        assert_eq!(v.value(), back.value());
    }

    #[cfg(feature = "serde")]
    #[test]
    fn serde_serializes_as_canonical_string() {
        let v = Temperature::try_from("100 °C").unwrap();
        let json = serde_json::to_string(&v).unwrap();
        assert!(json.contains("100"));
    }
}