Skip to main content

gnu_units/
lib.rs

1//! Safe, high-level Rust interface to GNU Units.
2//!
3//! By default this crate uses a native pure-Rust unit engine.
4//! Build with `--no-default-features --features vendored` to use the vendored C library instead.
5
6use std::fmt;
7
8/// The vendored C-bindings crate. Only available when `features = ["vendored"]`.
9#[cfg(feature = "vendored")]
10pub use gnu_units_sys;
11
12#[cfg(feature = "currency-update")]
13pub mod currency_update;
14
15pub(crate) mod definitions;
16mod engine;
17mod units;
18
19use self::definitions::{DEFINITIONS, ensure_definitions};
20pub use self::definitions::{Definition, DefinitionKind};
21
22#[cfg(feature = "currency-update")]
23use self::definitions::load_definitions;
24
25#[cfg(feature = "currency-update")]
26pub use self::currency_update::{
27    CurrencySource, CurrencyUpdateOptions, UpdateError, fetch_currency_updates,
28};
29
30/// Numeric error codes matching the GNU units C library values.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[repr(i32)]
33pub enum ErrorCode {
34    /// Successful conversion (no error).
35    Normal = 0,
36    /// Expression could not be parsed.
37    Parse = 1,
38    /// Incompatible dimensions in addition or subtraction.
39    BadSum = 5,
40    /// Result is not a dimensionless number.
41    NotANumber = 6,
42    /// Root of a non-integer dimension.
43    NotRoot = 7,
44    /// Unit name not found in the database.
45    UnknownUnit = 8,
46    /// Function argument outside its valid domain.
47    NotInDomain = 10,
48    /// Function argument has wrong dimensions.
49    BadFuncArg = 11,
50    /// Dimensional unit raised to an irrational exponent.
51    IrrationalExponent = 12,
52    /// Expression is not a function or table (used for fallback logic).
53    NotAFunc = 13,
54    /// Invalid numeric literal.
55    BadNum = 20,
56}
57
58impl ErrorCode {
59    #[cfg(feature = "vendored")]
60    pub(crate) fn from_raw(code: i32) -> Self {
61        match code {
62            0 => Self::Normal,
63            1 => Self::Parse,
64            5 => Self::BadSum,
65            6 => Self::NotANumber,
66            7 => Self::NotRoot,
67            8 => Self::UnknownUnit,
68            10 => Self::NotInDomain,
69            11 => Self::BadFuncArg,
70            12 => Self::IrrationalExponent,
71            13 => Self::NotAFunc,
72            20 => Self::BadNum,
73            _ => Self::Parse,
74        }
75    }
76}
77
78/// Wraps a raw error code returned by the units engine.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct UnitsError(ErrorCode);
81
82impl UnitsError {
83    /// Returns the error code as defined in [`ErrorCode`].
84    pub fn code(&self) -> ErrorCode {
85        self.0
86    }
87
88    /// Returns `true` when the error indicates a dimensionless reduction failed.
89    #[cfg(test)]
90    pub fn is_not_dimensionless(&self) -> bool {
91        self.0 == ErrorCode::NotANumber
92    }
93
94    /// Returns `true` when the expression was invalid (unknown/unparseable).
95    #[cfg(test)]
96    pub fn is_invalid_unit(&self) -> bool {
97        self.0 == ErrorCode::UnknownUnit || self.0 == ErrorCode::Parse
98    }
99}
100
101impl fmt::Display for UnitsError {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(f, "GNU units error code {}", self.0 as i32)
104    }
105}
106
107impl std::error::Error for UnitsError {}
108
109/// Convenience alias for [`std::result::Result<T, UnitsError>`].
110pub type Result<T> = std::result::Result<T, UnitsError>;
111
112/// Represents a dimensional quantity: a numeric factor paired with zero or
113/// more base dimensions.
114pub struct Unit {
115    raw: engine::RawUnit,
116}
117
118// SAFETY: For the vendored backend, all FFI calls are serialised through a
119// global mutex. The raw unittype is not accessed concurrently.
120#[cfg(feature = "vendored")]
121unsafe impl Send for Unit {}
122#[cfg(feature = "vendored")]
123unsafe impl Sync for Unit {}
124
125impl Unit {
126    /// Creates a freshly initialised unit with factor `1.0` and no dimensions.
127    pub fn new() -> Self {
128        Self {
129            raw: engine::unit_new(),
130        }
131    }
132
133    /// Parses a GNU units expression string and returns the resulting [`Unit`].
134    pub fn parse(input: &str) -> Result<Self> {
135        ensure_definitions();
136        engine::unit_parse(input).map(|raw| Self { raw })
137    }
138
139    /// Returns the numeric factor of the unit.
140    pub fn factor(&self) -> f64 {
141        engine::unit_factor(&self.raw)
142    }
143
144    /// Returns the base dimensions as a human-readable string.
145    pub fn base_units(&self) -> String {
146        engine::unit_base_units(&self.raw)
147    }
148
149    /// Multiplies `self` by `rhs` in place.
150    pub fn multiply(&mut self, rhs: Unit) -> Result<()> {
151        engine::unit_multiply(&mut self.raw, &rhs.raw)
152    }
153
154    /// Divides `self` by `rhs` in place.
155    pub fn divide(&mut self, rhs: Unit) -> Result<()> {
156        engine::unit_divide(&mut self.raw, &rhs.raw)
157    }
158
159    /// Adds `rhs` to `self` in place.
160    pub fn add(&mut self, rhs: Unit) -> Result<()> {
161        engine::unit_add(&mut self.raw, &rhs.raw)
162    }
163
164    /// Inverts `self` in place (reciprocal).
165    pub fn invert(&mut self) {
166        engine::unit_invert(&mut self.raw);
167    }
168
169    /// Raises `self` to a non-negative integer `power` in place.
170    pub fn pow(&mut self, power: i32) -> Result<()> {
171        engine::unit_pow(&mut self.raw, power)
172    }
173
174    /// Takes the `n`-th root of `self` in place.
175    pub fn root(&mut self, n: i32) -> Result<()> {
176        engine::unit_root(&mut self.raw, n)
177    }
178
179    /// Converts a dimensionless unit to its numeric value.
180    pub fn to_number(&self) -> Result<f64> {
181        engine::unit_to_number(&self.raw)
182    }
183
184    /// Converts `self` into the unit expressed by `to`, returning the numeric
185    /// conversion factor.
186    pub fn convert_to(mut self, to: Unit) -> Result<f64> {
187        self.divide(to)?;
188        self.to_number()
189    }
190
191    /// Returns `true` when `self` and `other` have the same base dimensions.
192    pub fn is_conformable(&self, other: &Unit) -> bool {
193        engine::unit_is_conformable(&self.raw, &other.raw)
194    }
195}
196
197impl Default for Unit {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203impl Clone for Unit {
204    fn clone(&self) -> Self {
205        Self {
206            raw: engine::unit_clone(&self.raw),
207        }
208    }
209}
210
211impl Drop for Unit {
212    fn drop(&mut self) {
213        engine::unit_drop(&mut self.raw);
214    }
215}
216
217/// Convenience wrapper around [`Unit::parse`].
218pub fn parse(input: &str) -> Result<Unit> {
219    Unit::parse(input)
220}
221
222/// Parses `from` and `to` as GNU units expressions and returns the numeric
223/// conversion factor.
224///
225/// First attempts a function or table conversion; if neither applies
226/// ([`ErrorCode::NotAFunc`]), falls back to standard unit division.
227pub fn convert(from: &str, to: &str) -> Result<f64> {
228    ensure_definitions();
229    match engine::convert_func(from, to) {
230        Err(e) if e.code() == ErrorCode::NotAFunc => {}
231        result => return result,
232    }
233    Unit::parse(from)?.convert_to(Unit::parse(to)?)
234}
235
236/// Returns all unit definitions conformable with `expr` in alphabetical order.
237pub fn conformable(expr: &str) -> Result<Vec<String>> {
238    let target = Unit::parse(expr)?;
239    let defs = list_definitions();
240    let names = defs
241        .iter()
242        .filter(|d| d.kind == DefinitionKind::Unit)
243        .filter_map(|d| {
244            let parsed = Unit::parse(&d.name).ok()?;
245            if parsed.is_conformable(&target) {
246                Some(d.name.clone())
247            } else {
248                None
249            }
250        })
251        .collect();
252    Ok(names)
253}
254
255/// Returns all unit definitions from the embedded GNU units database, sorted
256/// alphabetically.
257pub fn list_definitions() -> Vec<Definition> {
258    ensure_definitions();
259    DEFINITIONS
260        .read()
261        .unwrap_or_else(|e| e.into_inner())
262        .clone()
263}
264
265/// Reloads currency unit definitions from a GNU units currency file string.
266#[cfg(feature = "currency-update")]
267pub fn reload_currency(content: &str) {
268    ensure_definitions();
269    let new_defs = load_definitions(content, c"currency.units");
270    let mut defs = DEFINITIONS.write().unwrap_or_else(|e| e.into_inner());
271    defs.retain(|d| d.kind != DefinitionKind::Unit || !new_defs.iter().any(|n| n.name == d.name));
272    defs.extend(new_defs);
273    defs.sort_by(|a, b| a.name.cmp(&b.name));
274}
275
276// Required so that `Result<Unit, UnitsError>::unwrap_err()` compiles in tests
277// (unwrap_err needs T: Debug to format the unexpected Ok value).
278#[cfg(test)]
279impl fmt::Debug for Unit {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        write!(f, "Unit {{ factor: {} }}", self.factor())
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use rstest::rstest;
288
289    use super::*;
290    use crate::definitions::{ensure_definitions, replace_operators};
291
292    #[test]
293    fn units_error_display() {
294        let err = UnitsError(ErrorCode::BadNum);
295
296        let s = format!("{err}");
297
298        assert!(s.contains("20"));
299    }
300
301    #[rstest]
302    #[case::eq_same_code(ErrorCode::Parse, ErrorCode::Parse, true)]
303    #[case::ne_different_code(ErrorCode::Parse, ErrorCode::BadSum, false)]
304    fn units_error_eq_semantics(#[case] a: ErrorCode, #[case] b: ErrorCode, #[case] equal: bool) {
305        let ea = UnitsError(a);
306        let eb = UnitsError(b);
307
308        assert_eq!(ea == eb, equal);
309    }
310
311    #[test]
312    fn units_error_copy_semantics() {
313        let original = UnitsError(ErrorCode::BadSum);
314
315        let copy = original;
316
317        assert_eq!(copy, original);
318    }
319
320    #[rstest]
321    #[case::notanumber(ErrorCode::NotANumber, true)]
322    #[case::e_parse(ErrorCode::Parse, false)]
323    #[case::e_badsum(ErrorCode::BadSum, false)]
324    fn is_not_dimensionless(#[case] code: ErrorCode, #[case] expected: bool) {
325        let err = UnitsError(code);
326
327        assert_eq!(err.is_not_dimensionless(), expected);
328    }
329
330    #[rstest]
331    #[case::e_parse(ErrorCode::Parse, true)]
332    #[case::e_unknownunit(ErrorCode::UnknownUnit, true)]
333    #[case::e_notanumber(ErrorCode::NotANumber, false)]
334    fn is_invalid_unit(#[case] code: ErrorCode, #[case] expected: bool) {
335        let err = UnitsError(code);
336
337        assert_eq!(err.is_invalid_unit(), expected);
338    }
339
340    #[rstest]
341    #[case::via_new(Unit::new())]
342    #[case::via_default(Unit::default())]
343    fn initial_factor_is_one(#[case] unit: Unit) {
344        assert_eq!(unit.factor(), 1.0);
345    }
346
347    #[test]
348    fn error_on_unary_plus() {
349        let result = Unit::parse("+5");
350
351        assert!(result.is_err());
352    }
353
354    #[rstest]
355    #[case::hex_literal("0xff", 255.0)]
356    #[case::negative_exponent_dimensionless("2^-1", 0.5)]
357    fn parse_numeric(#[case] input: &str, #[case] expected: f64) {
358        let result = Unit::parse(input);
359
360        assert!(result.is_ok());
361        assert!((result.unwrap().factor() - expected).abs() < 1e-6);
362    }
363
364    #[test]
365    fn parse_hex_without_valid_digits_is_zero() {
366        let result = Unit::parse("0xGG");
367
368        assert!(result.is_ok());
369        assert_eq!(result.unwrap().factor(), 0.0);
370    }
371
372    /// Malformed numeric literals and trailing input must produce `E_PARSE`,
373    /// not `E_UNKNOWNUNIT` — ensures the parser gate is hit before unit lookup.
374    #[rstest]
375    #[case::error_on_trailing_paren("2 )")]
376    fn parse_error_code_is_e_parse(#[case] input: &str) {
377        let result = Unit::parse(input);
378
379        let err = result.unwrap_err();
380        assert_eq!(err.code(), ErrorCode::Parse);
381    }
382
383    #[test]
384    fn parse_m_inverse_has_inverse_dimension() {
385        let unit = Unit::parse("m^-1").unwrap();
386
387        let base = unit.base_units();
388
389        assert_eq!(unit.factor(), 1.0);
390        assert!(
391            base.contains("/ m"),
392            "expected '/ m' in base_units, got: {base}"
393        );
394    }
395
396    #[test]
397    fn clone_preserves_factor() {
398        let original = Unit::parse("5").unwrap();
399
400        let cloned = original.clone();
401
402        assert_eq!(cloned.factor(), original.factor());
403    }
404
405    #[test]
406    fn to_number_returns_factor() {
407        let unit = Unit::parse("42").unwrap();
408
409        let result = unit.to_number();
410
411        assert!(result.is_ok());
412        assert_eq!(result.unwrap(), 42.0);
413    }
414
415    #[test]
416    fn all_definitions_have_non_empty_names() {
417        let defs = list_definitions();
418
419        for d in defs.iter() {
420            assert!(!d.name.is_empty(), "found a definition with an empty name");
421        }
422    }
423
424    #[rstest]
425    #[case::kilo_prefix("kilo-")]
426    #[case::hms("hms")]
427    fn list_definitions_contains_known_entry(#[case] name: &str) {
428        let defs = list_definitions();
429
430        assert!(
431            defs.iter().any(|d| d.name == name),
432            "expected '{name}' in definitions"
433        );
434    }
435
436    #[rstest]
437    #[case::tempc("tempC", DefinitionKind::Function)]
438    #[case::gasmark("gasmark", DefinitionKind::Table)]
439    fn list_definitions_contains_known_kind_entry(
440        #[case] canonical: &str,
441        #[case] kind: DefinitionKind,
442    ) {
443        let defs = list_definitions();
444
445        assert!(
446            defs.iter()
447                .any(|d| d.canonical_name() == canonical && d.kind == kind),
448            "expected '{canonical}' with kind {kind:?} in definitions"
449        );
450    }
451
452    #[test]
453    fn definition_kind_name_invariant() {
454        let defs = list_definitions();
455
456        for d in defs.iter() {
457            match d.kind {
458                DefinitionKind::Prefix => {
459                    assert!(
460                        d.name.ends_with('-'),
461                        "prefix '{}' must end with '-'",
462                        d.name
463                    );
464                }
465                DefinitionKind::Table => {
466                    assert!(d.name.contains('['), "table '{}' must contain '['", d.name);
467                }
468                DefinitionKind::Function => {
469                    assert!(
470                        d.name.contains('('),
471                        "function '{}' must contain '('",
472                        d.name
473                    );
474                }
475                _ => {}
476            }
477        }
478    }
479
480    #[rstest]
481    #[case::simple_meter("m", "m")]
482    #[case::compound("kg m/s^2", " / ")]
483    fn base_units_contains_expected(#[case] expr: &str, #[case] contains: &str) {
484        let unit = Unit::parse(expr).unwrap();
485
486        let base = unit.base_units();
487
488        assert!(
489            base.contains(contains),
490            "base_units('{expr}') = '{base}', expected it to contain '{contains}'"
491        );
492    }
493
494    #[test]
495    fn base_units_dimensionless_is_empty() {
496        let unit = Unit::parse("42").unwrap();
497
498        let base = unit.base_units();
499
500        assert_eq!(base, "");
501    }
502
503    #[test]
504    fn conformable_does_not_contain_wrong_domain() {
505        let result = conformable("km").unwrap();
506
507        assert!(!result.contains(&"kg".to_owned()));
508        assert!(!result.contains(&"s".to_owned()));
509    }
510
511    #[rstest]
512    #[case::en_dash('\u{2013}', "-")]
513    #[case::minus_sign('\u{2212}', "-")]
514    #[case::times_sign('\u{00D7}', "*")]
515    #[case::middle_dot('\u{00B7}', "*")]
516    #[case::division_sign('\u{00F7}', "/")]
517    #[case::fraction_slash('\u{2044}', "|")]
518    #[case::nbsp('\u{00A0}', " ")]
519    #[case::zero_width_space('\u{200B}', "")]
520    fn replace_operators_cases(#[case] input: char, #[case] expected: &str) {
521        let result = replace_operators(&input.to_string());
522
523        assert_eq!(result, expected);
524    }
525
526    #[test]
527    fn root_even_negative_is_error() {
528        let mut unit = Unit::parse("-4").unwrap();
529
530        let result = unit.root(2);
531
532        assert!(result.is_err(), "even root of negative should fail");
533    }
534
535    #[test]
536    fn root_odd_negative_is_error() {
537        let mut unit = Unit::parse("-8").unwrap();
538
539        let result = unit.root(3);
540
541        assert!(
542            result.is_err(),
543            "odd root of negative should fail (matching C behavior)"
544        );
545    }
546
547    #[rstest]
548    #[case::gasmark1("gasmark(1)", "degR", 734.67, 0.1)]
549    #[case::gasmark5("gasmark(5)", "degR", 834.67, 0.1)]
550    #[case::gasmark10("gasmark(10)", "degR", 959.67, 0.1)]
551    fn table_parse_factor(
552        #[case] input: &str,
553        #[case] to: &str,
554        #[case] expected_factor_in_degr: f64,
555        #[case] tol: f64,
556    ) {
557        let result = convert(input, to);
558
559        assert!(
560            result.is_ok(),
561            "convert({input:?}, {to:?}) failed: {:?}",
562            result.err()
563        );
564        let factor = result.unwrap();
565        assert!(
566            (factor - expected_factor_in_degr).abs() < tol,
567            "convert({input:?}, {to:?}) = {factor}, expected {expected_factor_in_degr}\u{00b1}{tol}"
568        );
569    }
570
571    #[rstest]
572    #[case::tempc_inverse("~tempC(0 K)", -273.15, 0.01)]
573    fn inverse_function_parses(#[case] input: &str, #[case] expected: f64, #[case] tol: f64) {
574        let unit = Unit::parse(input);
575
576        assert!(unit.is_ok(), "parse({input:?}) failed: {:?}", unit.err());
577        let factor = unit.unwrap().factor();
578        assert!(
579            (factor - expected).abs() < tol,
580            "parse({input:?}).factor() = {factor}, expected {expected}\u{00b1}{tol}"
581        );
582    }
583
584    #[rstest]
585    #[case::tempc_0("tempC(0)", 273.15, 0.01)]
586    #[case::tempc_100("tempC(100)", 373.15, 0.01)]
587    #[case::tempf_32("tempF(32)", 273.15, 0.01)]
588    #[case::tempf_212("tempF(212)", 373.15, 0.01)]
589    fn forward_function_to_kelvin(
590        #[case] input: &str,
591        #[case] expected_kelvin: f64,
592        #[case] tol: f64,
593    ) {
594        let result = convert(input, "K");
595
596        assert!(
597            result.is_ok(),
598            "convert({input:?}, \"K\") failed: {:?}",
599            result.err()
600        );
601        let factor = result.unwrap();
602        assert!(
603            (factor - expected_kelvin).abs() < tol,
604            "convert({input:?}, \"K\") = {factor}, expected {expected_kelvin}\u{00b1}{tol}"
605        );
606    }
607
608    #[rstest]
609    #[case::kilogram("kilogram", "gram", 1000.0, 1e-9)]
610    #[case::milligram("milligram", "gram", 0.001, 1e-9)]
611    #[case::megabyte("megabyte", "byte", 1e6, 1e-3)]
612    #[case::microsecond("microsecond", "second", 1e-6, 1e-15)]
613    fn prefix_resolution(
614        #[case] from: &str,
615        #[case] to: &str,
616        #[case] expected: f64,
617        #[case] tol: f64,
618    ) {
619        let result = convert(from, to);
620
621        assert!(
622            result.is_ok(),
623            "convert({from:?}, {to:?}) failed: {:?}",
624            result.err()
625        );
626        let factor = result.unwrap();
627        assert!(
628            (factor - expected).abs() < tol,
629            "convert({from:?}, {to:?}) = {factor}, expected {expected}\u{00b1}{tol}"
630        );
631    }
632
633    #[rstest]
634    #[case::reciprocal("1|2", 0.5)]
635    #[case::fraction_5_9("5|9", 5.0 / 9.0)]
636    #[case::numdiv_3_8("3|8", 0.375)]
637    fn pipe_factor(#[case] input: &str, #[case] expected: f64) {
638        let unit = Unit::parse(input).unwrap();
639
640        assert!(
641            (unit.factor() - expected).abs() < 1e-12,
642            "{input} factor = {}, expected {expected}",
643            unit.factor()
644        );
645    }
646
647    #[test]
648    fn list_definitions_returns_independent_copy() {
649        let mut first = list_definitions();
650        let original_len = first.len();
651        first.clear();
652
653        let second = list_definitions();
654
655        assert_eq!(second.len(), original_len);
656    }
657
658    /// `m^(1|3)` must fail: the native engine uses integer dimension exponents,
659    /// so root-3 of `m^1` is not representable (1 % 3 != 0).
660    /// GAP: to support fractional-power dimensional units, the engine would need
661    /// rational exponents in `UnitValue::dimensions`.
662    #[test]
663    fn rational_exponent_one_third() {
664        ensure_definitions();
665
666        let result = Unit::parse("m^(1|3)");
667
668        assert!(
669            result.is_err(),
670            "m^(1|3) should fail (integer-only dimension exponents)"
671        );
672    }
673
674    #[rstest]
675    #[case::fractional_exponent("8^(1|3)", 2.0)]
676    #[case::asin("asin(1)", std::f64::consts::FRAC_PI_2)]
677    #[case::cuberoot("cuberoot(27)", 3.0)]
678    #[case::round("round(3.7)", 4.0)]
679    fn builtin_factor(#[case] input: &str, #[case] expected: f64) {
680        ensure_definitions();
681
682        let v = Unit::parse(input).unwrap();
683
684        assert!(
685            (v.factor() - expected).abs() < 1e-9,
686            "{input} factor = {}, expected {expected}",
687            v.factor()
688        );
689    }
690
691    /// `3|8 m`: the `|` consumes only its two adjacent `power` operands (both
692    /// dimensionless), then `m` is juxtaposed onto the 0.375 result.
693    #[test]
694    fn pipe_numdiv_with_unit() {
695        ensure_definitions();
696
697        let v = Unit::parse("3|8 m").unwrap();
698
699        assert!(
700            (v.factor() - 0.375).abs() < 1e-12,
701            "3|8 m factor = {}, expected 0.375",
702            v.factor()
703        );
704        assert!(
705            v.base_units().contains('m'),
706            "3|8 m base_units = '{}', expected it to contain 'm'",
707            v.base_units()
708        );
709    }
710
711    /// `per meter` is equivalent to `1/m`; converting between them must give 1.
712    #[test]
713    fn per_keyword_as_division() {
714        ensure_definitions();
715
716        let result = convert("per meter", "1/m");
717
718        assert!(
719            result.is_ok(),
720            "convert(\"per meter\", \"1/m\") failed: {:?}",
721            result.err()
722        );
723        assert!(
724            (result.unwrap() - 1.0).abs() < 1e-9,
725            "convert(\"per meter\", \"1/m\") != 1.0"
726        );
727    }
728
729    #[test]
730    fn add_incompatible_dimensions_returns_badsum() {
731        let result = convert("m + kg", "m");
732
733        assert!(result.is_err());
734        assert_eq!(result.unwrap_err().code(), ErrorCode::BadSum);
735    }
736
737    #[cfg(feature = "native")]
738    #[test]
739    fn non_divisible_root_returns_not_root() {
740        ensure_definitions();
741
742        let result = Unit::parse("m^(1|3)");
743
744        assert!(result.is_err());
745        assert_eq!(result.unwrap_err().code(), ErrorCode::NotRoot);
746    }
747
748    #[cfg(feature = "native")]
749    #[test]
750    fn table_dimension_mismatch_returns_bad_func_arg() {
751        ensure_definitions();
752
753        let result = convert("5 m", "gasmark");
754
755        assert!(result.is_err());
756        assert_eq!(result.unwrap_err().code(), ErrorCode::BadFuncArg);
757    }
758
759    #[rstest]
760    #[case::error_on_ln_of_zero("ln(0)")]
761    #[case::error_on_ln_of_negative("ln(-1)")]
762    #[case::error_on_log_of_zero("log(0)")]
763    #[case::error_on_log_of_negative("log(-1)")]
764    #[case::error_on_log2_of_zero("log2(0)")]
765    fn error_on_builtin_domain(#[case] expr: &str) {
766        let result = convert(expr, "1");
767
768        assert!(result.is_err(), "convert({expr:?}, \"1\") should fail");
769    }
770
771    #[rstest]
772    #[case::error_on_to_number_dimensional("m", ErrorCode::NotANumber)]
773    #[case::error_on_to_number_compound("kg m/s^2", ErrorCode::NotANumber)]
774    fn error_on_to_number(#[case] input: &str, #[case] expected_code: ErrorCode) {
775        let unit = Unit::parse(input).unwrap();
776
777        let result = unit.to_number();
778
779        assert!(result.is_err());
780        assert_eq!(result.unwrap_err().code(), expected_code);
781    }
782
783    #[test]
784    fn error_on_add_incompatible_dimensions() {
785        let mut a = Unit::parse("m").unwrap();
786        let b = Unit::parse("kg").unwrap();
787
788        let result = a.add(b);
789
790        assert!(result.is_err());
791        assert_eq!(result.unwrap_err().code(), ErrorCode::BadSum);
792    }
793
794    #[test]
795    fn error_on_convert_to_incompatible_units() {
796        let m = Unit::parse("m").unwrap();
797        let kg = Unit::parse("kg").unwrap();
798
799        let result = m.convert_to(kg);
800
801        assert!(result.is_err());
802    }
803
804    #[rstest]
805    #[case::division_by_zero("1/0", f64::INFINITY)]
806    #[case::deeply_nested("(((((((((( 1 ))))))))))", 1.0)]
807    fn parse_does_not_panic(#[case] input: &str, #[case] expected: f64) {
808        let result = Unit::parse(input);
809
810        assert!(result.is_ok(), "parse({input:?}) should not panic or fail");
811        assert_eq!(result.unwrap().factor(), expected);
812    }
813}