use crate::extra_units; use crate::units::UnitType; use fastnum::decimal::Context; use fastnum::D128; use serde_json::Value; const EXCHANGE_RATE_API_USD_LATEST: &str = "https://api.exchangerate-api.com/v4/latest/USD"; fn parse_d128_from_json_number(v: &Value) -> Result { let s = match v { Value::Number(n) => n.to_string(), _ => return Err("Expected JSON number".to_string()), }; D128::from_str(&s, Context::default()) .map_err(|_| format!("Invalid decimal number: {s}")) } /// Registers/updates currencies from the ExchangeRate API JSON response (base USD). /// /// The JSON is expected to look like `{"base":"USD","rates":{"EUR":0.852,...}}`. /// /// Internally we store a "weight" as USD-per-unit, so conversions use the existing /// `from.weight()/to.weight()` logic. pub fn register_currencies_from_exchange_rate_api_json(json: &str) -> Result { let v: Value = serde_json::from_str(json).map_err(|e| format!("Invalid JSON: {e}"))?; let base = v .get("base") .and_then(|b| b.as_str()) .ok_or("Missing 'base'")?; if base != "USD" { return Err(format!("Expected base USD but got {base}")); } let rates = v .get("rates") .and_then(|r| r.as_object()) .ok_or("Missing 'rates' object")?; let mut count = 0usize; for (code, rate_value) in rates { // Skip weird/empty keys defensively if code.len() != 3 { continue; } let code_upper = code.to_ascii_uppercase(); let code_lower = code.to_ascii_lowercase(); let rate = parse_d128_from_json_number(rate_value)?; if rate.is_zero() { continue; } // weight = USD per 1 {CODE} = 1 / (CODE per 1 USD) let weight = D128::ONE / rate; extra_units::upsert_unit( &code_lower, &code_upper, &code_upper, UnitType::Currency, weight, ); count += 1; } Ok(count) } #[cfg(not(target_arch = "wasm32"))] fn cache_path() -> std::path::PathBuf { let mut p = std::env::temp_dir(); p.push("cpc_currency_rates_usd.json"); p } #[cfg(not(target_arch = "wasm32"))] pub fn init_currencies_blocking() -> Result { // 1) Load cached (if any) so currencies work offline. let mut cached_count = 0usize; let cache_path = cache_path(); if let Ok(s) = std::fs::read_to_string(&cache_path) { if let Ok(n) = register_currencies_from_exchange_rate_api_json(&s) { cached_count = n; } } // 2) Try refresh from network. let resp = ureq::get(EXCHANGE_RATE_API_USD_LATEST) .call() .map_err(|e| format!("Currency fetch failed: {e}"))?; let body = resp .into_string() .map_err(|e| format!("Currency fetch read failed: {e}"))?; let n = register_currencies_from_exchange_rate_api_json(&body)?; // Best-effort cache write let _ = std::fs::write(&cache_path, body); Ok(std::cmp::max(cached_count, n)) } #[cfg(test)] mod tests { use super::*; use crate::extra_units; use crate::units::{convert, Unit}; use crate::Number; use fastnum::dec128 as d; #[test] fn register_and_convert_currency() { extra_units::clear_for_tests(); let json = r#"{ "base": "USD", "rates": { "USD": 1, "EUR": 0.5 } }"#; let n = register_currencies_from_exchange_rate_api_json(json).unwrap(); assert_eq!(n, 2); let usd = extra_units::lookup_unit("usd").unwrap(); let eur = extra_units::lookup_unit("eur").unwrap(); let one_usd = Number::new(d!(1), usd); let eur_amount = convert(one_usd, eur).unwrap(); assert_eq!(eur_amount.value, d!(0.5)); let one_eur = Number::new(d!(1), eur); let usd_amount = convert(one_eur, usd).unwrap(); assert_eq!(usd_amount.value, d!(2)); // Display name should be currency code assert_eq!(eur_amount.unit.singular(), "EUR"); assert_eq!(eur_amount.unit.plural(), "EUR"); // Sanity: it's still a Unit let _ = Unit::NoUnit; } }