pnc-engine 0.2.0

Core engine for PNC: arbitrary-precision RPN evaluation, units, and persistence
Documentation
use crate::error::PncError;
use crate::value::Value;
use num_bigint::BigInt;
use num_rational::BigRational;
use serde::{Deserialize, Serialize};
use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dimension {
    Temperature,
    Length,
    Mass,
    Time,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum UnitId {
    // Temperature
    Celsius,
    Fahrenheit,
    Kelvin,
    // Length
    Millimeter,
    Centimeter,
    Meter,
    Kilometer,
    Mile,
    Foot,
    Inch,
    Yard,
    // Mass
    Milligram,
    Gram,
    Kilogram,
    Pound,
    Ounce,
    // Time
    Millisecond,
    Second,
    Minute,
    Hour,
    Day,
    Week,
    // Unknown
    Unknown(String),
}

impl fmt::Display for UnitId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.symbol())
    }
}

impl UnitId {
    pub fn symbol(&self) -> &str {
        match self {
            UnitId::Celsius => "c",
            UnitId::Fahrenheit => "f",
            UnitId::Kelvin => "k",
            UnitId::Millimeter => "mm",
            UnitId::Centimeter => "cm",
            UnitId::Meter => "m",
            UnitId::Kilometer => "km",
            UnitId::Mile => "mi",
            UnitId::Foot => "ft",
            UnitId::Inch => "in",
            UnitId::Yard => "yd",
            UnitId::Milligram => "mg",
            UnitId::Gram => "g",
            UnitId::Kilogram => "kg",
            UnitId::Pound => "lb",
            UnitId::Ounce => "oz",
            UnitId::Millisecond => "ms",
            UnitId::Second => "s",
            UnitId::Minute => "min",
            UnitId::Hour => "h",
            UnitId::Day => "d",
            UnitId::Week => "wk",
            UnitId::Unknown(s) => s,
        }
    }

    pub fn from_symbol(s: &str) -> Option<UnitId> {
        match s {
            "c" => Some(UnitId::Celsius),
            "f" => Some(UnitId::Fahrenheit),
            "k" => Some(UnitId::Kelvin),
            "mm" => Some(UnitId::Millimeter),
            "cm" => Some(UnitId::Centimeter),
            "m" => Some(UnitId::Meter),
            "km" => Some(UnitId::Kilometer),
            "mi" => Some(UnitId::Mile),
            "ft" => Some(UnitId::Foot),
            "in" => Some(UnitId::Inch),
            "yd" => Some(UnitId::Yard),
            "mg" => Some(UnitId::Milligram),
            "g" => Some(UnitId::Gram),
            "kg" => Some(UnitId::Kilogram),
            "lb" => Some(UnitId::Pound),
            "oz" => Some(UnitId::Ounce),
            "ms" => Some(UnitId::Millisecond),
            "s" => Some(UnitId::Second),
            "min" => Some(UnitId::Minute),
            "h" => Some(UnitId::Hour),
            "d" => Some(UnitId::Day),
            "wk" => Some(UnitId::Week),
            _ => None,
        }
    }

    pub fn dimension(&self) -> Option<Dimension> {
        match self {
            UnitId::Celsius | UnitId::Fahrenheit | UnitId::Kelvin => Some(Dimension::Temperature),
            UnitId::Millimeter
            | UnitId::Centimeter
            | UnitId::Meter
            | UnitId::Kilometer
            | UnitId::Mile
            | UnitId::Foot
            | UnitId::Inch
            | UnitId::Yard => Some(Dimension::Length),
            UnitId::Milligram | UnitId::Gram | UnitId::Kilogram | UnitId::Pound | UnitId::Ounce => {
                Some(Dimension::Mass)
            }
            UnitId::Millisecond
            | UnitId::Second
            | UnitId::Minute
            | UnitId::Hour
            | UnitId::Day
            | UnitId::Week => Some(Dimension::Time),
            UnitId::Unknown(_) => None,
        }
    }

