smart-calculator 1.0.3

Natural-language calculator for math, units, time zones, dates, fiat, and crypto conversions
Documentation
use fixed_decimal::{Decimal as FixedDecimal, FloatPrecision};
use iso_currency::Currency;
use serde_json::json;
use std::collections::HashMap;

use crate::data::currencies::CRYPTO_CURRENCIES;
use crate::{
    data::currencies::FIAT_CURRENCIES,
    evaluators::error::{EvaluatorError, Result},
    formatting::{MAX_DISPLAY_FRACTION_DIGITS, format_display_number, normalize_locale},
    types::{AnswerType, CalculatorResult, RateProvider, ResultType},
};

pub async fn evaluate_currency(
    amount: f64,
    from: String,
    to: String,
    provider: Option<&dyn RateProvider>,
    locale: Option<String>,
    precision: Option<u8>,
) -> Result<CalculatorResult> {
    let from = normalize_code(from);
    let to = normalize_code(to);
    let input_str = format!("{amount} {from} to {to}");

    let provider = require_provider(provider)?;
    let precision = normalize_precision(precision, 6);
    let locale = normalize_locale(locale);

    let rate = provider
        .get_fiat_rate(&from, &to)
        .await
        .map_err(|err| EvaluatorError::CurrencyConversionFailed(err.to_string()))?;

    let converted = amount * rate;
    let rounded = round_to_significant_digits(converted, precision);
    let formatted = format_currency_value(rounded, &to, &locale, precision);

    let from_name = lookup_fiat_name(&from);
    let to_name = lookup_fiat_name(&to);
    let metadata = build_metadata(&from, &from_name, &to, &to_name, rate, amount);

    Ok(CalculatorResult {
        res_type: ResultType::Currency,
        input: input_str,
        result: AnswerType::Number(rounded),
        formatted,
        metadata: Some(metadata),
    })
}

pub async fn evaluate_crypto(
    amount: f64,
    from: String,
    to: String,
    provider: Option<&dyn RateProvider>,
    locale: Option<String>,
    precision: Option<u8>,
) -> Result<CalculatorResult> {
    let from = normalize_code(from);
    let to = normalize_code(to);
    let input_str = format!("{amount} {from} to {to}");

    let provider = require_provider(provider)?;
    let precision = normalize_precision(precision, 10);
    let locale = normalize_locale(locale);

    let rate = provider
        .get_crypto_rate(&from, &to)
        .await
        .map_err(|err| EvaluatorError::CryptoConversionFailed(err.to_string()))?;

    let converted = amount * rate;
    let rounded = round_to_significant_digits(converted, precision);

    let to_is_fiat = FIAT_CURRENCIES.pin().contains_key(to.as_str());
    let formatted = if to_is_fiat {
        format_currency_value(rounded, &to, &locale, precision)
    } else {
        format_asset_value(rounded, &to, &locale, precision)
    };

    let from_name = lookup_asset_name(&from);
    let to_name = lookup_asset_name(&to);
    let metadata = build_metadata(&from, &from_name, &to, &to_name, rate, amount);

    Ok(CalculatorResult {
        res_type: ResultType::Crypto,
        input: input_str,
        result: AnswerType::Number(rounded),
        formatted,
        metadata: Some(metadata),
    })
}

fn normalize_precision(precision: Option<u8>, default: u8) -> u8 {
    precision.unwrap_or(default).clamp(1, 21)
}

fn normalize_code(code: String) -> String {
    code.trim().to_uppercase()
}

fn require_provider(provider: Option<&dyn RateProvider>) -> Result<&dyn RateProvider> {
    provider.ok_or_else(|| EvaluatorError::RequiredParameter("provider".to_string()))
}

fn round_to_significant_digits(value: f64, precision: u8) -> f64 {
    if !value.is_finite() || value == 0.0 {
        return value;
    }

    FixedDecimal::try_from_f64(value, FloatPrecision::SignificantDigits(precision))
        .ok()
        .and_then(|decimal| decimal.to_string().parse::<f64>().ok())
        .unwrap_or(value)
}

fn format_asset_value(value: f64, code: &str, locale_str: &str, precision: u8) -> String {
    let numeric = format_display_number(
        value,
        locale_str,
        precision.min(MAX_DISPLAY_FRACTION_DIGITS),
    );
    format!("{numeric} {code}")
}

fn format_currency_value(value: f64, code: &str, locale_str: &str, precision: u8) -> String {
    let numeric = format_display_number(
        value,
        locale_str,
        precision.min(MAX_DISPLAY_FRACTION_DIGITS),
    );

    match Currency::from_code(code) {
        Some(currency) => {
            let symbol = currency.symbol().to_string();
            if symbol == "ยค" {
                format!("{numeric} {code}")
            } else {
                format!("{symbol}{numeric}")
            }
        }
        None => format!("{numeric} {code}"),
    }
}

fn lookup_fiat_name(code: &str) -> String {
    FIAT_CURRENCIES
        .pin()
        .get(code)
        .map(|name| (*name).to_string())
        .unwrap_or_else(|| code.to_string())
}

fn lookup_asset_name(code: &str) -> String {
    if let Some(name) = FIAT_CURRENCIES.pin().get(code) {
        return (*name).to_string();
    }
    if let Some(name) = CRYPTO_CURRENCIES.pin().get(code) {
        return (*name).to_string();
    }
    code.to_string()
}

fn build_metadata(
    from_code: &str,
    from_name: &str,
    to_code: &str,
    to_name: &str,
    rate: f64,
    amount: f64,
) -> HashMap<String, serde_json::Value> {
    let mut metadata = HashMap::new();
    metadata.insert(
        "from".to_string(),
        json!({ "code": from_code, "name": from_name }),
    );
    metadata.insert(
        "to".to_string(),
        json!({ "code": to_code, "name": to_name }),
    );
    metadata.insert("rate".to_string(), json!(rate));
    metadata.insert("amount".to_string(), json!(amount));
    metadata
}