Skip to main content

mudra_cli/
cache.rs

1//! Caching system for exchange rates and API responses
2
3use crate::api::ExchangeRateResponse;
4use chrono::{DateTime, Utc};
5use moka::future::Cache;
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8use std::time::Duration;
9
10/// Cache key for exchange rate data
11#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
12pub struct CacheKey {
13    /// Base currency code
14    pub base_currency: String,
15    /// Optional date for historical rates (None for latest)
16    pub date: Option<String>,
17}
18
19impl CacheKey {
20    /// Create a key for latest rates
21    pub fn latest(base_currency: &str) -> Self {
22        Self {
23            base_currency: base_currency.to_uppercase(),
24            date: None,
25        }
26    }
27
28    /// Create a key for historical rates
29    pub fn historical(base_currency: &str, date: &str) -> Self {
30        Self {
31            base_currency: base_currency.to_uppercase(),
32            date: Some(date.to_string()),
33        }
34    }
35}
36
37/// Cached exchange rate data with metadata
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CachedRates {
40    /// The actual exchange rate data
41    pub data: ExchangeRateResponse,
42    /// When this data was cached
43    pub cached_at: DateTime<Utc>,
44    /// When this data expires
45    pub expires_at: DateTime<Utc>,
46    /// Number of times this entry has been accessed
47    pub access_count: u64,
48}
49
50impl CachedRates {
51    /// Create a new cached rates entry
52    pub fn new(data: ExchangeRateResponse, ttl_seconds: u64) -> Self {
53        let now = Utc::now();
54        Self {
55            data,
56            cached_at: now,
57            expires_at: now + chrono::Duration::seconds(ttl_seconds as i64),
58            access_count: 0,
59        }
60    }
61
62    /// Check if this cache entry is still valid
63    pub fn is_valid(&self) -> bool {
64        Utc::now() < self.expires_at
65    }
66
67    /// Get the age of this cache entry in seconds
68    pub fn age_seconds(&self) -> i64 {
69        (Utc::now() - self.cached_at).num_seconds()
70    }
71
72    /// Record an access to this cache entry
73    pub fn record_access(&mut self) {
74        self.access_count += 1;
75    }
76}
77
78/// Configuration for the cache system
79#[derive(Debug, Clone)]
80pub struct CacheConfig {
81    /// Maximum number of entries to cache
82    pub max_capacity: u64,
83    /// Time-to-live for latest rates (seconds)
84    pub latest_ttl: u64,
85    /// Time-to-live for historical rates (seconds)
86    pub historical_ttl: u64,
87    /// Enable cache statistics
88    pub enable_stats: bool,
89}
90
91impl Default for CacheConfig {
92    fn default() -> Self {
93        Self {
94            max_capacity: 1000,
95            latest_ttl: 300,      // 5 minutes for latest rates
96            historical_ttl: 3600, // 1 hour for historical rates
97            enable_stats: true,
98        }
99    }
100}
101
102/// High-performance cache for exchange rate data
103#[derive(Debug, Clone)]
104pub struct ExchangeRateCache {
105    /// Main cache storage
106    cache: Cache<CacheKey, Arc<CachedRates>>,
107    /// Cache configuration
108    config: CacheConfig,
109    /// Cache statistics
110    stats: Arc<dashmap::DashMap<String, u64>>,
111}
112
113impl ExchangeRateCache {
114    /// Create a new cache with default configuration
115    pub fn new() -> Self {
116        Self::with_config(CacheConfig::default())
117    }
118
119    /// Create a new cache with custom configuration
120    pub fn with_config(config: CacheConfig) -> Self {
121        let cache = Cache::builder()
122            .max_capacity(config.max_capacity)
123            .time_to_live(Duration::from_secs(
124                config.latest_ttl.max(config.historical_ttl),
125            ))
126            .build();
127
128        Self {
129            cache,
130            config,
131            stats: Arc::new(dashmap::DashMap::new()),
132        }
133    }
134
135    /// Get cached rates if available and valid
136    pub async fn get(&self, key: &CacheKey) -> Option<ExchangeRateResponse> {
137        self.increment_stat("requests_total");
138
139        if let Some(cached) = self.cache.get(key).await {
140            if cached.is_valid() {
141                self.increment_stat("hits");
142
143                // Update access count (clone to modify)
144                let mut updated = (*cached).clone();
145                updated.record_access();
146                self.cache.insert(key.clone(), Arc::new(updated)).await;
147
148                return Some(cached.data.clone());
149            } else {
150                // Remove expired entry
151                self.cache.remove(key).await;
152                self.increment_stat("expired");
153            }
154        }
155
156        self.increment_stat("misses");
157        None
158    }
159
160    /// Cache exchange rate data
161    pub async fn put(&self, key: CacheKey, data: ExchangeRateResponse) {
162        let ttl = if key.date.is_some() {
163            self.config.historical_ttl
164        } else {
165            self.config.latest_ttl
166        };
167
168        let cached_rates = Arc::new(CachedRates::new(data, ttl));
169        self.cache.insert(key, cached_rates).await;
170        self.increment_stat("insertions");
171    }
172
173    /// Get cache statistics
174    pub fn get_stats(&self) -> CacheStats {
175        let total_requests = self.get_stat("requests_total");
176        let hits = self.get_stat("hits");
177        let misses = self.get_stat("misses");
178        let expired = self.get_stat("expired");
179
180        let hit_rate = if total_requests > 0 {
181            (hits as f64 / total_requests as f64) * 100.0
182        } else {
183            0.0
184        };
185
186        CacheStats {
187            total_requests,
188            hits,
189            misses,
190            expired,
191            hit_rate,
192            cached_entries: self.cache.entry_count(),
193            weighted_size: self.cache.weighted_size(),
194        }
195    }
196
197    /// Clear all cached entries
198    pub async fn clear(&self) {
199        self.cache.invalidate_all();
200        if self.config.enable_stats {
201            self.stats.clear();
202        }
203    }
204
205    /// Remove expired entries manually
206    pub async fn cleanup_expired(&self) {
207        // Moka automatically handles TTL, but we can force a cleanup
208        self.cache.run_pending_tasks().await;
209    }
210
211    /// Get all cache keys (for debugging)
212    pub async fn get_keys(&self) -> Vec<CacheKey> {
213        // Note: Moka doesn't provide a direct way to iterate keys
214        // This is a simplified implementation for debugging
215        vec![]
216    }
217
218    /// Increment a statistic counter
219    fn increment_stat(&self, key: &str) {
220        if self.config.enable_stats {
221            self.stats
222                .entry(key.to_string())
223                .and_modify(|v| *v += 1)
224                .or_insert(1);
225        }
226    }
227
228    /// Get a statistic value
229    fn get_stat(&self, key: &str) -> u64 {
230        self.stats.get(key).map(|v| *v).unwrap_or(0)
231    }
232}
233
234impl Default for ExchangeRateCache {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240/// Cache performance statistics
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct CacheStats {
243    /// Total number of cache requests
244    pub total_requests: u64,
245    /// Number of cache hits
246    pub hits: u64,
247    /// Number of cache misses
248    pub misses: u64,
249    /// Number of expired entries removed
250    pub expired: u64,
251    /// Cache hit rate as a percentage
252    pub hit_rate: f64,
253    /// Current number of cached entries
254    pub cached_entries: u64,
255    /// Total weighted size of cache
256    pub weighted_size: u64,
257}
258
259impl CacheStats {
260    /// Format statistics for display
261    pub fn format_summary(&self) -> String {
262        format!(
263            "Cache Stats: {:.1}% hit rate ({}/{} requests), {} entries, {} expired",
264            self.hit_rate, self.hits, self.total_requests, self.cached_entries, self.expired
265        )
266    }
267}