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