Skip to main content

use_measure/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// A measured scalar paired with a unit label.
5#[derive(Clone, Copy, Debug, PartialEq)]
6pub struct Measurement {
7    pub value: f64,
8    pub unit: &'static str,
9}
10
11impl Measurement {
12    /// Creates a new measurement value.
13    #[must_use]
14    pub const fn new(value: f64, unit: &'static str) -> Self {
15        Self { value, unit }
16    }
17
18    /// Converts the value when the conversion expects the current unit label.
19    #[must_use]
20    pub fn convert(self, conversion: Conversion) -> Option<Self> {
21        if self.unit != conversion.from_unit {
22            return None;
23        }
24
25        Some(Self::new(conversion.apply(self.value), conversion.to_unit))
26    }
27}
28
29/// A linear or affine conversion between two unit labels.
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub struct Conversion {
32    pub from_unit: &'static str,
33    pub to_unit: &'static str,
34    pub factor: f64,
35    pub offset: f64,
36}
37
38impl Conversion {
39    /// Creates a linear conversion with no offset.
40    #[must_use]
41    pub const fn linear(from_unit: &'static str, to_unit: &'static str, factor: f64) -> Self {
42        Self::affine(from_unit, to_unit, factor, 0.0)
43    }
44
45    /// Creates an affine conversion with both a factor and offset.
46    #[must_use]
47    pub const fn affine(
48        from_unit: &'static str,
49        to_unit: &'static str,
50        factor: f64,
51        offset: f64,
52    ) -> Self {
53        Self {
54            from_unit,
55            to_unit,
56            factor,
57            offset,
58        }
59    }
60
61    /// Applies the conversion to a raw scalar value.
62    #[must_use]
63    pub fn apply(self, value: f64) -> f64 {
64        value * self.factor + self.offset
65    }
66}
67
68/// Chains two compatible conversions into one.
69#[must_use]
70pub fn compose(first: Conversion, second: Conversion) -> Option<Conversion> {
71    if first.to_unit != second.from_unit {
72        return None;
73    }
74
75    Some(Conversion::affine(
76        first.from_unit,
77        second.to_unit,
78        first.factor * second.factor,
79        first.offset * second.factor + second.offset,
80    ))
81}
82
83/// Common measurement primitives.
84pub mod prelude {
85    pub use super::{Conversion, Measurement, compose};
86}
87
88#[cfg(test)]
89mod tests {
90    use super::{Conversion, Measurement, compose};
91
92    #[test]
93    fn converts_linear_measurements() {
94        let distance = Measurement::new(2.0, "km");
95        let conversion = Conversion::linear("km", "m", 1_000.0);
96
97        assert_eq!(
98            distance.convert(conversion),
99            Some(Measurement::new(2_000.0, "m"))
100        );
101    }
102
103    #[test]
104    fn supports_affine_conversions() {
105        let temperature = Measurement::new(20.0, "C");
106        let conversion = Conversion::affine("C", "F", 1.8, 32.0);
107
108        assert_eq!(
109            temperature.convert(conversion),
110            Some(Measurement::new(68.0, "F"))
111        );
112    }
113
114    #[test]
115    fn composes_compatible_conversions() {
116        let kilometers_to_meters = Conversion::linear("km", "m", 1_000.0);
117        let meters_to_centimeters = Conversion::linear("m", "cm", 100.0);
118        let kilometers_to_centimeters =
119            compose(kilometers_to_meters, meters_to_centimeters).expect("units should chain");
120
121        assert_eq!(kilometers_to_centimeters.apply(1.5), 150_000.0);
122    }
123
124    #[test]
125    fn rejects_incompatible_conversion_chains() {
126        let seconds_to_minutes = Conversion::linear("s", "min", 1.0 / 60.0);
127        let meters_to_centimeters = Conversion::linear("m", "cm", 100.0);
128
129        assert_eq!(compose(seconds_to_minutes, meters_to_centimeters), None);
130    }
131}