1use std::collections::HashMap;
2use std::sync::LazyLock;
3
4#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6pub struct Unit {
7 pub name: String,
8 pub symbols: Vec<String>,
9 pub quantity: String,
10}
11
12struct UnitsRegistry {
13 by_name: HashMap<String, Unit>,
14 by_symbol: HashMap<String, Unit>,
15}
16
17static UNITS: LazyLock<UnitsRegistry> = LazyLock::new(|| {
18 let data = include_str!("../../data/units.txt");
19 parse_units(data)
20});
21
22fn parse_units(data: &str) -> UnitsRegistry {
23 let mut by_name = HashMap::new();
24 let mut by_symbol = HashMap::new();
25 let mut current_quantity = String::new();
26
27 for line in data.lines() {
28 let line = line.trim();
29 if line.is_empty() || line.starts_with("//") {
30 continue;
31 }
32 if line.starts_with("-- ") && line.ends_with(" --") {
33 current_quantity = line[3..line.len() - 3].to_string();
34 continue;
35 }
36 let parts: Vec<&str> = line.split(',').collect();
37 if parts.is_empty() {
38 continue;
39 }
40 let name = parts[0].trim().to_string();
41 let symbols: Vec<String> = parts[1..]
42 .iter()
43 .map(|s| s.trim().to_string())
44 .filter(|s| !s.is_empty())
45 .collect();
46
47 let unit = Unit {
48 name: name.clone(),
49 symbols: symbols.clone(),
50 quantity: current_quantity.clone(),
51 };
52
53 by_name.insert(name, unit.clone());
54 for sym in &symbols {
55 by_symbol.insert(sym.clone(), unit.clone());
56 }
57 }
58
59 UnitsRegistry { by_name, by_symbol }
60}
61
62pub fn unit_for(s: &str) -> Option<&'static Unit> {
64 UNITS.by_name.get(s).or_else(|| UNITS.by_symbol.get(s))
65}
66
67pub fn units_by_name() -> &'static HashMap<String, Unit> {
69 &UNITS.by_name
70}
71
72pub fn units_by_symbol() -> &'static HashMap<String, Unit> {
74 &UNITS.by_symbol
75}
76
77#[derive(Debug, Clone, Copy)]
86pub struct ConversionFactor {
87 pub scale: f64,
89 pub offset: f64,
91}
92
93#[derive(Debug, Clone)]
95pub enum UnitError {
96 UnknownUnit(String),
97 IncompatibleUnits(String, String),
98}
99
100impl std::fmt::Display for UnitError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 UnitError::UnknownUnit(u) => write!(f, "unknown unit: {u}"),
104 UnitError::IncompatibleUnits(a, b) => write!(f, "incompatible units: {a} and {b}"),
105 }
106 }
107}
108
109impl std::error::Error for UnitError {}
110
111static CONVERSION_FACTORS: LazyLock<HashMap<&'static str, ConversionFactor>> =
112 LazyLock::new(|| {
113 let entries: &[(&str, f64, f64)] = &[
114 ("fahrenheit", 5.0 / 9.0, -32.0 * 5.0 / 9.0),
116 ("celsius", 1.0, 0.0),
117 ("kelvin", 1.0, -273.15),
118 ("meter", 1.0, 0.0),
120 ("kilometer", 1000.0, 0.0),
121 ("centimeter", 0.01, 0.0),
122 ("millimeter", 0.001, 0.0),
123 ("foot", 0.3048, 0.0),
124 ("inch", 0.0254, 0.0),
125 ("mile", 1609.344, 0.0),
126 ("yard", 0.9144, 0.0),
127 ("pascal", 1.0, 0.0),
129 ("kilopascal", 1000.0, 0.0),
130 ("bar", 100_000.0, 0.0),
131 ("millibar", 100.0, 0.0),
132 ("pounds_per_square_inch", 6894.757, 0.0),
133 ("inches_of_water", 248.84, 0.0),
134 ("inches_of_mercury", 3386.389, 0.0),
135 ("atmosphere", 101_325.0, 0.0),
136 ("hectopascal", 100.0, 0.0),
137 ("joule", 1.0, 0.0),
139 ("kilojoule", 1000.0, 0.0),
140 ("megajoule", 1e6, 0.0),
141 ("kilowatt_hour", 3.6e6, 0.0),
142 ("watt_hour", 3600.0, 0.0),
143 ("btu", 1055.06, 0.0),
144 ("megabtu", 1.055_06e9, 0.0),
145 ("therm", 1.055_06e8, 0.0),
146 ("tons_refrigeration_hour", 1.2661e7, 0.0),
147 ("kilobtu", 1.055_06e6, 0.0),
148 ("watt", 1.0, 0.0),
150 ("kilowatt", 1000.0, 0.0),
151 ("megawatt", 1e6, 0.0),
152 ("horsepower", 745.7, 0.0),
153 ("btus_per_hour", 0.293_07, 0.0),
154 ("tons_refrigeration", 3516.85, 0.0),
155 ("kilobtus_per_hour", 293.07, 0.0),
156 ("cubic_meter", 1.0, 0.0),
158 ("liter", 0.001, 0.0),
159 ("milliliter", 1e-6, 0.0),
160 ("gallon", 0.003_785, 0.0),
161 ("quart", 0.000_946_353, 0.0),
162 ("pint", 0.000_473_176, 0.0),
163 ("fluid_ounce", 2.957e-5, 0.0),
164 ("cubic_foot", 0.028_317, 0.0),
165 ("imperial_gallon", 0.004_546, 0.0),
166 ("cubic_meters_per_second", 1.0, 0.0),
168 ("liters_per_second", 0.001, 0.0),
169 ("liters_per_minute", 1.667e-5, 0.0),
170 ("cubic_feet_per_minute", 0.000_472, 0.0),
171 ("gallons_per_minute", 6.309e-5, 0.0),
172 ("cubic_meters_per_hour", 2.778e-4, 0.0),
173 ("liters_per_hour", 2.778e-7, 0.0),
174 ("square_meter", 1.0, 0.0),
176 ("square_foot", 0.0929, 0.0),
177 ("square_kilometer", 1e6, 0.0),
178 ("square_mile", 2.59e6, 0.0),
179 ("acre", 4046.86, 0.0),
180 ("square_centimeter", 1e-4, 0.0),
181 ("square_inch", 6.452e-4, 0.0),
182 ("kilogram", 1.0, 0.0),
184 ("gram", 0.001, 0.0),
185 ("milligram", 1e-6, 0.0),
186 ("metric_ton", 1000.0, 0.0),
187 ("pound", 0.4536, 0.0),
188 ("ounce", 0.028_35, 0.0),
189 ("short_ton", 907.185, 0.0),
190 ("second", 1.0, 0.0),
192 ("millisecond", 0.001, 0.0),
193 ("minute", 60.0, 0.0),
194 ("hour", 3600.0, 0.0),
195 ("day", 86400.0, 0.0),
196 ("week", 604_800.0, 0.0),
197 ("julian_month", 2_629_800.0, 0.0),
198 ("year", 31_557_600.0, 0.0),
199 ("ampere", 1.0, 0.0),
201 ("milliampere", 0.001, 0.0),
202 ("volt", 1.0, 0.0),
204 ("millivolt", 0.001, 0.0),
205 ("kilovolt", 1000.0, 0.0),
206 ("megavolt", 1e6, 0.0),
207 ("hertz", 1.0, 0.0),
209 ("kilohertz", 1000.0, 0.0),
210 ("megahertz", 1e6, 0.0),
211 ("per_minute", 1.0 / 60.0, 0.0),
212 ("per_hour", 1.0 / 3600.0, 0.0),
213 ("per_second", 1.0, 0.0),
214 ("lux", 1.0, 0.0),
216 ("footcandle", 10.764, 0.0),
217 ("phot", 10000.0, 0.0),
218 ("lumen", 1.0, 0.0),
220 ];
221 entries
222 .iter()
223 .map(|&(name, scale, offset)| (name, ConversionFactor { scale, offset }))
224 .collect()
225 });
226
227static BASE_UNITS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
228 let entries: &[(&str, &str)] = &[
229 ("temperature", "celsius"),
230 ("length", "meter"),
231 ("pressure", "pascal"),
232 ("energy", "joule"),
233 ("power", "watt"),
234 ("volume", "cubic_meter"),
235 ("volumetric flow", "cubic_meters_per_second"),
236 ("area", "square_meter"),
237 ("mass", "kilogram"),
238 ("time", "second"),
239 ("electric current", "ampere"),
240 ("electric potential", "volt"),
241 ("frequency", "hertz"),
242 ("illuminance", "lux"),
243 ("luminous flux", "lumen"),
244 ];
245 entries.iter().copied().collect()
246});
247
248fn resolve(s: &str) -> Result<(&'static Unit, &'static ConversionFactor), UnitError> {
250 let unit = unit_for(s).ok_or_else(|| UnitError::UnknownUnit(s.to_string()))?;
251 let cf = CONVERSION_FACTORS
252 .get(unit.name.as_str())
253 .ok_or_else(|| UnitError::UnknownUnit(s.to_string()))?;
254 Ok((unit, cf))
255}
256
257pub fn convert(val: f64, from: &str, to: &str) -> Result<f64, UnitError> {
269 let (from_unit, from_cf) = resolve(from)?;
270 let (to_unit, to_cf) = resolve(to)?;
271
272 if from_unit.quantity != to_unit.quantity {
273 return Err(UnitError::IncompatibleUnits(
274 from_unit.name.clone(),
275 to_unit.name.clone(),
276 ));
277 }
278
279 Ok((val * from_cf.scale + from_cf.offset - to_cf.offset) / to_cf.scale)
280}
281
282pub fn compatible(a: &str, b: &str) -> bool {
284 let (ua, ub) = match (unit_for(a), unit_for(b)) {
285 (Some(ua), Some(ub)) => (ua, ub),
286 _ => return false,
287 };
288 if !CONVERSION_FACTORS.contains_key(ua.name.as_str())
290 || !CONVERSION_FACTORS.contains_key(ub.name.as_str())
291 {
292 return false;
293 }
294 ua.quantity == ub.quantity
295}
296
297pub fn quantity(unit: &str) -> Option<&'static str> {
299 let u = unit_for(unit)?;
300 BASE_UNITS.keys().find(|&&q| q == u.quantity).copied()
302}
303
304pub fn base_unit(qty: &str) -> Option<&'static str> {
306 BASE_UNITS.get(qty).copied()
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn units_loaded() {
315 let by_name = units_by_name();
316 assert!(!by_name.is_empty(), "units should be loaded from units.txt");
317 assert!(
319 by_name.len() > 100,
320 "expected 100+ units, got {}",
321 by_name.len()
322 );
323 }
324
325 #[test]
326 fn unit_lookup_by_name() {
327 let u = unit_for("fahrenheit");
328 assert!(u.is_some(), "fahrenheit should exist");
329 let u = u.unwrap();
330 assert_eq!(u.name, "fahrenheit");
331 assert!(u.symbols.contains(&"°F".to_string()));
332 }
333
334 #[test]
335 fn unit_lookup_by_symbol() {
336 let u = unit_for("°F");
337 assert!(u.is_some(), "°F should resolve");
338 assert_eq!(u.unwrap().name, "fahrenheit");
339 }
340
341 #[test]
342 fn unit_lookup_celsius() {
343 let u = unit_for("celsius");
344 assert!(u.is_some());
345 assert!(u.unwrap().symbols.contains(&"°C".to_string()));
346 }
347
348 #[test]
349 fn unit_not_found() {
350 assert!(unit_for("nonexistent_unit_xyz").is_none());
351 }
352
353 #[test]
354 fn unit_has_quantity() {
355 let u = unit_for("fahrenheit").unwrap();
356 assert!(
357 !u.quantity.is_empty(),
358 "unit should have a quantity category"
359 );
360 }
361
362 #[test]
365 fn unit_convert_f_to_c() {
366 let c = convert(212.0, "fahrenheit", "celsius").unwrap();
367 assert!((c - 100.0).abs() < 0.01, "212°F = 100°C, got {c}");
368 }
369
370 #[test]
371 fn unit_convert_c_to_f() {
372 let f = convert(0.0, "celsius", "fahrenheit").unwrap();
373 assert!((f - 32.0).abs() < 0.01, "0°C = 32°F, got {f}");
374 }
375
376 #[test]
377 fn unit_convert_f_to_k() {
378 let k = convert(32.0, "fahrenheit", "kelvin").unwrap();
380 assert!((k - 273.15).abs() < 0.01, "32°F = 273.15K, got {k}");
381 }
382
383 #[test]
384 fn unit_convert_k_to_c() {
385 let c = convert(373.15, "kelvin", "celsius").unwrap();
386 assert!((c - 100.0).abs() < 0.01, "373.15K = 100°C, got {c}");
387 }
388
389 #[test]
390 fn unit_convert_c_to_k() {
391 let k = convert(100.0, "celsius", "kelvin").unwrap();
392 assert!((k - 373.15).abs() < 0.01, "100°C = 373.15K, got {k}");
393 }
394
395 #[test]
396 fn unit_convert_by_symbol() {
397 let c = convert(212.0, "°F", "°C").unwrap();
398 assert!((c - 100.0).abs() < 0.01);
399 }
400
401 #[test]
402 fn unit_convert_psi_to_kpa() {
403 let kpa = convert(1.0, "psi", "kPa").unwrap();
405 assert!(
406 (kpa - 6.894_757).abs() < 0.01,
407 "1 psi ≈ 6.895 kPa, got {kpa}"
408 );
409 }
410
411 #[test]
412 fn unit_convert_bar_to_psi() {
413 let psi = convert(1.0, "bar", "psi").unwrap();
415 assert!((psi - 14.504).abs() < 0.1, "1 bar ≈ 14.504 psi, got {psi}");
416 }
417
418 #[test]
419 fn unit_convert_feet_to_meters() {
420 let m = convert(1.0, "foot", "meter").unwrap();
421 assert!((m - 0.3048).abs() < 0.0001);
422 }
423
424 #[test]
425 fn unit_convert_miles_to_km() {
426 let km = convert(1.0, "mile", "kilometer").unwrap();
427 assert!((km - 1.609_344).abs() < 0.001);
428 }
429
430 #[test]
431 fn unit_convert_kwh_to_btu() {
432 let btu = convert(1.0, "kilowatt_hour", "btu").unwrap();
434 assert!((btu - 3412.14).abs() < 1.0, "1 kWh ≈ 3412 BTU, got {btu}");
435 }
436
437 #[test]
438 fn unit_convert_gallons_to_liters() {
439 let l = convert(1.0, "gallon", "liter").unwrap();
441 assert!((l - 3.785).abs() < 0.01);
442 }
443
444 #[test]
445 fn unit_convert_hours_to_seconds() {
446 let s = convert(1.0, "hour", "second").unwrap();
447 assert!((s - 3600.0).abs() < 0.01);
448 }
449
450 #[test]
451 fn unit_convert_identity() {
452 let v = convert(42.0, "celsius", "celsius").unwrap();
453 assert!((v - 42.0).abs() < 1e-10);
454 }
455
456 #[test]
457 fn unit_convert_incompatible() {
458 let err = convert(1.0, "celsius", "meter").unwrap_err();
459 assert!(matches!(err, UnitError::IncompatibleUnits(_, _)));
460 }
461
462 #[test]
463 fn unit_convert_unknown() {
464 let err = convert(1.0, "nonexistent_xyz", "celsius").unwrap_err();
465 assert!(matches!(err, UnitError::UnknownUnit(_)));
466 }
467
468 #[test]
469 fn unit_compatible_same_quantity() {
470 assert!(compatible("fahrenheit", "celsius"));
471 assert!(compatible("°F", "°C"));
472 assert!(compatible("meter", "foot"));
473 assert!(compatible("psi", "bar"));
474 }
475
476 #[test]
477 fn unit_compatible_different_quantity() {
478 assert!(!compatible("celsius", "meter"));
479 assert!(!compatible("watt", "joule"));
480 }
481
482 #[test]
483 fn unit_compatible_unknown() {
484 assert!(!compatible("nonexistent_xyz", "celsius"));
485 }
486
487 #[test]
488 fn unit_quantity_lookup() {
489 assert_eq!(quantity("fahrenheit"), Some("temperature"));
490 assert_eq!(quantity("meter"), Some("length"));
491 assert_eq!(quantity("psi"), Some("pressure"));
492 assert_eq!(quantity("nonexistent_xyz"), None);
493 }
494
495 #[test]
496 fn unit_base_unit_lookup() {
497 assert_eq!(base_unit("temperature"), Some("celsius"));
498 assert_eq!(base_unit("length"), Some("meter"));
499 assert_eq!(base_unit("pressure"), Some("pascal"));
500 assert_eq!(base_unit("energy"), Some("joule"));
501 assert_eq!(base_unit("power"), Some("watt"));
502 assert_eq!(base_unit("volume"), Some("cubic_meter"));
503 assert_eq!(
504 base_unit("volumetric flow"),
505 Some("cubic_meters_per_second")
506 );
507 assert_eq!(base_unit("area"), Some("square_meter"));
508 assert_eq!(base_unit("mass"), Some("kilogram"));
509 assert_eq!(base_unit("time"), Some("second"));
510 assert_eq!(base_unit("electric current"), Some("ampere"));
511 assert_eq!(base_unit("electric potential"), Some("volt"));
512 assert_eq!(base_unit("frequency"), Some("hertz"));
513 assert_eq!(base_unit("illuminance"), Some("lux"));
514 assert_eq!(base_unit("luminous flux"), Some("lumen"));
515 assert_eq!(base_unit("nonexistent"), None);
516 }
517
518 #[test]
519 fn unit_error_display() {
520 let e = UnitError::UnknownUnit("bogus".into());
521 assert_eq!(e.to_string(), "unknown unit: bogus");
522 let e = UnitError::IncompatibleUnits("celsius".into(), "meter".into());
523 assert_eq!(e.to_string(), "incompatible units: celsius and meter");
524 }
525}