1use std::collections::HashMap;
2use std::sync::Mutex;
3use std::time::{Duration, Instant, SystemTime};
4
5static PROVIDER_CACHE: std::sync::LazyLock<Mutex<ProviderCache>> =
6 std::sync::LazyLock::new(|| Mutex::new(ProviderCache::new()));
7
8struct CacheEntry {
9 data: String,
10 expires_at: Instant,
11 provider_id: String,
12}
13
14#[derive(Debug, Clone, Default)]
16pub struct ProviderCacheStats {
17 pub provider_id: String,
18 pub hits: u64,
19 pub misses: u64,
20 pub entry_count: usize,
21 pub last_fetch: Option<SystemTime>,
22}
23
24impl ProviderCacheStats {
25 pub fn hit_rate(&self) -> f64 {
26 let total = self.hits + self.misses;
27 if total == 0 {
28 return 0.0;
29 }
30 self.hits as f64 / total as f64
31 }
32}
33
34#[derive(Debug, Clone, Default)]
36pub struct CacheMetrics {
37 pub total_hits: u64,
38 pub total_misses: u64,
39 pub total_entries: usize,
40 pub provider_stats: Vec<ProviderCacheStats>,
41}
42
43impl CacheMetrics {
44 pub fn total_hit_rate(&self) -> f64 {
45 let total = self.total_hits + self.total_misses;
46 if total == 0 {
47 return 0.0;
48 }
49 self.total_hits as f64 / total as f64
50 }
51}
52
53struct ProviderCache {
54 entries: HashMap<String, CacheEntry>,
55 hits: HashMap<String, u64>,
56 misses: HashMap<String, u64>,
57 last_fetch: HashMap<String, SystemTime>,
58}
59
60impl ProviderCache {
61 fn new() -> Self {
62 Self {
63 entries: HashMap::new(),
64 hits: HashMap::new(),
65 misses: HashMap::new(),
66 last_fetch: HashMap::new(),
67 }
68 }
69
70 fn get(&mut self, key: &str) -> Option<&str> {
71 self.entries.retain(|_, v| v.expires_at > Instant::now());
72 if let Some(entry) = self.entries.get(key) {
73 *self.hits.entry(entry.provider_id.clone()).or_insert(0) += 1;
74 Some(entry.data.as_str())
75 } else {
76 let provider = key.split(':').next().unwrap_or("unknown");
77 *self.misses.entry(provider.to_string()).or_insert(0) += 1;
78 None
79 }
80 }
81
82 fn set(&mut self, key: String, data: String, ttl: Duration, provider_id: &str) {
83 self.last_fetch
84 .insert(provider_id.to_string(), SystemTime::now());
85 self.entries.insert(
86 key,
87 CacheEntry {
88 data,
89 expires_at: Instant::now() + ttl,
90 provider_id: provider_id.to_string(),
91 },
92 );
93 }
94
95 fn invalidate_provider(&mut self, provider_id: &str) -> usize {
96 let before = self.entries.len();
97 self.entries.retain(|_, v| v.provider_id != provider_id);
98 before - self.entries.len()
99 }
100
101 fn invalidate_all(&mut self) -> usize {
102 let count = self.entries.len();
103 self.entries.clear();
104 count
105 }
106
107 fn metrics(&mut self) -> CacheMetrics {
108 self.entries.retain(|_, v| v.expires_at > Instant::now());
109
110 let mut by_provider: HashMap<String, ProviderCacheStats> = HashMap::new();
111
112 for entry in self.entries.values() {
113 let stats = by_provider.entry(entry.provider_id.clone()).or_default();
114 stats.provider_id.clone_from(&entry.provider_id);
115 stats.entry_count += 1;
116 }
117
118 for (pid, &count) in &self.hits {
119 let stats = by_provider.entry(pid.clone()).or_default();
120 stats.provider_id.clone_from(pid);
121 stats.hits = count;
122 }
123 for (pid, &count) in &self.misses {
124 let stats = by_provider.entry(pid.clone()).or_default();
125 stats.provider_id.clone_from(pid);
126 stats.misses = count;
127 }
128 for (pid, &ts) in &self.last_fetch {
129 let stats = by_provider.entry(pid.clone()).or_default();
130 stats.provider_id.clone_from(pid);
131 stats.last_fetch = Some(ts);
132 }
133
134 let mut provider_stats: Vec<_> = by_provider.into_values().collect();
135 provider_stats.sort_by(|a, b| a.provider_id.cmp(&b.provider_id));
136
137 CacheMetrics {
138 total_hits: self.hits.values().sum(),
139 total_misses: self.misses.values().sum(),
140 total_entries: self.entries.len(),
141 provider_stats,
142 }
143 }
144}
145
146pub fn get_cached(key: &str) -> Option<String> {
147 PROVIDER_CACHE
148 .lock()
149 .ok()
150 .and_then(|mut c| c.get(key).map(std::string::ToString::to_string))
151}
152
153pub fn set_cached(key: &str, data: &str, ttl_secs: u64) {
154 set_cached_with_provider(
155 key,
156 data,
157 ttl_secs,
158 key.split(':').next().unwrap_or("unknown"),
159 );
160}
161
162pub fn set_cached_with_provider(key: &str, data: &str, ttl_secs: u64, provider_id: &str) {
163 if let Ok(mut cache) = PROVIDER_CACHE.lock() {
164 cache.set(
165 key.to_string(),
166 data.to_string(),
167 Duration::from_secs(ttl_secs),
168 provider_id,
169 );
170 }
171}
172
173pub fn invalidate_provider(provider_id: &str) -> usize {
174 PROVIDER_CACHE
175 .lock()
176 .ok()
177 .map_or(0, |mut c| c.invalidate_provider(provider_id))
178}
179
180pub fn invalidate_all() -> usize {
181 PROVIDER_CACHE
182 .lock()
183 .ok()
184 .map_or(0, |mut c| c.invalidate_all())
185}
186
187pub fn cache_metrics() -> CacheMetrics {
188 PROVIDER_CACHE
189 .lock()
190 .ok()
191 .map(|mut c| c.metrics())
192 .unwrap_or_default()
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn cache_set_and_get() {
201 let mut cache = ProviderCache::new();
202 cache.set(
203 "test:key".into(),
204 "value".into(),
205 Duration::from_mins(1),
206 "test",
207 );
208 assert_eq!(cache.get("test:key"), Some("value"));
209 }
210
211 #[test]
212 fn cache_expired_entry_returns_none() {
213 let mut cache = ProviderCache::new();
214 cache.set(
215 "test:key".into(),
216 "value".into(),
217 Duration::from_secs(0),
218 "test",
219 );
220 std::thread::sleep(Duration::from_millis(10));
221 assert!(cache.get("test:key").is_none());
222 }
223
224 #[test]
225 fn cache_tracks_hits_and_misses() {
226 let mut cache = ProviderCache::new();
227 cache.set(
228 "github:key".into(),
229 "data".into(),
230 Duration::from_mins(1),
231 "github",
232 );
233 cache.get("github:key"); cache.get("github:key"); cache.get("github:missing"); let metrics = cache.metrics();
238 assert_eq!(metrics.total_hits, 2);
239 assert_eq!(metrics.total_misses, 1);
240 assert!((metrics.total_hit_rate() - 0.666).abs() < 0.01);
241 }
242
243 #[test]
244 fn cache_invalidate_provider() {
245 let mut cache = ProviderCache::new();
246 cache.set(
247 "github:a".into(),
248 "1".into(),
249 Duration::from_mins(1),
250 "github",
251 );
252 cache.set(
253 "github:b".into(),
254 "2".into(),
255 Duration::from_mins(1),
256 "github",
257 );
258 cache.set(
259 "gitlab:c".into(),
260 "3".into(),
261 Duration::from_mins(1),
262 "gitlab",
263 );
264
265 let removed = cache.invalidate_provider("github");
266 assert_eq!(removed, 2);
267 assert!(cache.get("github:a").is_none());
268 assert_eq!(cache.get("gitlab:c"), Some("3"));
269 }
270
271 #[test]
272 fn cache_invalidate_all() {
273 let mut cache = ProviderCache::new();
274 cache.set("a".into(), "1".into(), Duration::from_mins(1), "x");
275 cache.set("b".into(), "2".into(), Duration::from_mins(1), "y");
276
277 let removed = cache.invalidate_all();
278 assert_eq!(removed, 2);
279 assert!(cache.get("a").is_none());
280 }
281
282 #[test]
283 fn cache_metrics_per_provider() {
284 let mut cache = ProviderCache::new();
285 cache.set(
286 "github:x".into(),
287 "a".into(),
288 Duration::from_mins(1),
289 "github",
290 );
291 cache.set(
292 "gitlab:y".into(),
293 "b".into(),
294 Duration::from_mins(1),
295 "gitlab",
296 );
297 cache.get("github:x");
298 cache.get("gitlab:miss");
299
300 let metrics = cache.metrics();
301 assert_eq!(metrics.provider_stats.len(), 2);
302
303 let gh = metrics
304 .provider_stats
305 .iter()
306 .find(|s| s.provider_id == "github")
307 .unwrap();
308 assert_eq!(gh.entry_count, 1);
309 assert_eq!(gh.hits, 1);
310
311 let gl = metrics
312 .provider_stats
313 .iter()
314 .find(|s| s.provider_id == "gitlab")
315 .unwrap();
316 assert_eq!(gl.entry_count, 1);
317 assert!(gl.last_fetch.is_some());
318 }
319
320 #[test]
321 fn provider_cache_stats_hit_rate() {
322 let stats = ProviderCacheStats {
323 provider_id: "test".into(),
324 hits: 3,
325 misses: 1,
326 entry_count: 2,
327 last_fetch: None,
328 };
329 assert!((stats.hit_rate() - 0.75).abs() < f64::EPSILON);
330 }
331
332 #[test]
333 fn provider_cache_stats_hit_rate_zero() {
334 let stats = ProviderCacheStats::default();
335 assert!((stats.hit_rate() - 0.0).abs() < f64::EPSILON);
336 }
337}