use crate::db::Database; use std::collections::HashMap; #[derive(Debug, thiserror::Error)] pub enum ExchangeError { #[error("network error: {0}")] Network(#[from] reqwest::Error), #[error("database error: {0}")] Database(#[from] rusqlite::Error), #[error("parse error: {0}")] Parse(String), #[error("currency not found: {0}")] NotFound(String), } pub struct ExchangeRateService<'a> { db: &'a Database, } impl<'a> ExchangeRateService<'a> { pub fn new(db: &'a Database) -> Self { ExchangeRateService { db } } pub async fn get_rate(&self, base: &str, target: &str) -> Result { let base_lower = base.to_lowercase(); let target_lower = target.to_lowercase(); if base_lower == target_lower { return Ok(1.0); } // Check cache (fresh if < 24 hours) if let Some(cached) = self.db.get_cached_rate(&base_lower, &target_lower)? { if let Ok(fetched) = chrono::DateTime::parse_from_rfc3339(&cached.fetched_at) { let age = chrono::Utc::now() - fetched.to_utc(); if age.num_hours() < 24 { return Ok(cached.rate); } } } // Fetch fresh rates let rates = match self.fetch_from_primary(&base_lower).await { Ok(r) => r, Err(_) => self.fetch_from_fallback(&base_lower).await?, }; // Cache all fetched rates self.db.cache_rates(&base_lower, &rates)?; rates .get(&target_lower) .copied() .ok_or_else(|| ExchangeError::NotFound(target.to_string())) } async fn fetch_from_primary(&self, base: &str) -> Result, ExchangeError> { let url = format!( "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/{}.json", base ); let resp: serde_json::Value = reqwest::get(&url).await?.json().await?; let rates_obj = resp .get(base) .and_then(|v| v.as_object()) .ok_or_else(|| ExchangeError::Parse("missing currency object".to_string()))?; let mut rates = HashMap::new(); for (key, val) in rates_obj { if let Some(r) = val.as_f64() { rates.insert(key.clone(), r); } } Ok(rates) } async fn fetch_from_fallback(&self, base: &str) -> Result, ExchangeError> { let url = format!( "https://api.frankfurter.dev/v1/latest?base={}", base.to_uppercase() ); let resp: serde_json::Value = reqwest::get(&url).await?.json().await?; let rates_obj = resp .get("rates") .and_then(|v| v.as_object()) .ok_or_else(|| ExchangeError::Parse("missing rates object".to_string()))?; let mut rates = HashMap::new(); for (key, val) in rates_obj { if let Some(r) = val.as_f64() { rates.insert(key.to_lowercase(), r); } } Ok(rates) } pub fn supported_currencies() -> Vec<(&'static str, &'static str)> { vec![ ("USD", "US Dollar"), ("EUR", "Euro"), ("GBP", "British Pound"), ("JPY", "Japanese Yen"), ("CAD", "Canadian Dollar"), ("AUD", "Australian Dollar"), ("CHF", "Swiss Franc"), ("CNY", "Chinese Yuan"), ("INR", "Indian Rupee"), ("BRL", "Brazilian Real"), ("MXN", "Mexican Peso"), ("KRW", "South Korean Won"), ("SGD", "Singapore Dollar"), ("HKD", "Hong Kong Dollar"), ("SEK", "Swedish Krona"), ("NOK", "Norwegian Krone"), ("DKK", "Danish Krone"), ("PLN", "Polish Zloty"), ("ZAR", "South African Rand"), ("TRY", "Turkish Lira"), ("RUB", "Russian Ruble"), ("NZD", "New Zealand Dollar"), ("THB", "Thai Baht"), ("TWD", "Taiwan Dollar"), ("CZK", "Czech Koruna"), ("HUF", "Hungarian Forint"), ("ILS", "Israeli Shekel"), ("PHP", "Philippine Peso"), ("MYR", "Malaysian Ringgit"), ("IDR", "Indonesian Rupiah"), ] } } pub fn parse_primary_response(json: &serde_json::Value, base: &str) -> Option> { let rates_obj = json.get(base)?.as_object()?; let mut rates = HashMap::new(); for (key, val) in rates_obj { if let Some(r) = val.as_f64() { rates.insert(key.clone(), r); } } Some(rates) } pub fn parse_fallback_response(json: &serde_json::Value) -> Option> { let rates_obj = json.get("rates")?.as_object()?; let mut rates = HashMap::new(); for (key, val) in rates_obj { if let Some(r) = val.as_f64() { rates.insert(key.to_lowercase(), r); } } Some(rates) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_primary_response() { let json: serde_json::Value = serde_json::from_str(r#"{ "date": "2026-03-01", "usd": { "eur": 0.92, "gbp": 0.79, "jpy": 149.5 } }"#).unwrap(); let rates = parse_primary_response(&json, "usd").unwrap(); assert!((rates["eur"] - 0.92).abs() < 0.001); assert!((rates["gbp"] - 0.79).abs() < 0.001); assert!((rates["jpy"] - 149.5).abs() < 0.1); } #[test] fn test_parse_primary_response_missing_base() { let json: serde_json::Value = serde_json::from_str(r#"{"date": "2026-03-01"}"#).unwrap(); assert!(parse_primary_response(&json, "usd").is_none()); } #[test] fn test_parse_fallback_response() { let json: serde_json::Value = serde_json::from_str(r#"{ "base": "USD", "date": "2026-03-01", "rates": { "EUR": 0.92, "GBP": 0.79 } }"#).unwrap(); let rates = parse_fallback_response(&json).unwrap(); assert!((rates["eur"] - 0.92).abs() < 0.001); assert!((rates["gbp"] - 0.79).abs() < 0.001); } #[test] fn test_parse_fallback_response_missing_rates() { let json: serde_json::Value = serde_json::from_str(r#"{"base": "USD"}"#).unwrap(); assert!(parse_fallback_response(&json).is_none()); } #[test] fn test_cache_and_retrieve_rate() { let db = Database::open_in_memory().unwrap(); let mut rates = HashMap::new(); rates.insert("eur".to_string(), 0.92); rates.insert("gbp".to_string(), 0.79); db.cache_rates("usd", &rates).unwrap(); let cached = db.get_cached_rate("usd", "eur").unwrap().unwrap(); assert!((cached.rate - 0.92).abs() < 0.001); assert_eq!(cached.base, "usd"); assert_eq!(cached.target, "eur"); let cached_gbp = db.get_cached_rate("usd", "gbp").unwrap().unwrap(); assert!((cached_gbp.rate - 0.79).abs() < 0.001); } #[test] fn test_cache_miss_returns_none() { let db = Database::open_in_memory().unwrap(); let result = db.get_cached_rate("usd", "eur").unwrap(); assert!(result.is_none()); } #[test] fn test_supported_currencies_not_empty() { let currencies = ExchangeRateService::supported_currencies(); assert!(currencies.len() >= 10); assert!(currencies.iter().any(|(code, _)| *code == "USD")); assert!(currencies.iter().any(|(code, _)| *code == "EUR")); } #[test] fn test_same_currency_rate_is_one() { let db = Database::open_in_memory().unwrap(); let service = ExchangeRateService::new(&db); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let rate = rt.block_on(service.get_rate("USD", "USD")).unwrap(); assert!((rate - 1.0).abs() < 0.0001); } }