#![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};
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
UnsupportedUnit(String),
IncompatibleMeasures {
from: &'static str,
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 {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnitInfo {
pub abbr: &'static str,
pub measure: &'static str,
pub system: &'static str,
pub singular: &'static str,
pub plural: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Best {
pub val: f64,
pub unit: &'static str,
pub singular: &'static str,
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)
}
fn temperature_transform(from_system: &str, value: f64) -> f64 {
if from_system == "metric" {
value / (5.0 / 9.0) + 32.0
} else {
(value - 32.0) * (5.0 / 9.0)
}
}
#[must_use]
pub fn convert(value: f64) -> Convert {
Convert { value }
}
#[derive(Debug, Clone, Copy)]
pub struct Convert {
value: f64,
}
impl Convert {
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,
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct Conversion {
value: f64,
origin: &'static Unit,
}
impl Conversion {
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,
});
}
let mut result = self.value * self.origin.to_anchor;
result -= self.origin.anchor_shift;
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)
}
#[must_use]
pub fn to_best(&self) -> Option<Best> {
self.to_best_with(&[], 1.0)
}
#[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
}
#[must_use]
pub fn possibilities(&self) -> Vec<&'static str> {
UNITS
.iter()
.filter(|u| u.measure == self.origin.measure)
.map(|u| u.abbr)
.collect()
}
#[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,
}
}
#[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
}
#[must_use]
pub fn describe(unit: &str) -> Option<UnitInfo> {
find_unit(unit).map(unit_info)
}
#[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); }
#[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);
}
}