1use std::collections::HashMap;
2use std::sync::Mutex;
3use std::time::{Duration, Instant};
4
5use sha2::{Digest, Sha256};
6
7use crate::model::Issue;
8
9use super::graph::{AnalysisConfig, GraphMetrics, IssueGraph};
10
11const DEFAULT_TTL_SECS: u64 = 300; #[derive(Clone)]
16struct CacheEntry {
17 metrics: GraphMetrics,
18 inserted_at: Instant,
19 ttl: Duration,
20}
21
22impl CacheEntry {
23 fn is_expired(&self) -> bool {
24 self.inserted_at.elapsed() > self.ttl
25 }
26}
27
28pub struct MetricsCache {
34 entries: Mutex<HashMap<[u8; 32], CacheEntry>>,
35 ttl: Duration,
36 hits: Mutex<u64>,
37 misses: Mutex<u64>,
38}
39
40#[derive(Debug, Clone, Copy)]
42pub struct CacheStats {
43 pub hits: u64,
44 pub misses: u64,
45 pub entries: usize,
46}
47
48impl MetricsCache {
49 #[must_use]
51 pub fn new() -> Self {
52 Self {
53 entries: Mutex::new(HashMap::new()),
54 ttl: Duration::from_secs(DEFAULT_TTL_SECS),
55 hits: Mutex::new(0),
56 misses: Mutex::new(0),
57 }
58 }
59
60 #[must_use]
62 pub fn with_ttl(ttl: Duration) -> Self {
63 Self {
64 entries: Mutex::new(HashMap::new()),
65 ttl,
66 hits: Mutex::new(0),
67 misses: Mutex::new(0),
68 }
69 }
70
71 pub fn get_or_compute(&self, issues: &[Issue], config: &AnalysisConfig) -> GraphMetrics {
76 let key = compute_cache_key(issues, config);
77
78 {
80 let entries = self
81 .entries
82 .lock()
83 .unwrap_or_else(std::sync::PoisonError::into_inner);
84 if let Some(entry) = entries.get(&key) {
85 if !entry.is_expired() {
86 *self
87 .hits
88 .lock()
89 .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
90 return entry.metrics.clone();
91 }
92 }
93 }
94
95 *self
97 .misses
98 .lock()
99 .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
100 let graph = IssueGraph::build(issues);
101 let metrics = graph.compute_metrics_with_config(config);
102
103 {
105 let mut entries = self
106 .entries
107 .lock()
108 .unwrap_or_else(std::sync::PoisonError::into_inner);
109 entries.retain(|_, entry| !entry.is_expired());
111 entries.insert(
112 key,
113 CacheEntry {
114 metrics: metrics.clone(),
115 inserted_at: Instant::now(),
116 ttl: self.ttl,
117 },
118 );
119 }
120
121 metrics
122 }
123
124 pub fn clear(&self) {
126 self.entries
127 .lock()
128 .unwrap_or_else(std::sync::PoisonError::into_inner)
129 .clear();
130 }
131
132 #[must_use]
134 pub fn stats(&self) -> CacheStats {
135 let entries = self
136 .entries
137 .lock()
138 .unwrap_or_else(std::sync::PoisonError::into_inner);
139 let hits = *self
140 .hits
141 .lock()
142 .unwrap_or_else(std::sync::PoisonError::into_inner);
143 let misses = *self
144 .misses
145 .lock()
146 .unwrap_or_else(std::sync::PoisonError::into_inner);
147 CacheStats {
148 hits,
149 misses,
150 entries: entries.len(),
151 }
152 }
153}
154
155impl Default for MetricsCache {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161fn compute_cache_key(issues: &[Issue], config: &AnalysisConfig) -> [u8; 32] {
169 let mut hasher = Sha256::new();
170
171 let mut sorted_ids: Vec<(&str, &str)> = issues
173 .iter()
174 .map(|i| (i.id.as_str(), i.status.as_str()))
175 .collect();
176 sorted_ids.sort();
177
178 for (id, status) in &sorted_ids {
179 hasher.update(id.as_bytes());
180 hasher.update(b"\0");
181 hasher.update(status.as_bytes());
182 hasher.update(b"\n");
183 }
184
185 let mut edges: Vec<(&str, &str)> = Vec::new();
187 for issue in issues {
188 for dep in &issue.dependencies {
189 if dep.is_blocking() {
190 edges.push((issue.id.as_str(), dep.depends_on_id.as_str()));
191 }
192 }
193 }
194 edges.sort();
195 for (from, to) in &edges {
196 hasher.update(from.as_bytes());
197 hasher.update(b"->");
198 hasher.update(to.as_bytes());
199 hasher.update(b"\n");
200 }
201
202 hasher.update(if config.enable_pagerank {
204 b"pr:1"
205 } else {
206 b"pr:0"
207 });
208 hasher.update(if config.enable_betweenness {
209 b"bt:1"
210 } else {
211 b"bt:0"
212 });
213 hasher.update(if config.enable_eigenvector {
214 b"ev:1"
215 } else {
216 b"ev:0"
217 });
218 hasher.update(if config.enable_hits { b"hi:1" } else { b"hi:0" });
219 hasher.update(if config.enable_k_core {
220 b"kc:1"
221 } else {
222 b"kc:0"
223 });
224 hasher.update(if config.enable_cycles {
225 b"cy:1"
226 } else {
227 b"cy:0"
228 });
229 hasher.update(if config.enable_critical_path {
230 b"cp:1"
231 } else {
232 b"cp:0"
233 });
234 hasher.update(if config.enable_articulation {
235 b"ap:1"
236 } else {
237 b"ap:0"
238 });
239 hasher.update(if config.enable_slack {
240 b"sl:1"
241 } else {
242 b"sl:0"
243 });
244
245 let result = hasher.finalize();
246 let mut key = [0u8; 32];
247 key.copy_from_slice(&result);
248 key
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::model::{Dependency, Issue};
255 use std::time::Duration;
256
257 fn make_issue(id: &str, status: &str) -> Issue {
258 Issue {
259 id: id.to_string(),
260 title: format!("Issue {id}"),
261 status: status.to_string(),
262 issue_type: "task".to_string(),
263 priority: 2,
264 ..Issue::default()
265 }
266 }
267
268 fn make_blocked(id: &str, depends_on: &str) -> Issue {
269 Issue {
270 id: id.to_string(),
271 title: format!("Issue {id}"),
272 status: "blocked".to_string(),
273 issue_type: "task".to_string(),
274 priority: 2,
275 dependencies: vec![Dependency {
276 issue_id: id.to_string(),
277 depends_on_id: depends_on.to_string(),
278 dep_type: "blocks".to_string(),
279 ..Dependency::default()
280 }],
281 ..Issue::default()
282 }
283 }
284
285 #[test]
286 fn cache_hit_returns_same_metrics() {
287 let cache = MetricsCache::new();
288 let issues = vec![make_issue("A", "open"), make_blocked("B", "A")];
289 let config = AnalysisConfig::default();
290
291 let first = cache.get_or_compute(&issues, &config);
292 let second = cache.get_or_compute(&issues, &config);
293
294 assert_eq!(first.pagerank.get("A"), second.pagerank.get("A"));
296 assert_eq!(first.pagerank.get("B"), second.pagerank.get("B"));
297
298 let stats = cache.stats();
299 assert_eq!(stats.misses, 1);
300 assert_eq!(stats.hits, 1);
301 assert_eq!(stats.entries, 1);
302 }
303
304 #[test]
305 fn cache_miss_on_changed_graph() {
306 let cache = MetricsCache::new();
307 let config = AnalysisConfig::default();
308
309 let issues_v1 = vec![make_issue("A", "open")];
310 cache.get_or_compute(&issues_v1, &config);
311
312 let issues_v2 = vec![make_issue("A", "open"), make_issue("B", "open")];
313 cache.get_or_compute(&issues_v2, &config);
314
315 let stats = cache.stats();
316 assert_eq!(stats.misses, 2);
317 assert_eq!(stats.hits, 0);
318 }
319
320 #[test]
321 fn cache_miss_on_status_change() {
322 let cache = MetricsCache::new();
323 let config = AnalysisConfig::default();
324
325 let issues_open = vec![make_issue("A", "open")];
326 cache.get_or_compute(&issues_open, &config);
327
328 let issues_closed = vec![make_issue("A", "closed")];
329 cache.get_or_compute(&issues_closed, &config);
330
331 let stats = cache.stats();
332 assert_eq!(stats.misses, 2, "status change should invalidate cache");
333 }
334
335 #[test]
336 fn cache_ttl_expiry() {
337 let cache = MetricsCache::with_ttl(Duration::from_millis(1));
338 let issues = vec![make_issue("A", "open")];
339 let config = AnalysisConfig::default();
340
341 cache.get_or_compute(&issues, &config);
342 std::thread::sleep(Duration::from_millis(10));
344 cache.get_or_compute(&issues, &config);
345
346 let stats = cache.stats();
347 assert_eq!(stats.misses, 2, "expired entry should cause a miss");
348 }
349
350 #[test]
351 fn cache_clear() {
352 let cache = MetricsCache::new();
353 let issues = vec![make_issue("A", "open")];
354 let config = AnalysisConfig::default();
355
356 cache.get_or_compute(&issues, &config);
357 assert_eq!(cache.stats().entries, 1);
358
359 cache.clear();
360 assert_eq!(cache.stats().entries, 0);
361
362 cache.get_or_compute(&issues, &config);
364 assert_eq!(cache.stats().misses, 2);
365 }
366
367 #[test]
368 fn cache_key_deterministic() {
369 let issues = vec![make_issue("A", "open"), make_blocked("B", "A")];
370 let config = AnalysisConfig::default();
371
372 let key1 = compute_cache_key(&issues, &config);
373 let key2 = compute_cache_key(&issues, &config);
374 assert_eq!(key1, key2);
375 }
376
377 #[test]
378 fn cache_key_differs_for_different_config() {
379 let issues = vec![make_issue("A", "open")];
380 let config1 = AnalysisConfig::default();
381 let mut config2 = AnalysisConfig::default();
382 config2.enable_pagerank = false;
383
384 let key1 = compute_cache_key(&issues, &config1);
385 let key2 = compute_cache_key(&issues, &config2);
386 assert_ne!(key1, key2);
387 }
388
389 #[test]
390 fn cache_empty_issues() {
391 let cache = MetricsCache::new();
392 let issues: Vec<Issue> = vec![];
393 let config = AnalysisConfig::default();
394
395 let metrics = cache.get_or_compute(&issues, &config);
396 assert!(metrics.pagerank.is_empty());
397 assert_eq!(cache.stats().misses, 1);
398 }
399
400 #[test]
401 fn cache_default_impl() {
402 let cache = MetricsCache::default();
403 let stats = cache.stats();
404 assert_eq!(stats.hits, 0);
405 assert_eq!(stats.misses, 0);
406 assert_eq!(stats.entries, 0);
407 }
408}