Files
calctext/calcpad-engine/src/currency/rates.rs
C. Cassel 68fa54615a feat(engine): add units, currency, datetime, variables, and functions modules
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.
2026-03-17 09:01:13 -04:00

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);
}
}