//! Rate storage, caching, and staleness detection. //! //! Provides the core `ExchangeRates` type (a timestamped map of currency-code -> rate) //! and `ExchangeRateCache` for persisting rates to disk as JSON. use crate::currency::CurrencyError; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::Path; use std::time::Duration; /// Fetched exchange rates with metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExchangeRates { /// Base currency (always "USD"). pub base: String, /// Map of currency code -> rate (1 USD = rate units of that currency). pub rates: HashMap, /// When the rates were fetched. pub timestamp: DateTime, /// Which provider supplied the rates. pub provider: String, } /// Describes the source of rates used for a conversion. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RateSource { /// Freshly fetched from the provider. Live, /// Loaded from disk cache (still within staleness threshold). Cached, /// Loaded from stale cache because the provider was unreachable. Offline, } /// Metadata about the rates used for a currency conversion. #[derive(Debug, Clone)] pub struct RateMetadata { /// When the rates were last updated. pub updated_at: DateTime, /// How the rates were obtained. pub source: RateSource, /// The provider that originally supplied the rates. pub provider: String, } impl RateMetadata { /// Format a human-readable status string. /// /// - Live/Cached: "rates updated 5 minutes ago" /// - Offline: "offline -- rates from 2026-03-16 14:30:00 UTC" pub fn display_status(&self) -> String { match self.source { RateSource::Offline => { format!( "offline -- rates from {}", self.updated_at.format("%Y-%m-%d %H:%M:%S UTC") ) } RateSource::Live | RateSource::Cached => { let elapsed = Utc::now().signed_duration_since(self.updated_at); let secs = elapsed.num_seconds().max(0); let relative = if secs < 60 { "just now".to_string() } else if secs < 3600 { let mins = secs / 60; format!( "{} minute{} ago", mins, if mins == 1 { "" } else { "s" } ) } else if secs < 86400 { let hours = secs / 3600; format!( "{} hour{} ago", hours, if hours == 1 { "" } else { "s" } ) } else { let days = secs / 86400; format!( "{} day{} ago", days, if days == 1 { "" } else { "s" } ) }; format!("rates updated {}", relative) } } } } /// Configuration for the fiat currency provider. #[derive(Debug, Clone)] pub struct ProviderConfig { /// API key for the provider (if required). pub api_key: Option, /// How long before cached rates are considered stale. pub staleness_threshold: Duration, /// Path to the cache file on disk. pub cache_path: String, } impl Default for ProviderConfig { fn default() -> Self { Self { api_key: None, staleness_threshold: Duration::from_secs(3600), // 1 hour cache_path: "calcpad_exchange_rates.json".to_string(), } } } /// Disk-based cache for exchange rates. pub struct ExchangeRateCache { /// Path to the JSON cache file. path: String, } impl ExchangeRateCache { pub fn new(path: &str) -> Self { Self { path: path.to_string(), } } /// Save exchange rates to disk as JSON. pub fn save(&self, rates: &ExchangeRates) -> Result<(), CurrencyError> { let json = serde_json::to_string_pretty(rates) .map_err(|e| CurrencyError::CacheError(format!("Failed to serialize rates: {}", e)))?; // Ensure parent directory exists if let Some(parent) = Path::new(&self.path).parent() { if !parent.as_os_str().is_empty() { fs::create_dir_all(parent).map_err(|e| { CurrencyError::CacheError(format!( "Failed to create cache directory: {}", e )) })?; } } fs::write(&self.path, json) .map_err(|e| CurrencyError::CacheError(format!("Failed to write cache file: {}", e)))?; Ok(()) } /// Load exchange rates from disk. /// Returns None if the cache file doesn't exist or is empty. pub fn load(&self) -> Result, CurrencyError> { let path = Path::new(&self.path); if !path.exists() { return Ok(None); } let json = fs::read_to_string(path) .map_err(|e| CurrencyError::CacheError(format!("Failed to read cache file: {}", e)))?; if json.trim().is_empty() { return Ok(None); } let rates: ExchangeRates = serde_json::from_str(&json).map_err(|e| { CurrencyError::CacheError(format!("Failed to parse cache file: {}", e)) })?; Ok(Some(rates)) } /// Check whether cached rates are stale based on the given threshold. pub fn is_stale(&self, rates: &ExchangeRates, threshold: Duration) -> bool { let elapsed = Utc::now() .signed_duration_since(rates.timestamp) .num_seconds() .max(0) as u64; elapsed >= threshold.as_secs() } /// Check if the cache file exists. pub fn exists(&self) -> bool { Path::new(&self.path).exists() } } #[cfg(test)] mod tests { use super::*; use tempfile::NamedTempFile; fn sample_rates() -> ExchangeRates { let mut rates = HashMap::new(); rates.insert("EUR".to_string(), 0.85); rates.insert("GBP".to_string(), 0.73); rates.insert("JPY".to_string(), 110.0); ExchangeRates { base: "USD".to_string(), rates, timestamp: Utc::now(), provider: "test".to_string(), } } #[test] fn test_save_and_load() { let tmp = NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap(); let cache = ExchangeRateCache::new(path); let rates = sample_rates(); cache.save(&rates).unwrap(); let loaded = cache.load().unwrap().unwrap(); assert_eq!(loaded.base, "USD"); assert_eq!(loaded.provider, "test"); assert_eq!(loaded.rates.len(), 3); assert!((loaded.rates["EUR"] - 0.85).abs() < f64::EPSILON); } #[test] fn test_load_nonexistent() { let cache = ExchangeRateCache::new("/tmp/calcpad_nonexistent_test_cache_12345.json"); let result = cache.load().unwrap(); assert!(result.is_none()); } #[test] fn test_is_stale_fresh() { let rates = sample_rates(); // timestamp = now let cache = ExchangeRateCache::new("/tmp/test_stale.json"); assert!(!cache.is_stale(&rates, Duration::from_secs(3600))); } #[test] fn test_is_stale_old() { let mut rates = sample_rates(); rates.timestamp = Utc::now() - chrono::Duration::hours(2); let cache = ExchangeRateCache::new("/tmp/test_stale.json"); assert!(cache.is_stale(&rates, Duration::from_secs(3600))); } #[test] fn test_exists() { let tmp = NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap(); let cache = ExchangeRateCache::new(path); assert!(cache.exists()); let cache2 = ExchangeRateCache::new("/tmp/calcpad_no_such_file_99999.json"); assert!(!cache2.exists()); } #[test] fn test_provider_config_default() { let config = ProviderConfig::default(); assert!(config.api_key.is_none()); assert_eq!(config.staleness_threshold, Duration::from_secs(3600)); assert!(!config.cache_path.is_empty()); } #[test] fn test_metadata_display_live() { let metadata = RateMetadata { updated_at: Utc::now(), source: RateSource::Live, provider: "test".to_string(), }; let display = metadata.display_status(); assert!(display.starts_with("rates updated ")); assert!(display.contains("just now")); } #[test] fn test_metadata_display_offline() { let metadata = RateMetadata { updated_at: Utc::now() - chrono::Duration::hours(2), source: RateSource::Offline, provider: "test".to_string(), }; let display = metadata.display_status(); assert!(display.starts_with("offline -- rates from ")); } #[test] fn test_metadata_display_minutes_ago() { let metadata = RateMetadata { updated_at: Utc::now() - chrono::Duration::minutes(5), source: RateSource::Cached, provider: "test".to_string(), }; let display = metadata.display_status(); assert!(display.contains("minute")); } #[test] fn test_metadata_display_hours_ago() { let metadata = RateMetadata { updated_at: Utc::now() - chrono::Duration::hours(3), source: RateSource::Live, provider: "test".to_string(), }; let display = metadata.display_status(); assert!(display.contains("hour")); } #[test] fn test_metadata_display_days_ago() { let metadata = RateMetadata { updated_at: Utc::now() - chrono::Duration::days(2), source: RateSource::Cached, provider: "test".to_string(), }; let display = metadata.display_status(); assert!(display.contains("day")); } #[test] fn test_exchange_rates_serialization_roundtrip() { let rates = sample_rates(); let json = serde_json::to_string(&rates).unwrap(); let deserialized: ExchangeRates = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.base, "USD"); assert_eq!(deserialized.rates.len(), 3); assert!((deserialized.rates["EUR"] - 0.85).abs() < f64::EPSILON); } }