use std::cell::RefCell;
use std::collections::HashMap;
use rink_core::output::QueryReply;
use rink_core::{Context, eval as rink_eval, simple_context};
use crate::domain::errors::{AppError, Result};
thread_local! {
static CONTEXT: RefCell<Option<Context>> = const { RefCell::new(None) };
static UNIT_CACHE: RefCell<HashMap<String, bool>> =
RefCell::new(HashMap::new());
}
fn with_context<T>(f: impl FnOnce(&mut Context) -> T) -> T {
CONTEXT.with(|cell| {
let mut slot = cell.borrow_mut();
let context = slot.get_or_insert_with(|| {
simple_context().expect("the bundle-files feature is enabled")
});
f(context)
})
}
pub fn eval(query: &str) -> Result<(f64, Option<String>)> {
let reply = with_context(|ctx| rink_eval(ctx, query))
.map_err(|error| AppError::Units(first_line(&error.to_string())))?;
extract(&reply)
.ok_or_else(|| AppError::Units("not a numeric result".to_string()))
}
pub fn scale_of(unit: &str) -> Result<f64> {
let (value, _) = eval(&format!("1 {unit}"))?;
Ok(value)
}
const PREFIX_SYMBOLS: &[(&str, &str)] = &[
("yotta", "Y"),
("zetta", "Z"),
("exa", "E"),
("peta", "P"),
("tera", "T"),
("giga", "G"),
("mega", "M"),
("kilo", "k"),
("hecto", "h"),
("deca", "da"),
("deci", "d"),
("centi", "c"),
("milli", "m"),
("micro", "\u{00b5}"),
("nano", "n"),
("pico", "p"),
("femto", "f"),
("atto", "a"),
];
const BASE_SYMBOLS: &[(&str, &str)] = &[
("meter", "m"),
("gram", "g"),
("second", "s"),
("ampere", "A"),
("kelvin", "K"),
("mole", "mol"),
("candela", "cd"),
("newton", "N"),
("pascal", "Pa"),
("joule", "J"),
("watt", "W"),
("hertz", "Hz"),
("coulomb", "C"),
("volt", "V"),
("ohm", "\u{03a9}"),
("farad", "F"),
("siemens", "S"),
("weber", "Wb"),
("tesla", "T"),
("henry", "H"),
("lumen", "lm"),
("lux", "lx"),
("becquerel", "Bq"),
("gray", "Gy"),
("sievert", "Sv"),
("katal", "kat"),
("liter", "l"),
("tonne", "t"),
("bar", "bar"),
("radian", "rad"),
("steradian", "sr"),
("minute", "min"),
("hour", "h"),
("day", "d"),
];
pub fn prettify_unit(name: &str) -> String {
name.split(" / ")
.map(prettify_factor)
.collect::<Vec<_>>()
.join("/")
}
fn prettify_factor(factor: &str) -> String {
factor
.split_whitespace()
.map(prettify_word)
.collect::<Vec<_>>()
.join(" ")
}
fn prettify_word(word: &str) -> String {
let (stem, exponent) = match word.split_once('^') {
Some((stem, power)) => (stem, format!("^{power}")),
None => (word, String::new()),
};
let symbol = symbol_for(stem).unwrap_or_else(|| stem.to_string());
format!("{symbol}{exponent}")
}
fn symbol_for(stem: &str) -> Option<String> {
if let Some((_, symbol)) =
BASE_SYMBOLS.iter().find(|(name, _)| *name == stem)
{
return Some((*symbol).to_string());
}
for (prefix, prefix_symbol) in PREFIX_SYMBOLS {
let Some(base) = stem.strip_prefix(prefix) else {
continue;
};
if let Some((_, symbol)) =
BASE_SYMBOLS.iter().find(|(name, _)| *name == base)
{
return Some(format!("{prefix_symbol}{symbol}"));
}
}
None
}
pub fn is_unit(symbol: &str) -> bool {
if symbol.is_empty() {
return false;
}
if let Some(hit) = UNIT_CACHE.with(|c| c.borrow().get(symbol).copied()) {
return hit;
}
let result = with_context(|ctx| rink_eval(ctx, symbol)).is_ok();
UNIT_CACHE.with(|c| c.borrow_mut().insert(symbol.to_string(), result));
result
}
fn extract(reply: &QueryReply) -> Option<(f64, Option<String>)> {
let parts = match reply {
QueryReply::Number(parts) => parts,
QueryReply::Conversion(conversion) => &conversion.value,
_ => return None,
};
let value = parts.raw_value.as_ref()?.value.to_f64();
Some((value, parts.unit.clone()))
}
fn first_line(message: &str) -> String {
message.lines().next().unwrap_or(message).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_between_compatible_units() {
let (value, unit) = eval("123 MPa -> bar").unwrap();
assert!((value - 1230.0).abs() < 1e-6);
assert_eq!(unit.as_deref(), Some("bar"));
}
#[test]
fn exponent_and_compound_units_convert() {
let (value, _) = eval("1 dm^3 -> cm^3").unwrap();
assert!((value - 1000.0).abs() < 1e-6);
let (value, _) = eval("N/mm^2 -> MPa").unwrap();
assert!((value - 1.0).abs() < 1e-9);
}
#[test]
fn arithmetic_with_units_produces_a_derived_unit() {
let (value, unit) = eval("1 m * 2 m").unwrap();
assert!((value - 2.0).abs() < 1e-9);
assert!(unit.unwrap().contains("meter"));
}
#[test]
fn addition_picks_a_single_unit() {
let (value, _) = eval("1 m + 50 cm").unwrap();
assert!((value - 1.5).abs() < 1e-9);
}
#[test]
fn incompatible_units_error() {
assert!(eval("5 N -> bar").is_err());
assert!(eval("1 m + 1 s").is_err());
}
#[test]
fn prettify_unit_shortens_rink_names_to_symbols() {
assert_eq!(prettify_unit("kilonewton"), "kN");
assert_eq!(prettify_unit("meter^2"), "m^2");
assert_eq!(prettify_unit("meter / second"), "m/s");
assert_eq!(prettify_unit("kilonewton / meter^2"), "kN/m^2");
assert_eq!(prettify_unit("millimeter^3"), "mm^3");
assert_eq!(prettify_unit("pascal"), "Pa");
assert_eq!(prettify_unit("megajoule"), "MJ");
assert_eq!(prettify_unit("kilogram meter / second^2"), "kg m/s^2");
assert_eq!(prettify_unit("frobnicate"), "frobnicate");
}
#[test]
fn prettified_symbols_are_still_parseable_by_rink() {
for symbol in ["kN", "m^2", "m/s", "kN/m^2", "mm^3"] {
assert!(is_unit(symbol) || eval(&format!("1 {symbol}")).is_ok());
}
}
#[test]
fn is_unit_recognizes_units_but_not_unknown_identifiers() {
assert!(is_unit("m"));
assert!(is_unit("kN"));
assert!(is_unit("MPa"));
assert!(is_unit("dm"));
assert!(!is_unit("notaunit"));
assert!(!is_unit(""));
}
}