Skip to main content

jpx_core/extensions/
units.rs

1//! Unit conversion functions for temperature, length, mass, and volume.
2
3use std::collections::HashSet;
4
5use serde_json::Value;
6
7use crate::functions::{Function, custom_error, number_value};
8use crate::interpreter::SearchResult;
9use crate::registry::register_if_enabled;
10use crate::{Context, Runtime, arg, defn};
11
12/// Register unit conversion functions that are in the enabled set.
13pub fn register_filtered(runtime: &mut Runtime, enabled: &HashSet<&str>) {
14    register_if_enabled(
15        runtime,
16        "convert_temperature",
17        enabled,
18        Box::new(ConvertTemperatureFn::new()),
19    );
20    register_if_enabled(
21        runtime,
22        "convert_length",
23        enabled,
24        Box::new(ConvertLengthFn::new()),
25    );
26    register_if_enabled(
27        runtime,
28        "convert_mass",
29        enabled,
30        Box::new(ConvertMassFn::new()),
31    );
32    register_if_enabled(
33        runtime,
34        "convert_volume",
35        enabled,
36        Box::new(ConvertVolumeFn::new()),
37    );
38}
39
40// =============================================================================
41// convert_temperature(value, from_unit, to_unit) -> number
42// =============================================================================
43
44defn!(
45    ConvertTemperatureFn,
46    vec![arg!(number), arg!(string), arg!(string)],
47    None
48);
49
50fn normalize_temp_unit(unit: &str) -> Option<&'static str> {
51    match unit.to_lowercase().as_str() {
52        "c" | "celsius" => Some("C"),
53        "f" | "fahrenheit" => Some("F"),
54        "k" | "kelvin" => Some("K"),
55        _ => None,
56    }
57}
58
59fn to_celsius(value: f64, from: &str) -> f64 {
60    match from {
61        "C" => value,
62        "F" => (value - 32.0) * 5.0 / 9.0,
63        "K" => value - 273.15,
64        _ => unreachable!(),
65    }
66}
67
68fn from_celsius(value: f64, to: &str) -> f64 {
69    match to {
70        "C" => value,
71        "F" => value * 9.0 / 5.0 + 32.0,
72        "K" => value + 273.15,
73        _ => unreachable!(),
74    }
75}
76
77impl Function for ConvertTemperatureFn {
78    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
79        self.signature.validate(args, ctx)?;
80
81        let value = args[0].as_f64().unwrap();
82        let from_str = args[1].as_str().unwrap();
83        let to_str = args[2].as_str().unwrap();
84
85        let from = normalize_temp_unit(from_str)
86            .ok_or_else(|| custom_error(ctx, &format!("unknown temperature unit: {}", from_str)))?;
87        let to = normalize_temp_unit(to_str)
88            .ok_or_else(|| custom_error(ctx, &format!("unknown temperature unit: {}", to_str)))?;
89
90        let celsius = to_celsius(value, from);
91        let result = from_celsius(celsius, to);
92
93        Ok(number_value(result))
94    }
95}
96
97// =============================================================================
98// convert_length(value, from_unit, to_unit) -> number
99// =============================================================================
100
101defn!(
102    ConvertLengthFn,
103    vec![arg!(number), arg!(string), arg!(string)],
104    None
105);
106
107/// Returns the factor to convert from the given unit to meters.
108fn length_to_meters(unit: &str) -> Option<f64> {
109    match unit.to_lowercase().as_str() {
110        "m" | "meters" => Some(1.0),
111        "km" | "kilometers" => Some(1000.0),
112        "cm" | "centimeters" => Some(0.01),
113        "mm" | "millimeters" => Some(0.001),
114        "mi" | "miles" => Some(1609.344),
115        "ft" | "feet" => Some(0.3048),
116        "in" | "inches" => Some(0.0254),
117        "yd" | "yards" => Some(0.9144),
118        "nmi" | "nautical_miles" => Some(1852.0),
119        _ => None,
120    }
121}
122
123impl Function for ConvertLengthFn {
124    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
125        self.signature.validate(args, ctx)?;
126
127        let value = args[0].as_f64().unwrap();
128        let from_str = args[1].as_str().unwrap();
129        let to_str = args[2].as_str().unwrap();
130
131        let from_factor = length_to_meters(from_str)
132            .ok_or_else(|| custom_error(ctx, &format!("unknown length unit: {}", from_str)))?;
133        let to_factor = length_to_meters(to_str)
134            .ok_or_else(|| custom_error(ctx, &format!("unknown length unit: {}", to_str)))?;
135
136        let meters = value * from_factor;
137        let result = meters / to_factor;
138
139        Ok(number_value(result))
140    }
141}
142
143// =============================================================================
144// convert_mass(value, from_unit, to_unit) -> number
145// =============================================================================
146
147defn!(
148    ConvertMassFn,
149    vec![arg!(number), arg!(string), arg!(string)],
150    None
151);
152
153/// Returns the factor to convert from the given unit to kilograms.
154fn mass_to_kg(unit: &str) -> Option<f64> {
155    match unit.to_lowercase().as_str() {
156        "kg" | "kilograms" => Some(1.0),
157        "g" | "grams" => Some(0.001),
158        "mg" | "milligrams" => Some(0.000001),
159        "lbs" | "pounds" => Some(0.45359237),
160        "oz" | "ounces" => Some(0.028349523125),
161        "t" | "tonnes" => Some(1000.0),
162        "st" | "stones" => Some(6.35029318),
163        _ => None,
164    }
165}
166
167impl Function for ConvertMassFn {
168    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
169        self.signature.validate(args, ctx)?;
170
171        let value = args[0].as_f64().unwrap();
172        let from_str = args[1].as_str().unwrap();
173        let to_str = args[2].as_str().unwrap();
174
175        let from_factor = mass_to_kg(from_str)
176            .ok_or_else(|| custom_error(ctx, &format!("unknown mass unit: {}", from_str)))?;
177        let to_factor = mass_to_kg(to_str)
178            .ok_or_else(|| custom_error(ctx, &format!("unknown mass unit: {}", to_str)))?;
179
180        let kg = value * from_factor;
181        let result = kg / to_factor;
182
183        Ok(number_value(result))
184    }
185}
186
187// =============================================================================
188// convert_volume(value, from_unit, to_unit) -> number
189// =============================================================================
190
191defn!(
192    ConvertVolumeFn,
193    vec![arg!(number), arg!(string), arg!(string)],
194    None
195);
196
197/// Returns the factor to convert from the given unit to liters.
198fn volume_to_liters(unit: &str) -> Option<f64> {
199    match unit.to_lowercase().as_str() {
200        "l" | "liters" => Some(1.0),
201        "ml" | "milliliters" => Some(0.001),
202        "gal" | "gallons" => Some(3.785411784),
203        "qt" | "quarts" => Some(0.946352946),
204        "pt" | "pints" => Some(0.473176473),
205        "cup" | "cups" => Some(0.2365882365),
206        "floz" | "fluid_ounces" => Some(0.0295735295625),
207        "tbsp" | "tablespoons" => Some(0.0147867647813),
208        "tsp" | "teaspoons" => Some(0.00492892159375),
209        _ => None,
210    }
211}
212
213impl Function for ConvertVolumeFn {
214    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
215        self.signature.validate(args, ctx)?;
216
217        let value = args[0].as_f64().unwrap();
218        let from_str = args[1].as_str().unwrap();
219        let to_str = args[2].as_str().unwrap();
220
221        let from_factor = volume_to_liters(from_str)
222            .ok_or_else(|| custom_error(ctx, &format!("unknown volume unit: {}", from_str)))?;
223        let to_factor = volume_to_liters(to_str)
224            .ok_or_else(|| custom_error(ctx, &format!("unknown volume unit: {}", to_str)))?;
225
226        let liters = value * from_factor;
227        let result = liters / to_factor;
228
229        Ok(number_value(result))
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use crate::Runtime;
236    use serde_json::json;
237
238    fn setup_runtime() -> Runtime {
239        Runtime::builder()
240            .with_standard()
241            .with_all_extensions()
242            .build()
243    }
244
245    // =========================================================================
246    // Temperature tests
247    // =========================================================================
248
249    #[test]
250    fn test_celsius_to_fahrenheit() {
251        let runtime = setup_runtime();
252        let expr = runtime
253            .compile("convert_temperature(`100`, 'C', 'F')")
254            .unwrap();
255        let result = expr.search(&json!(null)).unwrap();
256        assert!((result.as_f64().unwrap() - 212.0).abs() < 1e-9);
257    }
258
259    #[test]
260    fn test_fahrenheit_to_celsius() {
261        let runtime = setup_runtime();
262        let expr = runtime
263            .compile("convert_temperature(`32`, 'F', 'C')")
264            .unwrap();
265        let result = expr.search(&json!(null)).unwrap();
266        assert!((result.as_f64().unwrap() - 0.0).abs() < 1e-9);
267    }
268
269    #[test]
270    fn test_celsius_to_kelvin() {
271        let runtime = setup_runtime();
272        let expr = runtime
273            .compile("convert_temperature(`0`, 'C', 'K')")
274            .unwrap();
275        let result = expr.search(&json!(null)).unwrap();
276        assert!((result.as_f64().unwrap() - 273.15).abs() < 1e-9);
277    }
278
279    #[test]
280    fn test_kelvin_to_celsius() {
281        let runtime = setup_runtime();
282        let expr = runtime
283            .compile("convert_temperature(`273.15`, 'K', 'C')")
284            .unwrap();
285        let result = expr.search(&json!(null)).unwrap();
286        assert!((result.as_f64().unwrap() - 0.0).abs() < 1e-9);
287    }
288
289    #[test]
290    fn test_fahrenheit_to_kelvin() {
291        let runtime = setup_runtime();
292        let expr = runtime
293            .compile("convert_temperature(`212`, 'fahrenheit', 'kelvin')")
294            .unwrap();
295        let result = expr.search(&json!(null)).unwrap();
296        assert!((result.as_f64().unwrap() - 373.15).abs() < 1e-9);
297    }
298
299    #[test]
300    fn test_temperature_same_unit() {
301        let runtime = setup_runtime();
302        let expr = runtime
303            .compile("convert_temperature(`42`, 'C', 'celsius')")
304            .unwrap();
305        let result = expr.search(&json!(null)).unwrap();
306        assert!((result.as_f64().unwrap() - 42.0).abs() < 1e-9);
307    }
308
309    #[test]
310    fn test_temperature_invalid_unit() {
311        let runtime = setup_runtime();
312        let expr = runtime
313            .compile("convert_temperature(`100`, 'C', 'invalid')")
314            .unwrap();
315        let result = expr.search(&json!(null));
316        assert!(result.is_err());
317    }
318
319    // =========================================================================
320    // Length tests
321    // =========================================================================
322
323    #[test]
324    fn test_km_to_miles() {
325        let runtime = setup_runtime();
326        let expr = runtime.compile("convert_length(`1`, 'km', 'mi')").unwrap();
327        let result = expr.search(&json!(null)).unwrap();
328        assert!((result.as_f64().unwrap() - 0.621371192237334).abs() < 1e-6);
329    }
330
331    #[test]
332    fn test_miles_to_km() {
333        let runtime = setup_runtime();
334        let expr = runtime.compile("convert_length(`1`, 'mi', 'km')").unwrap();
335        let result = expr.search(&json!(null)).unwrap();
336        assert!((result.as_f64().unwrap() - 1.609344).abs() < 1e-6);
337    }
338
339    #[test]
340    fn test_feet_to_meters() {
341        let runtime = setup_runtime();
342        let expr = runtime.compile("convert_length(`1`, 'ft', 'm')").unwrap();
343        let result = expr.search(&json!(null)).unwrap();
344        assert!((result.as_f64().unwrap() - 0.3048).abs() < 1e-6);
345    }
346
347    #[test]
348    fn test_inches_to_cm() {
349        let runtime = setup_runtime();
350        let expr = runtime.compile("convert_length(`1`, 'in', 'cm')").unwrap();
351        let result = expr.search(&json!(null)).unwrap();
352        assert!((result.as_f64().unwrap() - 2.54).abs() < 1e-6);
353    }
354
355    #[test]
356    fn test_yards_to_meters() {
357        let runtime = setup_runtime();
358        let expr = runtime.compile("convert_length(`1`, 'yd', 'm')").unwrap();
359        let result = expr.search(&json!(null)).unwrap();
360        assert!((result.as_f64().unwrap() - 0.9144).abs() < 1e-6);
361    }
362
363    #[test]
364    fn test_nautical_miles_to_km() {
365        let runtime = setup_runtime();
366        let expr = runtime.compile("convert_length(`1`, 'nmi', 'km')").unwrap();
367        let result = expr.search(&json!(null)).unwrap();
368        assert!((result.as_f64().unwrap() - 1.852).abs() < 1e-6);
369    }
370
371    #[test]
372    fn test_length_same_unit() {
373        let runtime = setup_runtime();
374        let expr = runtime
375            .compile("convert_length(`42`, 'meters', 'm')")
376            .unwrap();
377        let result = expr.search(&json!(null)).unwrap();
378        assert!((result.as_f64().unwrap() - 42.0).abs() < 1e-9);
379    }
380
381    #[test]
382    fn test_length_invalid_unit() {
383        let runtime = setup_runtime();
384        let expr = runtime
385            .compile("convert_length(`1`, 'm', 'furlongs')")
386            .unwrap();
387        let result = expr.search(&json!(null));
388        assert!(result.is_err());
389    }
390
391    // =========================================================================
392    // Mass tests
393    // =========================================================================
394
395    #[test]
396    fn test_kg_to_lbs() {
397        let runtime = setup_runtime();
398        let expr = runtime.compile("convert_mass(`1`, 'kg', 'lbs')").unwrap();
399        let result = expr.search(&json!(null)).unwrap();
400        assert!((result.as_f64().unwrap() - 2.20462262185).abs() < 1e-4);
401    }
402
403    #[test]
404    fn test_lbs_to_kg() {
405        let runtime = setup_runtime();
406        let expr = runtime.compile("convert_mass(`1`, 'lbs', 'kg')").unwrap();
407        let result = expr.search(&json!(null)).unwrap();
408        assert!((result.as_f64().unwrap() - 0.45359237).abs() < 1e-6);
409    }
410
411    #[test]
412    fn test_grams_to_ounces() {
413        let runtime = setup_runtime();
414        let expr = runtime.compile("convert_mass(`1000`, 'g', 'oz')").unwrap();
415        let result = expr.search(&json!(null)).unwrap();
416        // 1000g = 1kg = ~35.274 oz
417        assert!((result.as_f64().unwrap() - 35.27396195).abs() < 1e-3);
418    }
419
420    #[test]
421    fn test_tonnes_to_kg() {
422        let runtime = setup_runtime();
423        let expr = runtime.compile("convert_mass(`1`, 't', 'kg')").unwrap();
424        let result = expr.search(&json!(null)).unwrap();
425        assert!((result.as_f64().unwrap() - 1000.0).abs() < 1e-9);
426    }
427
428    #[test]
429    fn test_stones_to_lbs() {
430        let runtime = setup_runtime();
431        let expr = runtime.compile("convert_mass(`1`, 'st', 'lbs')").unwrap();
432        let result = expr.search(&json!(null)).unwrap();
433        assert!((result.as_f64().unwrap() - 14.0).abs() < 0.01);
434    }
435
436    #[test]
437    fn test_mass_same_unit() {
438        let runtime = setup_runtime();
439        let expr = runtime
440            .compile("convert_mass(`42`, 'kilograms', 'kg')")
441            .unwrap();
442        let result = expr.search(&json!(null)).unwrap();
443        assert!((result.as_f64().unwrap() - 42.0).abs() < 1e-9);
444    }
445
446    #[test]
447    fn test_mass_invalid_unit() {
448        let runtime = setup_runtime();
449        let expr = runtime
450            .compile("convert_mass(`1`, 'kg', 'bushels')")
451            .unwrap();
452        let result = expr.search(&json!(null));
453        assert!(result.is_err());
454    }
455
456    // =========================================================================
457    // Volume tests
458    // =========================================================================
459
460    #[test]
461    fn test_gallons_to_liters() {
462        let runtime = setup_runtime();
463        let expr = runtime.compile("convert_volume(`1`, 'gal', 'l')").unwrap();
464        let result = expr.search(&json!(null)).unwrap();
465        assert!((result.as_f64().unwrap() - 3.785411784).abs() < 1e-6);
466    }
467
468    #[test]
469    fn test_liters_to_ml() {
470        let runtime = setup_runtime();
471        let expr = runtime.compile("convert_volume(`1`, 'l', 'ml')").unwrap();
472        let result = expr.search(&json!(null)).unwrap();
473        assert!((result.as_f64().unwrap() - 1000.0).abs() < 1e-6);
474    }
475
476    #[test]
477    fn test_cups_to_ml() {
478        let runtime = setup_runtime();
479        let expr = runtime.compile("convert_volume(`1`, 'cup', 'ml')").unwrap();
480        let result = expr.search(&json!(null)).unwrap();
481        assert!((result.as_f64().unwrap() - 236.5882365).abs() < 0.01);
482    }
483
484    #[test]
485    fn test_tbsp_to_tsp() {
486        let runtime = setup_runtime();
487        let expr = runtime
488            .compile("convert_volume(`1`, 'tbsp', 'tsp')")
489            .unwrap();
490        let result = expr.search(&json!(null)).unwrap();
491        assert!((result.as_f64().unwrap() - 3.0).abs() < 0.01);
492    }
493
494    #[test]
495    fn test_quarts_to_pints() {
496        let runtime = setup_runtime();
497        let expr = runtime.compile("convert_volume(`1`, 'qt', 'pt')").unwrap();
498        let result = expr.search(&json!(null)).unwrap();
499        assert!((result.as_f64().unwrap() - 2.0).abs() < 0.01);
500    }
501
502    #[test]
503    fn test_floz_to_ml() {
504        let runtime = setup_runtime();
505        let expr = runtime
506            .compile("convert_volume(`1`, 'floz', 'ml')")
507            .unwrap();
508        let result = expr.search(&json!(null)).unwrap();
509        assert!((result.as_f64().unwrap() - 29.5735295625).abs() < 0.01);
510    }
511
512    #[test]
513    fn test_volume_same_unit() {
514        let runtime = setup_runtime();
515        let expr = runtime
516            .compile("convert_volume(`42`, 'liters', 'l')")
517            .unwrap();
518        let result = expr.search(&json!(null)).unwrap();
519        assert!((result.as_f64().unwrap() - 42.0).abs() < 1e-9);
520    }
521
522    #[test]
523    fn test_volume_invalid_unit() {
524        let runtime = setup_runtime();
525        let expr = runtime
526            .compile("convert_volume(`1`, 'l', 'barrels')")
527            .unwrap();
528        let result = expr.search(&json!(null));
529        assert!(result.is_err());
530    }
531
532    // =========================================================================
533    // Data-driven tests
534    // =========================================================================
535
536    #[test]
537    fn test_convert_from_json_data() {
538        let runtime = setup_runtime();
539        let data = json!({"temp": 100, "from": "C", "to": "F"});
540        let expr = runtime
541            .compile("convert_temperature(temp, from, to)")
542            .unwrap();
543        let result = expr.search(&data).unwrap();
544        assert!((result.as_f64().unwrap() - 212.0).abs() < 1e-9);
545    }
546}