Skip to main content

convert_units/
lib.rs

1//! # convert-units — convert between units of measurement
2//!
3//! A fluent unit converter covering length, area, mass, volume, temperature, time, digital,
4//! speed, pressure, energy, power, frequency, angle, and more (23 measures, 185 units).
5//! A faithful Rust port of the popular
6//! [`convert-units`](https://www.npmjs.com/package/convert-units) npm package.
7//!
8//! ```
9//! use convert_units::convert;
10//!
11//! assert_eq!(convert(1.0).from("kg").unwrap().to("lb").unwrap(), 2.2046244201837775);
12//! assert_eq!(convert(100.0).from("cm").unwrap().to("m").unwrap(), 1.0);
13//! assert_eq!(convert(0.0).from("C").unwrap().to("F").unwrap(), 32.0);
14//! ```
15//!
16//! Find the most appropriate unit with [`Conversion::to_best`]:
17//!
18//! ```
19//! use convert_units::convert;
20//! let best = convert(12000.0).from("mm").unwrap().to_best().unwrap();
21//! assert_eq!(best.unit, "m");
22//! assert_eq!(best.val, 12.0);
23//! ```
24//!
25//! **Zero dependencies.**
26
27#![forbid(unsafe_code)]
28#![doc(html_root_url = "https://docs.rs/convert-units/0.1.0")]
29#![allow(
30    clippy::unreadable_literal,
31    clippy::must_use_candidate,
32    // Exact float equality in tests checks conversions that are mathematically exact.
33    clippy::float_cmp
34)]
35
36use core::fmt;
37
38mod data;
39use data::{Unit, ANCHOR_RATIOS, UNITS};
40
41// Compile-test the README's examples as part of `cargo test`.
42#[cfg(doctest)]
43#[doc = include_str!("../README.md")]
44struct ReadmeDoctests;
45
46/// An error from a conversion.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Error {
49    /// The given unit abbreviation is not recognized.
50    UnsupportedUnit(String),
51    /// The source and destination units measure different things (e.g. mass vs length).
52    IncompatibleMeasures {
53        /// The source measure.
54        from: &'static str,
55        /// The destination measure.
56        to: &'static str,
57    },
58}
59
60impl fmt::Display for Error {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            Error::UnsupportedUnit(u) => write!(f, "unsupported unit: {u}"),
64            Error::IncompatibleMeasures { from, to } => {
65                write!(f, "cannot convert incompatible measures of {to} and {from}")
66            }
67        }
68    }
69}
70
71impl std::error::Error for Error {}
72
73/// Metadata describing a unit.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct UnitInfo {
76    /// The unit abbreviation, e.g. `kg`.
77    pub abbr: &'static str,
78    /// The measure the unit belongs to, e.g. `mass`.
79    pub measure: &'static str,
80    /// The system the unit belongs to, e.g. `metric`.
81    pub system: &'static str,
82    /// The singular display name, e.g. `Kilogram`.
83    pub singular: &'static str,
84    /// The plural display name, e.g. `Kilograms`.
85    pub plural: &'static str,
86}
87
88/// The best matching unit found by [`Conversion::to_best`].
89#[derive(Debug, Clone, Copy, PartialEq)]
90pub struct Best {
91    /// The converted value.
92    pub val: f64,
93    /// The chosen unit's abbreviation.
94    pub unit: &'static str,
95    /// The chosen unit's singular name.
96    pub singular: &'static str,
97    /// The chosen unit's plural name.
98    pub plural: &'static str,
99}
100
101fn find_unit(abbr: &str) -> Option<&'static Unit> {
102    UNITS.iter().find(|u| u.abbr == abbr)
103}
104
105fn anchor_ratio(measure: &str, system: &str) -> f64 {
106    ANCHOR_RATIOS
107        .iter()
108        .find(|(m, s, _)| *m == measure && *s == system)
109        .map_or(1.0, |(_, _, r)| *r)
110}
111
112/// `_anchors[system].transform` for temperature.
113fn temperature_transform(from_system: &str, value: f64) -> f64 {
114    if from_system == "metric" {
115        // Celsius → Fahrenheit, matching `C / (5/9) + 32`.
116        value / (5.0 / 9.0) + 32.0
117    } else {
118        // Fahrenheit → Celsius, matching `(F - 32) * (5/9)`.
119        (value - 32.0) * (5.0 / 9.0)
120    }
121}
122
123/// Start a conversion of `value`. Call [`Convert::from`] next.
124#[must_use]
125pub fn convert(value: f64) -> Convert {
126    Convert { value }
127}
128
129/// A value awaiting its source unit.
130#[derive(Debug, Clone, Copy)]
131pub struct Convert {
132    value: f64,
133}
134
135impl Convert {
136    /// Set the source unit.
137    ///
138    /// # Errors
139    /// Returns [`Error::UnsupportedUnit`] if `unit` is not recognized.
140    pub fn from(self, unit: &str) -> Result<Conversion, Error> {
141        let origin = find_unit(unit).ok_or_else(|| Error::UnsupportedUnit(unit.to_string()))?;
142        Ok(Conversion {
143            value: self.value,
144            origin,
145        })
146    }
147}
148
149/// A value with a known source unit, ready to convert.
150#[derive(Debug, Clone, Copy)]
151pub struct Conversion {
152    value: f64,
153    origin: &'static Unit,
154}
155
156impl Conversion {
157    /// Convert to the destination `unit`.
158    ///
159    /// # Errors
160    /// Returns [`Error::UnsupportedUnit`] if `unit` is unknown, or
161    /// [`Error::IncompatibleMeasures`] if it measures something different from the source.
162    pub fn to(&self, unit: &str) -> Result<f64, Error> {
163        let dest = find_unit(unit).ok_or_else(|| Error::UnsupportedUnit(unit.to_string()))?;
164
165        if self.origin.abbr == dest.abbr {
166            return Ok(self.value);
167        }
168        if self.origin.measure != dest.measure {
169            return Err(Error::IncompatibleMeasures {
170                from: self.origin.measure,
171                to: dest.measure,
172            });
173        }
174
175        // To the source system's anchor.
176        let mut result = self.value * self.origin.to_anchor;
177        result -= self.origin.anchor_shift;
178
179        // Across systems via ratio or transform.
180        if self.origin.system != dest.system {
181            if self.origin.measure == "temperature" {
182                result = temperature_transform(self.origin.system, result);
183            } else {
184                result *= anchor_ratio(self.origin.measure, self.origin.system);
185            }
186        }
187
188        result += dest.anchor_shift;
189        Ok(result / dest.to_anchor)
190    }
191
192    /// Convert to the most appropriate unit (the smallest value ≥ 1 in the source system).
193    #[must_use]
194    pub fn to_best(&self) -> Option<Best> {
195        self.to_best_with(&[], 1.0)
196    }
197
198    /// Like [`to_best`](Self::to_best), excluding units in `exclude` and using `cut_off`
199    /// as the lower bound a candidate value must reach.
200    #[must_use]
201    pub fn to_best_with(&self, exclude: &[&str], cut_off: f64) -> Option<Best> {
202        let mut best: Option<Best> = None;
203        for possibility in self.possibilities() {
204            let Some(unit) = find_unit(possibility) else {
205                continue;
206            };
207            let included = !exclude.contains(&possibility);
208            if included && unit.system == self.origin.system {
209                let Ok(result) = self.to(possibility) else {
210                    continue;
211                };
212                let replace = match best {
213                    None => true,
214                    Some(b) => result >= cut_off && result < b.val,
215                };
216                if replace {
217                    best = Some(Best {
218                        val: result,
219                        unit: possibility,
220                        singular: unit.singular,
221                        plural: unit.plural,
222                    });
223                }
224            }
225        }
226        best
227    }
228
229    /// The unit abbreviations the source value can be converted to (its measure's units).
230    #[must_use]
231    pub fn possibilities(&self) -> Vec<&'static str> {
232        UNITS
233            .iter()
234            .filter(|u| u.measure == self.origin.measure)
235            .map(|u| u.abbr)
236            .collect()
237    }
238
239    /// Metadata about the source unit.
240    #[must_use]
241    pub fn describe_origin(&self) -> UnitInfo {
242        unit_info(self.origin)
243    }
244}
245
246fn unit_info(u: &'static Unit) -> UnitInfo {
247    UnitInfo {
248        abbr: u.abbr,
249        measure: u.measure,
250        system: u.system,
251        singular: u.singular,
252        plural: u.plural,
253    }
254}
255
256/// The names of all supported measures, in order.
257#[must_use]
258pub fn measures() -> Vec<&'static str> {
259    let mut out: Vec<&'static str> = Vec::new();
260    for u in UNITS {
261        if !out.contains(&u.measure) {
262            out.push(u.measure);
263        }
264    }
265    out
266}
267
268/// Describe a single unit by its abbreviation, or `None` if unknown.
269#[must_use]
270pub fn describe(unit: &str) -> Option<UnitInfo> {
271    find_unit(unit).map(unit_info)
272}
273
274/// A detailed list of all supported units, optionally restricted to one `measure`.
275#[must_use]
276pub fn list(measure: Option<&str>) -> Vec<UnitInfo> {
277    UNITS
278        .iter()
279        .filter(|u| measure.map_or(true, |m| u.measure == m))
280        .map(unit_info)
281        .collect()
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn c(v: f64, from: &str, to: &str) -> f64 {
289        convert(v).from(from).unwrap().to(to).unwrap()
290    }
291
292    #[test]
293    fn basic_conversions() {
294        assert_eq!(c(1.0, "kg", "lb"), 2.2046244201837775);
295        assert_eq!(c(100.0, "cm", "m"), 1.0);
296        assert_eq!(c(1.0, "l", "ml"), 1000.0);
297        assert_eq!(c(1.0, "mi", "km"), 1.6093439485009937);
298        assert_eq!(c(1.0, "h", "min"), 60.0);
299        assert_eq!(c(5.0, "m", "m"), 5.0); // same unit
300    }
301
302    #[test]
303    fn temperature() {
304        assert_eq!(c(0.0, "C", "F"), 32.0);
305        assert_eq!(c(100.0, "C", "F"), 212.0);
306        assert_eq!(c(32.0, "F", "C"), 0.0);
307        assert_eq!(c(0.0, "C", "K"), 273.15);
308        assert_eq!(c(0.0, "K", "C"), -273.15);
309    }
310
311    #[test]
312    fn digital() {
313        assert_eq!(c(1.0, "B", "b"), 8.0);
314        assert_eq!(c(1.0, "KB", "B"), 1024.0);
315    }
316
317    #[test]
318    fn errors() {
319        assert!(matches!(
320            convert(1.0).from("nope"),
321            Err(Error::UnsupportedUnit(_))
322        ));
323        assert!(matches!(
324            convert(1.0).from("kg").unwrap().to("m"),
325            Err(Error::IncompatibleMeasures { .. })
326        ));
327    }
328
329    #[test]
330    fn best_and_meta() {
331        let best = convert(12000.0).from("mm").unwrap().to_best().unwrap();
332        assert_eq!(best.unit, "m");
333        assert_eq!(best.val, 12.0);
334
335        assert_eq!(convert(1.0).from("kg").unwrap().possibilities().len(), 8);
336        assert_eq!(measures().len(), 23);
337        assert_eq!(describe("kg").unwrap().singular, "Kilogram");
338        assert_eq!(list(Some("length")).len(), 9);
339        assert_eq!(list(None).len(), 185);
340    }
341}