smart-calculator 1.0.3

Natural-language calculator for math, units, time zones, dates, fiat, and crypto conversions
Documentation
use std::{collections::HashMap, error::Error};

use crate::{
    http::fetch_json,
    provider::{
        error::{ProviderError, Result},
        models::{BinancePriceResponse, CoinCapResponse},
        static_rates::{
            COINCAP_IDS, COINGECKO_FIATS, COINGECKO_IDS, static_crypto_rate as static_crypto,
        },
    },
};

type NestedRates = HashMap<String, HashMap<String, f64>>;

async fn coingecko_rate(from: &str, to: &str) -> Result<f64> {
    let from_id = COINGECKO_IDS.get(from);
    let to_id = COINGECKO_IDS.get(to);

    if let Some(from_id) = from_id {
        let vs_currency = if let Some(to_id) = to_id {
            to_id.to_lowercase()
        } else {
            to.to_lowercase()
        };

        if COINGECKO_FIATS.contains(vs_currency.as_str()) && to_id.is_none() {
            return Err(ProviderError::UnsupportedTarget(to.to_string()));
        }

        let url = format!(
            "https://api.coingecko.com/api/v3/simple/price?ids={from_id}&vs_currencies={vs_currency}"
        );
        let data = fetch_json(&url, None).await?;

        let parsed: NestedRates = serde_json::from_value(data)
            .map_err(|e| ProviderError::DeserializationError(e.to_string()))?;

        if let Some(rate) = parsed.get(*from_id) {
            return rate
                .get(vs_currency.as_str())
                .copied()
                .ok_or(ProviderError::RateNotFound);
        }
    }

    if let Some(to_id) = to_id {
        let fiat = from.to_lowercase();
        if COINGECKO_FIATS.contains(fiat.as_str()) {
            return Err(ProviderError::UnsupportedTarget(to.to_string()));
        }

        let url = format!(
            "https://api.coingecko.com/api/v3/simple/price?ids={to_id}&vs_currencies={fiat}"
        );
        let data = fetch_json(&url, None).await?;

        let parsed: NestedRates = serde_json::from_value(data)
            .map_err(|e| ProviderError::DeserializationError(e.to_string()))?;

        if let Some(rate) = parsed.get(*to_id) {
            let final_rate = rate
                .get(fiat.as_str())
                .copied()
                .ok_or(ProviderError::RateNotFound)?;
            return Ok(1_f64 / final_rate);
        }
    }

    Err(ProviderError::PairNotFound("coingecko".to_string()))
}

async fn binance_direct(from: &str, to: &str) -> Option<f64> {
    let symbol = format!("{from}{to}");
    let url = format!("https://api.binance.com/api/v3/ticker/price?symbol={symbol}");

    let data = fetch_json(&url, None).await.ok()?;
    let parsed: BinancePriceResponse = serde_json::from_value(data).ok()?;
    parsed.price.parse::<f64>().ok()
}

async fn binance_rate(from: &str, to: &str) -> Result<f64> {
    if let Some(rate) = binance_direct(from, to).await {
        return Ok(rate);
    }

    if let Some(rate) = binance_direct(to, from).await {
        return Ok(1_f64 / rate);
    }

    if from != "USDT" && to != "USDT" {
        let from_usdt = binance_direct(from, "USDT").await;
        let to_usdt = binance_direct(to, "USDT").await;

        if let (Some(from_usdt), Some(to_usdt)) = (from_usdt, to_usdt) {
            return Ok(from_usdt / to_usdt);
        }
    }

    Err(ProviderError::PairNotFound("binance".to_string()))
}

async fn get_coincap_usd_price(ticker: &str) -> Result<f64> {
    if ticker == "USD" {
        return Ok(1.0);
    }

    let id = COINCAP_IDS.get(ticker).ok_or_else(|| {
        ProviderError::CryptoRateUnavaliable(ticker.to_string(), "USD".to_string())
    })?;

    let url = format!("https://api.coincap.io/v2/assets/{id}");
    let json = fetch_json(&url, None).await?;

    let response: CoinCapResponse = serde_json::from_value(json)
        .map_err(|e| ProviderError::DeserializationError(e.to_string()))?;

    response
        .data
        .price_usd
        .parse::<f64>()
        .map_err(|_| ProviderError::CryptoRateUnavaliable(ticker.to_string(), "USD".to_string()))
}

