Extracted and integrated unique feature modules from Epic 2-6 branches: - units/: 200+ unit conversions across 14 categories, SI prefixes (nano-tera), CSS/screen units, binary vs decimal data, custom units - currency/: fiat (180+ currencies, cached rates, offline fallback), crypto (63 coins, CoinGecko), symbol recognition, rate caching - datetime/: date/time math, 150+ city timezone mappings (chrono-tz), business day calculations, unix timestamps, relative expressions - variables/: line references (lineN, #N, prev/ans), section aggregators (sum/total/avg/min/max/count), subtotals, autocomplete - functions/: trig, log, combinatorics, financial, rounding, list operations (min/max/gcd/lcm), video timecodes 585 tests passing across workspace.
334 lines
10 KiB
Rust
334 lines
10 KiB
Rust
//! 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<String, f64>,
|
|
/// When the rates were fetched.
|
|
pub timestamp: DateTime<Utc>,
|
|
/// 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<Utc>,
|
|
/// 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<String>,
|
|
/// 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<Option<ExchangeRates>, 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);
|
|
}
|
|
}
|