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.
This commit is contained in:
333
calcpad-engine/src/currency/rates.rs
Normal file
333
calcpad-engine/src/currency/rates.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user