mudra-cli 0.1.0

A robust, high-performance currency converter with caching and CLI interface
Documentation
//! Caching system for exchange rates and API responses

use crate::api::ExchangeRateResponse;
use chrono::{DateTime, Utc};
use moka::future::Cache;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;

/// Cache key for exchange rate data
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheKey {
    /// Base currency code
    pub base_currency: String,
    /// Optional date for historical rates (None for latest)
    pub date: Option<String>,
}

impl CacheKey {
    /// Create a key for latest rates
    pub fn latest(base_currency: &str) -> Self {
        Self {
            base_currency: base_currency.to_uppercase(),
            date: None,
        }
    }

    /// Create a key for historical rates
    pub fn historical(base_currency: &str, date: &str) -> Self {
        Self {
            base_currency: base_currency.to_uppercase(),
            date: Some(date.to_string()),
        }
    }
}

/// Cached exchange rate data with metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedRates {
    /// The actual exchange rate data
    pub data: ExchangeRateResponse,
    /// When this data was cached
    pub cached_at: DateTime<Utc>,
    /// When this data expires
    pub expires_at: DateTime<Utc>,
    /// Number of times this entry has been accessed
    pub access_count: u64,
}

impl CachedRates {
    /// Create a new cached rates entry
    pub fn new(data: ExchangeRateResponse, ttl_seconds: u64) -> Self {
        let now = Utc::now();
        Self {
            data,
            cached_at: now,
            expires_at: now + chrono::Duration::seconds(ttl_seconds as i64),
            access_count: 0,
        }
    }

    /// Check if this cache entry is still valid
    pub fn is_valid(&self) -> bool {
        Utc::now() < self.expires_at
    }

    /// Get the age of this cache entry in seconds
    pub fn age_seconds(&self) -> i64 {
        (Utc::now() - self.cached_at).num_seconds()
    }

    /// Record an access to this cache entry
    pub fn record_access(&mut self) {
        self.access_count += 1;
    }
}

/// Configuration for the cache system
#[derive(Debug, Clone)]
pub struct CacheConfig {
    /// Maximum number of entries to cache
    pub max_capacity: u64,
    /// Time-to-live for latest rates (seconds)
    pub latest_ttl: u64,
    /// Time-to-live for historical rates (seconds)
    pub historical_ttl: u64,
    /// Enable cache statistics
    pub enable_stats: bool,
}

impl Default for CacheConfig {
    fn default() -> Self {
        Self {
            max_capacity: 1000,
            latest_ttl: 300,      // 5 minutes for latest rates
            historical_ttl: 3600, // 1 hour for historical rates
            enable_stats: true,
        }
    }
}

/// High-performance cache for exchange rate data
#[derive(Debug, Clone)]
pub struct ExchangeRateCache {
    /// Main cache storage
    cache: Cache<CacheKey, Arc<CachedRates>>,
    /// Cache configuration
    config: CacheConfig,
    /// Cache statistics
    stats: Arc<dashmap::DashMap<String, u64>>,
}

impl ExchangeRateCache {
    /// Create a new cache with default configuration
    pub fn new() -> Self {
        Self::with_config(CacheConfig::default())
    }

    /// Create a new cache with custom configuration
    pub fn with_config(config: CacheConfig) -> Self {
        let cache = Cache::builder()
            .max_capacity(config.max_capacity)
            .time_to_live(Duration::from_secs(
                config.latest_ttl.max(config.historical_ttl),
            ))
            .build();

        Self {
            cache,
            config,
            stats: Arc::new(dashmap::DashMap::new()),
        }
    }

    /// Get cached rates if available and valid
    pub async fn get(&self, key: &CacheKey) -> Option<ExchangeRateResponse> {
        self.increment_stat("requests_total");

        if let Some(cached) = self.cache.get(key).await {
            if cached.is_valid() {
                self.increment_stat("hits");

                // Update access count (clone to modify)
                let mut updated = (*cached).clone();
                updated.record_access();
                self.cache.insert(key.clone(), Arc::new(updated)).await;

                return Some(cached.data.clone());
            } else {
                // Remove expired entry
                self.cache.remove(key).await;
                self.increment_stat("expired");
            }
        }

        self.increment_stat("misses");
        None
    }

    /// Cache exchange rate data
    pub async fn put(&self, key: CacheKey, data: ExchangeRateResponse) {
        let ttl = if key.date.is_some() {
            self.config.historical_ttl
        } else {
            self.config.latest_ttl
        };

        let cached_rates = Arc::new(CachedRates::new(data, ttl));
        self.cache.insert(key, cached_rates).await;
        self.increment_stat("insertions");
    }

    /// Get cache statistics
    pub fn get_stats(&self) -> CacheStats {
        let total_requests = self.get_stat("requests_total");
        let hits = self.get_stat("hits");
        let misses = self.get_stat("misses");
        let expired = self.get_stat("expired");

        let hit_rate = if total_requests > 0 {
            (hits as f64 / total_requests as f64) * 100.0
        } else {
            0.0
        };

        CacheStats {
            total_requests,
            hits,
            misses,
            expired,
            hit_rate,
            cached_entries: self.cache.entry_count(),
            weighted_size: self.cache.weighted_size(),
        }
    }

    /// Clear all cached entries
    pub async fn clear(&self) {
        self.cache.invalidate_all();
        if self.config.enable_stats {
            self.stats.clear();
        }
    }

    /// Remove expired entries manually
    pub async fn cleanup_expired(&self) {
        // Moka automatically handles TTL, but we can force a cleanup
        self.cache.run_pending_tasks().await;
    }

    /// Get all cache keys (for debugging)
    pub async fn get_keys(&self) -> Vec<CacheKey> {
        // Note: Moka doesn't provide a direct way to iterate keys
        // This is a simplified implementation for debugging
        vec![]
    }

    /// Increment a statistic counter
    fn increment_stat(&self, key: &str) {
        if self.config.enable_stats {
            self.stats
                .entry(key.to_string())
                .and_modify(|v| *v += 1)
                .or_insert(1);
        }
    }

    /// Get a statistic value
    fn get_stat(&self, key: &str) -> u64 {
        self.stats.get(key).map(|v| *v).unwrap_or(0)
    }
}

impl Default for ExchangeRateCache {
    fn default() -> Self {
        Self::new()
    }
}

/// Cache performance statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheStats {
    /// Total number of cache requests
    pub total_requests: u64,
    /// Number of cache hits
    pub hits: u64,
    /// Number of cache misses
    pub misses: u64,
    /// Number of expired entries removed
    pub expired: u64,
    /// Cache hit rate as a percentage
    pub hit_rate: f64,
    /// Current number of cached entries
    pub cached_entries: u64,
    /// Total weighted size of cache
    pub weighted_size: u64,
}

impl CacheStats {
    /// Format statistics for display
    pub fn format_summary(&self) -> String {
        format!(
            "Cache Stats: {:.1}% hit rate ({}/{} requests), {} entries, {} expired",
            self.hit_rate, self.hits, self.total_requests, self.cached_entries, self.expired
        )
    }
}