    pub(crate) fn factor(&self) -> BigRational {
        match self {
            UnitId::Celsius => BigRational::from_integer(BigInt::from(1)),
            UnitId::Fahrenheit => BigRational::new(BigInt::from(5), BigInt::from(9)),
            UnitId::Kelvin => BigRational::from_integer(BigInt::from(1)),
            UnitId::Millimeter => BigRational::new(BigInt::from(1), BigInt::from(1000)),
            UnitId::Centimeter => BigRational::new(BigInt::from(1), BigInt::from(100)),
            UnitId::Meter => BigRational::from_integer(BigInt::from(1)),
            UnitId::Kilometer => BigRational::from_integer(BigInt::from(1000)),
            UnitId::Mile => BigRational::new(BigInt::from(1609344), BigInt::from(1000)),
            UnitId::Foot => BigRational::new(BigInt::from(3048), BigInt::from(10000)),
            UnitId::Inch => BigRational::new(BigInt::from(254), BigInt::from(10000)),
            UnitId::Yard => BigRational::new(BigInt::from(9144), BigInt::from(10000)),
            UnitId::Milligram => BigRational::new(BigInt::from(1), BigInt::from(1000000)),
            UnitId::Gram => BigRational::new(BigInt::from(1), BigInt::from(1000)),
            UnitId::Kilogram => BigRational::from_integer(BigInt::from(1)),
            UnitId::Pound => BigRational::new(BigInt::from(45359237), BigInt::from(100000000)),
            UnitId::Ounce => BigRational::new(BigInt::from(45359237), BigInt::from(1600000000)),
            UnitId::Millisecond => BigRational::new(BigInt::from(1), BigInt::from(1000)),
            UnitId::Second => BigRational::from_integer(BigInt::from(1)),
            UnitId::Minute => BigRational::from_integer(BigInt::from(60)),
            UnitId::Hour => BigRational::from_integer(BigInt::from(3600)),
            UnitId::Day => BigRational::from_integer(BigInt::from(86400)),
            UnitId::Week => BigRational::from_integer(BigInt::from(604800)),
            UnitId::Unknown(_) => BigRational::from_integer(BigInt::from(1)),
        }
    }

    pub(crate) fn offset(&self) -> BigRational {
        match self {
            UnitId::Celsius => BigRational::new(BigInt::from(27315), BigInt::from(100)),
            UnitId::Fahrenheit => BigRational::new(BigInt::from(45967), BigInt::from(180)),
            _ => BigRational::from_integer(BigInt::from(0)),
        }
    }

    pub fn units_in_dimension(d: Dimension) -> &'static [UnitId] {
        match d {
            Dimension::Temperature => &[UnitId::Celsius, UnitId::Fahrenheit, UnitId::Kelvin],
            Dimension::Length => &[
                UnitId::Millimeter,
                UnitId::Centimeter,
                UnitId::Meter,
                UnitId::Kilometer,
                UnitId::Mile,
                UnitId::Foot,
                UnitId::Inch,
                UnitId::Yard,
            ],
            Dimension::Mass => &[
                UnitId::Milligram,
                UnitId::Gram,
                UnitId::Kilogram,
                UnitId::Pound,
                UnitId::Ounce,
            ],
            Dimension::Time => &[
                UnitId::Millisecond,
                UnitId::Second,
                UnitId::Minute,
                UnitId::Hour,
                UnitId::Day,
                UnitId::Week,
            ],
        }
    }
}

pub fn convert(value: &Value, target: UnitId) -> Result<Value, PncError> {
    // Unitless number: attach target unit.
    match value {
        Value::Int(n) => {
            return Ok(Value::Unit {
                number: Box::new(Value::Int(n.clone())),
                unit: target,
            });
        }
        Value::Rational(r) => {
            return Ok(Value::Unit {
                number: Box::new(Value::Rational(r.clone())),
                unit: target,
            });
        }
        _ => {}
    }

    // Value::Unit
    if let Value::Unit {
        number,
        unit: source,
    } = value
    {
        // Same unit → return original.
        if *source == target {
            return Ok(value.clone());
        }
        // Unknown units: identity only for equal strings (already handled above).
        if source.dimension().is_none() || target.dimension().is_none() {
            return Err(PncError::InvalidUnitConversion);
        }
        // Different dimensions.
        if source.dimension() != target.dimension() {
            return Err(PncError::InvalidUnitConversion);
        }

        // Extract rational from number.
        let num = match number.as_ref() {
            Value::Int(n) => BigRational::from_integer(n.clone()),
            Value::Rational(r) => r.clone(),
            _ => return Err(PncError::InvalidUnitConversion),
        };
        let base = num * source.factor() + source.offset();
        let result = (base - target.offset()) / target.factor();

        // Precision check on rational result.
        crate::ops::check_precision(&Value::Rational(result.clone()))
            .map_err(|_| PncError::PrecisionBudgetExceeded)?;

        // Normalize via rational_to_value (inline).
        let normalized = if result.denom() == &BigInt::from(1) {
            Value::Int(result.numer().clone())
        } else {
            Value::Rational(result)
        };

        return Ok(Value::Unit {
            number: Box::new(normalized),
            unit: target,
        });
    }

    Err(PncError::InvalidUnitConversion)
}