pub async fn coincap_rate(from: &str, to: &str) -> Result<f64> {
    let is_fiat = |t: &str| !COINCAP_IDS.contains_key(t) && t != "USD";

    if to == "USD" {
        return get_coincap_usd_price(from).await;
    }

    if is_fiat(to) {
        return Err(ProviderError::FiatRateUnavaliable(
            from.to_string(),
            to.to_string(),
        ));
    }

    if from == "USD" {
        let price = get_coincap_usd_price(to).await?;
        return if price == 0.0 {
            Ok(0.0)
        } else {
            Ok(1.0 / price)
        };
    }

    let from_usd = get_coincap_usd_price(from).await?;
    let to_usd = get_coincap_usd_price(to).await?;

    if to_usd == 0.0 {
        return Err(ProviderError::CryptoRateUnavaliable(
            from.to_string(),
            to.to_string(),
        ));
    }

    Ok(from_usd / to_usd)
}

fn is_crypto(ticker: &str) -> bool {
    COINGECKO_IDS.contains_key(ticker)
}

async fn try_crypto_to_crypto(from: &str, to: &str) -> Option<f64> {
    if let Ok(rate) = coingecko_rate(from, to).await {
        return Some(rate);
    }
    if let Ok(rate) = binance_rate(from, to).await {
        return Some(rate);
    }
    if let Ok(rate) = coincap_rate(from, to).await {
        return Some(rate);
    }
    static_crypto(from.to_string(), to.to_string()).ok()
}

async fn try_crypto_to_usd(from: &str) -> Option<f64> {
    if let Ok(rate) = coingecko_rate(from, "USD").await {
        return Some(rate);
    }
    if let Ok(rate) = binance_rate(from, "USDT").await {
        return Some(rate);
    }
    if let Ok(rate) = coincap_rate(from, "USD").await {
        return Some(rate);
    }
    static_crypto(from.to_string(), "USD".to_string()).ok()
}

async fn try_usd_to_crypto(to: &str) -> Option<f64> {
    if let Ok(rate) = coingecko_rate("USD", to).await {
        if rate > 0.0 {
            return Some(rate);
        }
    }
    if let Ok(price) = coingecko_rate(to, "USD").await {
        if price > 0.0 {
            return Some(1.0 / price);
        }
    }
    if let Ok(price) = coincap_rate(to, "USD").await {
        if price > 0.0 {
            return Some(1.0 / price);
        }
    }
    static_crypto("USD".to_string(), to.to_string()).ok()
}

pub async fn get_crypto_rate_with_fiat_fallback<F, Fut>(
    from: &str,
    to: &str,
    get_fiat: F,
) -> Result<f64>
where
    F: Fn(String, String) -> Fut,
    Fut: std::future::Future<Output = std::result::Result<f64, Box<dyn Error>>>,
{
    let from_is_crypto = is_crypto(from);
    let to_is_crypto = is_crypto(to);
    let to_is_fiat = !to_is_crypto;
    let from_is_fiat = !from_is_crypto;

    if let Some(rate) = try_crypto_to_crypto(from, to).await {
        return Ok(rate);
    }

    if from_is_crypto && to_is_fiat {
        let crypto_to_usd = try_crypto_to_usd(from).await.ok_or_else(|| {
            ProviderError::CryptoRateUnavaliable(from.to_string(), "USD".to_string())
        })?;

        if to == "USD" || to == "USDT" || to == "USDC" {
            return Ok(crypto_to_usd);
        }

        let usd_to_fiat = get_fiat("USD".to_string(), to.to_string())
            .await
            .map_err(|e| ProviderError::DeserializationError(e.to_string()))?;
        return Ok(crypto_to_usd * usd_to_fiat);
    }

    if from_is_fiat && to_is_crypto {
        let fiat_to_usd = if from == "USD" {
            Ok(1.0)
        } else {
            get_fiat(from.to_string(), "USD".to_string())
                .await
                .map_err(|e| ProviderError::DeserializationError(e.to_string()))
        }?;

        let usd_to_crypto = try_usd_to_crypto(to).await.ok_or_else(|| {
            ProviderError::CryptoRateUnavaliable("USD".to_string(), to.to_string())
        })?;

        return Ok(fiat_to_usd * usd_to_crypto);
    }

    static_crypto(from.to_string(), to.to_string())
}