CPC/src/currency.rs
2025-12-11 20:11:53 -05:00

146 lines
3.7 KiB
Rust

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<D128, String> {
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<usize, String> {
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<usize, String> {
// 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;
}
}