convert-units 0.1.0

Convert between units of measurement (length, mass, volume, temperature, time, digital, ...) with a fluent API. A faithful port of the convert-units npm package. Zero dependencies.
Documentation
//! # convert-units — convert between units of measurement
//!
//! A fluent unit converter covering length, area, mass, volume, temperature, time, digital,
//! speed, pressure, energy, power, frequency, angle, and more (23 measures, 185 units).
//! A faithful Rust port of the popular
//! [`convert-units`](https://www.npmjs.com/package/convert-units) npm package.
//!
//! ```
//! use convert_units::convert;
//!
//! assert_eq!(convert(1.0).from("kg").unwrap().to("lb").unwrap(), 2.2046244201837775);
//! assert_eq!(convert(100.0).from("cm").unwrap().to("m").unwrap(), 1.0);
//! assert_eq!(convert(0.0).from("C").unwrap().to("F").unwrap(), 32.0);
//! ```
//!
//! Find the most appropriate unit with [`Conversion::to_best`]:
//!
//! ```
//! use convert_units::convert;
//! let best = convert(12000.0).from("mm").unwrap().to_best().unwrap();
//! assert_eq!(best.unit, "m");
//! assert_eq!(best.val, 12.0);
//! ```
//!
//! **Zero dependencies.**

#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/convert-units/0.1.0")]
#![allow(
    clippy::unreadable_literal,
    clippy::must_use_candidate,
    // Exact float equality in tests checks conversions that are mathematically exact.
    clippy::float_cmp
)]

use core::fmt;

mod data;
use data::{Unit, ANCHOR_RATIOS, UNITS};

// Compile-test the README's examples as part of `cargo test`.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

/// An error from a conversion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
    /// The given unit abbreviation is not recognized.
    UnsupportedUnit(String),
    /// The source and destination units measure different things (e.g. mass vs length).
    IncompatibleMeasures {
        /// The source measure.
        from: &'static str,
        /// The destination measure.
        to: &'static str,
    },
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::UnsupportedUnit(u) => write!(f, "unsupported unit: {u}"),
            Error::IncompatibleMeasures { from, to } => {
                write!(f, "cannot convert incompatible measures of {to} and {from}")
            }
        }
    }
}

impl std::error::Error for Error {}

/// Metadata describing a unit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnitInfo {
    /// The unit abbreviation, e.g. `kg`.
    pub abbr: &'static str,
    /// The measure the unit belongs to, e.g. `mass`.
    pub measure: &'static str,
    /// The system the unit belongs to, e.g. `metric`.
    pub system: &'static str,
    /// The singular display name, e.g. `Kilogram`.
    pub singular: &'static str,
    /// The plural display name, e.g. `Kilograms`.
    pub plural: &'static str,
}

/// The best matching unit found by [`Conversion::to_best`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Best {
    /// The converted value.
    pub val: f64,
    /// The chosen unit's abbreviation.
    pub unit: &'static str,
    /// The chosen unit's singular name.
    pub singular: &'static str,
    /// The chosen unit's plural name.
    pub plural: &'static str,
}

fn find_unit(abbr: &str) -> Option<&'static Unit> {
    UNITS.iter().find(|u| u.abbr == abbr)
}

fn anchor_ratio(measure: &str, system: &str) -> f64 {
    ANCHOR_RATIOS
        .iter()
        .find(|(m, s, _)| *m == measure && *s == system)
        .map_or(1.0, |(_, _, r)| *r)
}

/// `_anchors[system].transform` for temperature.
fn temperature_transform(from_system: &str, value: f64) -> f64 {
    if from_system == "metric" {
        // Celsius → Fahrenheit, matching `C / (5/9) + 32`.
        value / (5.0 / 9.0) + 32.0
    } else {
        // Fahrenheit → Celsius, matching `(F - 32) * (5/9)`.
        (value - 32.0) * (5.0 / 9.0)
    }
}

/// Start a conversion of `value`. Call [`Convert::from`] next.
#[must_use]
pub fn convert(value: f64) -> Convert {
    Convert { value }
}

/// A value awaiting its source unit.
#[derive(Debug, Clone, Copy)]
pub struct Convert {
    value: f64,
}

impl Convert {
    /// Set the source unit.
    ///
    /// # Errors
    /// Returns [`Error::UnsupportedUnit`] if `unit` is not recognized.
    pub fn from(self, unit: &str) -> Result<Conversion, Error> {
        let origin = find_unit(unit).ok_or_else(|| Error::UnsupportedUnit(unit.to_string()))?;
        Ok(Conversion {
            value: self.value,
            origin,
        })
    }
}

/// A value with a known source unit, ready to convert.
#[derive(Debug, Clone, Copy)]
pub struct Conversion {
    value: f64,
    origin: &'static Unit,
}

