1use std::path::PathBuf;
15use std::sync::OnceLock;
16
17use anyhow::{Context, Result};
18use chrono::{DateTime, Duration, Utc};
19use serde::{Deserialize, Serialize};
20#[cfg(test)]
21use tracing::debug;
22use tracing::warn;
23
24static CACHE_UNAVAILABLE_WARNING: OnceLock<()> = OnceLock::new();
26
27pub const DEFAULT_ISSUE_TTL_MINS: i64 = 60;
29
30pub const DEFAULT_REPO_TTL_HOURS: i64 = 24;
32
33pub const DEFAULT_MODEL_TTL_SECS: u64 = 86400;
35
36pub const DEFAULT_SECURITY_TTL_DAYS: i64 = 7;
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
43pub(crate) struct CacheEntry<T> {
44 pub data: T,
46 pub cached_at: DateTime<Utc>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub etag: Option<String>,
51}
52
53impl<T> CacheEntry<T> {
54 pub fn new(data: T) -> Self {
56 Self {
57 data,
58 cached_at: Utc::now(),
59 etag: None,
60 }
61 }
62
63 #[cfg(test)]
65 pub fn with_etag(data: T, etag: String) -> Self {
66 Self {
67 data,
68 cached_at: Utc::now(),
69 etag: Some(etag),
70 }
71 }
72
73 pub fn is_valid(&self, ttl: Duration) -> bool {
83 let now = Utc::now();
84 now.signed_duration_since(self.cached_at) < ttl
85 }
86}
87
88#[must_use]
96pub fn cache_dir() -> Option<PathBuf> {
97 dirs::cache_dir().map(|dir| dir.join("aptu"))
98}
99
100#[allow(async_fn_in_trait)]
108pub(crate) trait FileCache<V> {
109 async fn get(&self, key: &str) -> Result<Option<V>>;
119
120 async fn get_stale(&self, key: &str) -> Result<Option<V>>;
130
131 async fn set(&self, key: &str, value: &V) -> Result<()>;
138
139 #[cfg(test)]
145 async fn remove(&self, key: &str) -> Result<()>;
146}
147
148pub(crate) struct FileCacheImpl<V> {
153 cache_dir: Option<PathBuf>,
154 ttl: Duration,
155 subdirectory: String,
156 _phantom: std::marker::PhantomData<V>,
157}
158
159impl<V> FileCacheImpl<V>
160where
161 V: Serialize + for<'de> Deserialize<'de>,
162{
163 #[must_use]
173 pub fn new(subdirectory: impl Into<String>, ttl: Duration) -> Self {
174 let cache_dir = cache_dir();
175 if cache_dir.is_none() {
176 CACHE_UNAVAILABLE_WARNING.get_or_init(|| {
177 warn!("Cache directory unavailable, caching disabled");
178 });
179 }
180 Self::with_dir(cache_dir, subdirectory, ttl)
181 }
182
183 #[must_use]
191 pub fn with_dir(
192 cache_dir: Option<PathBuf>,
193 subdirectory: impl Into<String>,
194 ttl: Duration,
195 ) -> Self {
196 Self {
197 cache_dir,
198 ttl,
199 subdirectory: subdirectory.into(),
200 _phantom: std::marker::PhantomData,
201 }
202 }
203
204 fn is_enabled(&self) -> bool {
206 self.cache_dir.is_some()
207 }
208
209 fn cache_path(&self, key: &str) -> Option<PathBuf> {
214 if key.contains('/') || key.contains('\\') || key.contains("..") {
216 return None;
217 }
218
219 let filename = if std::path::Path::new(key)
220 .extension()
221 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
222 {
223 key.to_string()
224 } else {
225 format!("{key}.json")
226 };
227 self.cache_dir
228 .as_ref()
229 .map(|dir| dir.join(&self.subdirectory).join(filename))
230 }
231
232 #[cfg(test)]
245 pub async fn evict_stale(&self, eviction_days: i64) -> usize {
246 if !self.is_enabled() {
247 return 0;
248 }
249
250 let Some(cache_dir) = &self.cache_dir else {
251 return 0;
252 };
253
254 let subdir = cache_dir.join(&self.subdirectory);
255
256 if !tokio::fs::try_exists(&subdir).await.unwrap_or(false) {
258 return 0;
259 }
260
261 let Ok(mut read_dir) = tokio::fs::read_dir(&subdir).await else {
262 return 0;
263 };
264
265 let mut evicted_count = 0;
266 let cutoff_time = Utc::now() - Duration::days(eviction_days);
267
268 while let Ok(Some(entry)) = read_dir.next_entry().await {
269 let path = entry.path();
270
271 if !path
273 .extension()
274 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
275 {
276 continue;
277 }
278
279 let Ok(contents) = tokio::fs::read_to_string(&path).await else {
280 continue;
281 };
282
283 let Ok(entry_data) = serde_json::from_str::<CacheEntry<serde_json::Value>>(&contents)
284 else {
285 continue;
286 };
287
288 if entry_data.cached_at < cutoff_time && tokio::fs::remove_file(&path).await.is_ok() {
289 debug!("Evicted stale cache file: {}", path.display());
290 evicted_count += 1;
291 }
292 }
293
294 evicted_count
295 }
296}
297
298impl<V> FileCache<V> for FileCacheImpl<V>
299where
300 V: Serialize + for<'de> Deserialize<'de>,
301{
302 async fn get(&self, key: &str) -> Result<Option<V>> {
303 if !self.is_enabled() {
304 return Ok(None);
305 }
306
307 let Some(path) = self.cache_path(key) else {
308 return Ok(None);
309 };
310
311 if !tokio::fs::try_exists(&path)
312 .await
313 .with_context(|| format!("Failed to check cache file: {}", path.display()))?
314 {
315 return Ok(None);
316 }
317
318 let contents = tokio::fs::read_to_string(&path)
319 .await
320 .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
321
322 let entry: CacheEntry<V> = serde_json::from_str(&contents)
323 .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
324
325 if entry.is_valid(self.ttl) {
326 Ok(Some(entry.data))
327 } else {
328 Ok(None)
329 }
330 }
331
332 async fn get_stale(&self, key: &str) -> Result<Option<V>> {
333 if !self.is_enabled() {
334 return Ok(None);
335 }
336
337 let Some(path) = self.cache_path(key) else {
338 return Ok(None);
339 };
340
341 if !tokio::fs::try_exists(&path)
342 .await
343 .with_context(|| format!("Failed to check cache file: {}", path.display()))?
344 {
345 return Ok(None);
346 }
347
348 let contents = tokio::fs::read_to_string(&path)
349 .await
350 .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
351
352 let entry: CacheEntry<V> = serde_json::from_str(&contents)
353 .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
354
355 Ok(Some(entry.data))
356 }
357
358 async fn set(&self, key: &str, value: &V) -> Result<()> {
359 if !self.is_enabled() {
360 return Ok(());
361 }
362
363 let Some(path) = self.cache_path(key) else {
364 return Ok(());
365 };
366
367 if let Some(parent) = path.parent() {
369 tokio::fs::create_dir_all(parent).await.with_context(|| {
370 format!("Failed to create cache directory: {}", parent.display())
371 })?;
372 }
373
374 let entry = CacheEntry::new(value);
375 let contents =
376 serde_json::to_string_pretty(&entry).context("Failed to serialize cache entry")?;
377
378 let temp_path = path.with_extension("tmp");
380 tokio::fs::write(&temp_path, contents)
381 .await
382 .with_context(|| format!("Failed to write cache temp file: {}", temp_path.display()))?;
383
384 tokio::fs::rename(&temp_path, &path)
385 .await
386 .with_context(|| format!("Failed to rename cache file: {}", path.display()))?;
387
388 Ok(())
389 }
390
391 #[cfg(test)]
392 async fn remove(&self, key: &str) -> Result<()> {
393 if !self.is_enabled() {
394 return Ok(());
395 }
396
397 let Some(path) = self.cache_path(key) else {
398 return Ok(());
399 };
400
401 if tokio::fs::try_exists(&path)
402 .await
403 .with_context(|| format!("Failed to check cache file: {}", path.display()))?
404 {
405 tokio::fs::remove_file(&path)
406 .await
407 .with_context(|| format!("Failed to remove cache file: {}", path.display()))?;
408 }
409 Ok(())
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
418 struct TestData {
419 value: String,
420 count: u32,
421 }
422
423 #[test]
424 fn test_cache_entry_new() {
425 let data = TestData {
426 value: "test".to_string(),
427 count: 42,
428 };
429 let entry = CacheEntry::new(data.clone());
430
431 assert_eq!(entry.data, data);
432 assert!(entry.etag.is_none());
433 }
434
435 #[test]
436 fn test_cache_entry_with_etag() {
437 let data = TestData {
438 value: "test".to_string(),
439 count: 42,
440 };
441 let etag = "abc123".to_string();
442 let entry = CacheEntry::with_etag(data.clone(), etag.clone());
443
444 assert_eq!(entry.data, data);
445 assert_eq!(entry.etag, Some(etag));
446 }
447
448 #[test]
449 fn test_cache_entry_is_valid_within_ttl() {
450 let data = TestData {
451 value: "test".to_string(),
452 count: 42,
453 };
454 let entry = CacheEntry::new(data);
455 let ttl = Duration::hours(1);
456
457 assert!(entry.is_valid(ttl));
458 }
459
460 #[test]
461 fn test_cache_entry_is_valid_expired() {
462 let data = TestData {
463 value: "test".to_string(),
464 count: 42,
465 };
466 let mut entry = CacheEntry::new(data);
467 entry.cached_at = Utc::now() - Duration::hours(2);
469 let ttl = Duration::hours(1);
470
471 assert!(!entry.is_valid(ttl));
472 }
473
474 #[test]
475 fn test_cache_dir_path() {
476 let dir = cache_dir();
477 assert!(dir.is_some());
478 assert!(dir.unwrap().ends_with("aptu"));
479 }
480
481 #[test]
482 fn test_cache_serialization_with_etag() {
483 let data = TestData {
484 value: "test".to_string(),
485 count: 42,
486 };
487 let etag = "xyz789".to_string();
488 let entry = CacheEntry::with_etag(data.clone(), etag.clone());
489
490 let json = serde_json::to_string(&entry).expect("serialize");
491 let parsed: CacheEntry<TestData> = serde_json::from_str(&json).expect("deserialize");
492
493 assert_eq!(parsed.data, data);
494 assert_eq!(parsed.etag, Some(etag));
495 }
496
497 #[tokio::test]
498 async fn test_file_cache_get_set() {
499 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
500 let data = TestData {
501 value: "test".to_string(),
502 count: 42,
503 };
504
505 cache.set("test_key", &data).await.expect("set cache");
507
508 let result = cache.get("test_key").await.expect("get cache");
510 assert!(result.is_some());
511 assert_eq!(result.unwrap(), data);
512
513 cache.remove("test_key").await.ok();
515 }
516
517 #[tokio::test]
518 async fn test_file_cache_get_miss() {
519 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
520
521 let result = cache.get("nonexistent").await.expect("get cache");
522 assert!(result.is_none());
523 }
524
525 #[tokio::test]
526 async fn test_file_cache_get_stale() {
527 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::seconds(0));
528 let data = TestData {
529 value: "stale".to_string(),
530 count: 99,
531 };
532
533 cache.set("stale_key", &data).await.expect("set cache");
535
536 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
538
539 let result = cache.get("stale_key").await.expect("get cache");
541 assert!(result.is_none());
542
543 let stale_result = cache.get_stale("stale_key").await.expect("get stale cache");
545 assert!(stale_result.is_some());
546 assert_eq!(stale_result.unwrap(), data);
547
548 cache.remove("stale_key").await.ok();
550 }
551
552 #[tokio::test]
553 async fn test_file_cache_remove() {
554 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
555 let data = TestData {
556 value: "remove_me".to_string(),
557 count: 1,
558 };
559
560 cache.set("remove_key", &data).await.expect("set cache");
562
563 assert!(cache.get("remove_key").await.expect("get cache").is_some());
565
566 cache.remove("remove_key").await.expect("remove cache");
568
569 assert!(cache.get("remove_key").await.expect("get cache").is_none());
571 }
572
573 #[tokio::test]
574 async fn test_cache_key_rejects_forward_slash() {
575 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
576 let result = cache
577 .get("../etc/passwd")
578 .await
579 .expect("get should succeed");
580 assert!(result.is_none());
581 }
582
583 #[tokio::test]
584 async fn test_cache_key_rejects_backslash() {
585 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
586 let data = TestData {
587 value: "x".to_string(),
588 count: 0,
589 };
590 let result = cache
591 .set("..\\windows\\system32", &data)
592 .await
593 .expect("set should succeed silently");
594 assert_eq!(result, ());
595 }
596
597 #[tokio::test]
598 async fn test_cache_key_rejects_parent_dir() {
599 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
600 let result = cache.get("foo..bar").await.expect("get should succeed");
601 assert!(result.is_none());
602 }
603
604 #[tokio::test]
605 async fn test_disabled_cache_get_returns_none() {
606 let cache: FileCacheImpl<TestData> =
607 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
608 let result = cache.get("any_key").await.expect("get should succeed");
609 assert!(result.is_none());
610 }
611
612 #[tokio::test]
613 async fn test_disabled_cache_set_succeeds_silently() {
614 let cache: FileCacheImpl<TestData> =
615 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
616 let data = TestData {
617 value: "test".to_string(),
618 count: 42,
619 };
620 cache
621 .set("any_key", &data)
622 .await
623 .expect("set should succeed");
624 }
625
626 #[tokio::test]
627 async fn test_disabled_cache_remove_succeeds_silently() {
628 let cache: FileCacheImpl<TestData> =
629 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
630 cache
631 .remove("any_key")
632 .await
633 .expect("remove should succeed");
634 }
635
636 #[tokio::test]
637 async fn test_disabled_cache_get_stale_returns_none() {
638 let cache: FileCacheImpl<TestData> =
639 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
640 let result = cache
641 .get_stale("any_key")
642 .await
643 .expect("get_stale should succeed");
644 assert!(result.is_none());
645 }
646
647 #[tokio::test]
648 async fn test_evict_stale_removes_old_files() {
649 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_evict", Duration::hours(1));
650 let data = TestData {
651 value: "old".to_string(),
652 count: 1,
653 };
654
655 cache.set("old_key", &data).await.expect("set cache");
657
658 if let Some(path) = cache.cache_path("old_key") {
660 let contents = tokio::fs::read_to_string(&path)
661 .await
662 .expect("read cache file");
663 let mut entry: CacheEntry<TestData> =
664 serde_json::from_str(&contents).expect("parse cache entry");
665 entry.cached_at = Utc::now() - Duration::days(10);
666 let new_contents = serde_json::to_string_pretty(&entry).expect("serialize cache entry");
667 tokio::fs::write(&path, new_contents)
668 .await
669 .expect("write cache file");
670 }
671
672 let evicted = cache.evict_stale(7).await;
674 assert_eq!(evicted, 1);
675
676 let result = cache.get("old_key").await.expect("get cache");
678 assert!(result.is_none());
679 }
680
681 #[tokio::test]
682 async fn test_evict_stale_preserves_fresh_files() {
683 let cache: FileCacheImpl<TestData> =
684 FileCacheImpl::new("test_evict_fresh", Duration::hours(1));
685 let data = TestData {
686 value: "fresh".to_string(),
687 count: 2,
688 };
689
690 cache.set("fresh_key", &data).await.expect("set cache");
692
693 let evicted = cache.evict_stale(7).await;
695 assert_eq!(evicted, 0);
696
697 let result = cache.get("fresh_key").await.expect("get cache");
699 assert!(result.is_some());
700 assert_eq!(result.unwrap(), data);
701
702 cache.remove("fresh_key").await.ok();
704 }
705}