Skip to main content

haystack_core/kinds/
number.rs

1use crate::codecs::shared;
2use crate::kinds::units::{UnitError, convert, unit_for};
3use std::fmt;
4use std::hash::{Hash, Hasher};
5
6/// Haystack Number — a 64-bit float with optional unit string.
7///
8/// Equality requires both `val` and `unit` to match.
9/// NaN == NaN (consistent with Hash, which uses `to_bits()`).
10/// Display uses compact format: no trailing zeros, unit appended directly.
11#[derive(Debug, Clone)]
12pub struct Number {
13    pub val: f64,
14    pub unit: Option<String>,
15}
16
17impl Number {
18    pub fn new(val: f64, unit: Option<String>) -> Self {
19        Self { val, unit }
20    }
21
22    pub fn unitless(val: f64) -> Self {
23        Self { val, unit: None }
24    }
25
26    /// Convert this number to a different unit.
27    ///
28    /// The target may be a unit name (`"celsius"`) or symbol (`"°C"`).
29    /// Returns a new `Number` with the converted value and the target unit's
30    /// canonical name.
31    pub fn convert_to(&self, target_unit: &str) -> Result<Number, UnitError> {
32        let from = self
33            .unit
34            .as_deref()
35            .ok_or_else(|| UnitError::UnknownUnit("(none)".to_string()))?;
36        let converted = convert(self.val, from, target_unit)?;
37        let target_name = unit_for(target_unit)
38            .map(|u| u.name.clone())
39            .unwrap_or_else(|| target_unit.to_string());
40        Ok(Number::new(converted, Some(target_name)))
41    }
42}
43
44impl PartialEq for Number {
45    fn eq(&self, other: &Self) -> bool {
46        self.val.to_bits() == other.val.to_bits() && self.unit == other.unit
47    }
48}
49
50impl Eq for Number {}
51
52impl Hash for Number {
53    fn hash<H: Hasher>(&self, state: &mut H) {
54        self.val.to_bits().hash(state);
55        self.unit.hash(state);
56    }
57}
58
59impl fmt::Display for Number {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(f, "{}", shared::format_number_val(self.val))?;
62        if let Some(ref u) = self.unit {
63            write!(f, "{u}")?;
64        }
65        Ok(())
66    }
67}
68
69impl PartialOrd for Number {
70    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
71        if self.unit != other.unit {
72            return None;
73        }
74        self.val.partial_cmp(&other.val)
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn number_unitless() {
84        let n = Number::unitless(72.5);
85        assert_eq!(n.val, 72.5);
86        assert_eq!(n.unit, None);
87        assert_eq!(n.to_string(), "72.5");
88    }
89
90    #[test]
91    fn number_with_unit() {
92        let n = Number::new(72.5, Some("°F".into()));
93        assert_eq!(n.to_string(), "72.5°F");
94    }
95
96    #[test]
97    fn number_integer_display() {
98        let n = Number::unitless(42.0);
99        assert_eq!(n.to_string(), "42");
100    }
101
102    #[test]
103    fn number_zero() {
104        let n = Number::unitless(0.0);
105        assert_eq!(n.to_string(), "0");
106    }
107
108    #[test]
109    fn number_negative() {
110        let n = Number::new(-23.45, Some("m²".into()));
111        assert_eq!(n.to_string(), "-23.45m²");
112    }
113
114    #[test]
115    fn number_scientific() {
116        let n = Number::new(5.4e8, Some("kW".into()));
117        // Rust's default Display for large floats
118        let s = n.to_string();
119        assert!(s.contains("kW"));
120    }
121
122    #[test]
123    fn number_special_inf() {
124        assert_eq!(Number::unitless(f64::INFINITY).to_string(), "INF");
125    }
126
127    #[test]
128    fn number_special_neg_inf() {
129        assert_eq!(Number::unitless(f64::NEG_INFINITY).to_string(), "-INF");
130    }
131
132    #[test]
133    fn number_special_nan() {
134        assert_eq!(Number::unitless(f64::NAN).to_string(), "NaN");
135    }
136
137    #[test]
138    fn number_equality() {
139        let a = Number::new(72.5, Some("°F".into()));
140        let b = Number::new(72.5, Some("°F".into()));
141        let c = Number::new(72.5, Some("°C".into()));
142        assert_eq!(a, b);
143        assert_ne!(a, c);
144    }
145
146    #[test]
147    fn number_nan_equality() {
148        let a = Number::unitless(f64::NAN);
149        let b = Number::unitless(f64::NAN);
150        assert_eq!(a, b);
151    }
152
153    #[test]
154    fn number_ordering_same_unit() {
155        let a = Number::new(10.0, Some("°F".into()));
156        let b = Number::new(20.0, Some("°F".into()));
157        assert!(a < b);
158    }
159
160    #[test]
161    fn number_ordering_different_unit() {
162        let a = Number::new(10.0, Some("°F".into()));
163        let b = Number::new(20.0, Some("°C".into()));
164        assert_eq!(a.partial_cmp(&b), None);
165    }
166
167    #[test]
168    fn number_hashable() {
169        use std::collections::HashSet;
170        let mut set = HashSet::new();
171        set.insert(Number::unitless(42.0));
172        assert!(set.contains(&Number::unitless(42.0)));
173    }
174
175    // --- convert_to tests ---
176
177    #[test]
178    fn number_convert_to_celsius() {
179        let n = Number::new(212.0, Some("fahrenheit".into()));
180        let c = n.convert_to("celsius").unwrap();
181        assert!((c.val - 100.0).abs() < 0.01);
182        assert_eq!(c.unit.as_deref(), Some("celsius"));
183    }
184
185    #[test]
186    fn number_convert_to_by_symbol() {
187        let n = Number::new(0.0, Some("°C".into()));
188        let f = n.convert_to("°F").unwrap();
189        assert!((f.val - 32.0).abs() < 0.01);
190        assert_eq!(f.unit.as_deref(), Some("fahrenheit"));
191    }
192
193    #[test]
194    fn number_convert_to_unitless_error() {
195        let n = Number::unitless(42.0);
196        let err = n.convert_to("celsius").unwrap_err();
197        assert!(matches!(err, crate::kinds::units::UnitError::UnknownUnit(ref s) if s == "(none)"));
198    }
199
200    #[test]
201    fn number_convert_to_incompatible() {
202        let n = Number::new(100.0, Some("celsius".into()));
203        let err = n.convert_to("meter").unwrap_err();
204        assert!(matches!(
205            err,
206            crate::kinds::units::UnitError::IncompatibleUnits(_, _)
207        ));
208    }
209}