impl Conversion {
    /// Convert to the destination `unit`.
    ///
    /// # Errors
    /// Returns [`Error::UnsupportedUnit`] if `unit` is unknown, or
    /// [`Error::IncompatibleMeasures`] if it measures something different from the source.
    pub fn to(&self, unit: &str) -> Result<f64, Error> {
        let dest = find_unit(unit).ok_or_else(|| Error::UnsupportedUnit(unit.to_string()))?;

        if self.origin.abbr == dest.abbr {
            return Ok(self.value);
        }
        if self.origin.measure != dest.measure {
            return Err(Error::IncompatibleMeasures {
                from: self.origin.measure,
                to: dest.measure,
            });
        }

        // To the source system's anchor.
        let mut result = self.value * self.origin.to_anchor;
        result -= self.origin.anchor_shift;

        // Across systems via ratio or transform.
        if self.origin.system != dest.system {
            if self.origin.measure == "temperature" {
                result = temperature_transform(self.origin.system, result);
            } else {
                result *= anchor_ratio(self.origin.measure, self.origin.system);
            }
        }

        result += dest.anchor_shift;
        Ok(result / dest.to_anchor)
    }

    /// Convert to the most appropriate unit (the smallest value ≥ 1 in the source system).
    #[must_use]
    pub fn to_best(&self) -> Option<Best> {
        self.to_best_with(&[], 1.0)
    }

    /// Like [`to_best`](Self::to_best), excluding units in `exclude` and using `cut_off`
    /// as the lower bound a candidate value must reach.
    #[must_use]
    pub fn to_best_with(&self, exclude: &[&str], cut_off: f64) -> Option<Best> {
        let mut best: Option<Best> = None;
        for possibility in self.possibilities() {
            let Some(unit) = find_unit(possibility) else {
                continue;
            };
            let included = !exclude.contains(&possibility);
            if included && unit.system == self.origin.system {
                let Ok(result) = self.to(possibility) else {
                    continue;
                };
                let replace = match best {
                    None => true,
                    Some(b) => result >= cut_off && result < b.val,
                };
                if replace {
                    best = Some(Best {
                        val: result,
                        unit: possibility,
                        singular: unit.singular,
                        plural: unit.plural,
                    });
                }
            }
        }
        best
    }

    /// The unit abbreviations the source value can be converted to (its measure's units).
    #[must_use]
    pub fn possibilities(&self) -> Vec<&'static str> {
        UNITS
            .iter()
            .filter(|u| u.measure == self.origin.measure)
            .map(|u| u.abbr)
            .collect()
    }

    /// Metadata about the source unit.
    #[must_use]
    pub fn describe_origin(&self) -> UnitInfo {
        unit_info(self.origin)
    }
}

fn unit_info(u: &'static Unit) -> UnitInfo {
    UnitInfo {
        abbr: u.abbr,
        measure: u.measure,
        system: u.system,
        singular: u.singular,
        plural: u.plural,
    }
}

/// The names of all supported measures, in order.
#[must_use]
pub fn measures() -> Vec<&'static str> {
    let mut out: Vec<&'static str> = Vec::new();
    for u in UNITS {
        if !out.contains(&u.measure) {
            out.push(u.measure);
        }
    }
    out
}

/// Describe a single unit by its abbreviation, or `None` if unknown.
#[must_use]
pub fn describe(unit: &str) -> Option<UnitInfo> {
    find_unit(unit).map(unit_info)
}

/// A detailed list of all supported units, optionally restricted to one `measure`.
#[must_use]
pub fn list(measure: Option<&str>) -> Vec<UnitInfo> {
    UNITS
        .iter()
        .filter(|u| measure.map_or(true, |m| u.measure == m))
        .map(unit_info)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn c(v: f64, from: &str, to: &str) -> f64 {
        convert(v).from(from).unwrap().to(to).unwrap()
    }

    #[test]
    fn basic_conversions() {
        assert_eq!(c(1.0, "kg", "lb"), 2.2046244201837775);
        assert_eq!(c(100.0, "cm", "m"), 1.0);
        assert_eq!(c(1.0, "l", "ml"), 1000.0);
        assert_eq!(c(1.0, "mi", "km"), 1.6093439485009937);
        assert_eq!(c(1.0, "h", "min"), 60.0);
        assert_eq!(c(5.0, "m", "m"), 5.0); // same unit
    }

    #[test]
    fn temperature() {
        assert_eq!(c(0.0, "C", "F"), 32.0);
        assert_eq!(c(100.0, "C", "F"), 212.0);
        assert_eq!(c(32.0, "F", "C"), 0.0);
        assert_eq!(c(0.0, "C", "K"), 273.15);
        assert_eq!(c(0.0, "K", "C"), -273.15);
    }

    #[test]
    fn digital() {
        assert_eq!(c(1.0, "B", "b"), 8.0);
        assert_eq!(c(1.0, "KB", "B"), 1024.0);
    }

    #[test]
    fn errors() {
        assert!(matches!(
            convert(1.0).from("nope"),
            Err(Error::UnsupportedUnit(_))
        ));
        assert!(matches!(
            convert(1.0).from("kg").unwrap().to("m"),
            Err(Error::IncompatibleMeasures { .. })
        ));
    }

    #[test]
    fn best_and_meta() {
        let best = convert(12000.0).from("mm").unwrap().to_best().unwrap();
        assert_eq!(best.unit, "m");
        assert_eq!(best.val, 12.0);

        assert_eq!(convert(1.0).from("kg").unwrap().possibilities().len(), 8);
        assert_eq!(measures().len(), 23);
        assert_eq!(describe("kg").unwrap().singular, "Kilogram");
        assert_eq!(list(Some("length")).len(), 9);
        assert_eq!(list(None).len(), 185);
    }
}