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#[cfg(not(target_arch = "wasm32"))]
96#[must_use]
97pub fn cache_dir() -> Option<PathBuf> {
98 dirs::cache_dir().map(|dir| dir.join("aptu"))
99}
100
101#[allow(async_fn_in_trait)]
109pub(crate) trait FileCache<V> {
110 async fn get(&self, key: &str) -> Result<Option<V>>;
120
121 async fn get_stale(&self, key: &str) -> Result<Option<V>>;
131
132 async fn set(&self, key: &str, value: &V) -> Result<()>;
139
140 #[cfg(test)]
146 async fn remove(&self, key: &str) -> Result<()>;
147}
148
149#[cfg(not(target_arch = "wasm32"))]
154pub(crate) struct FileCacheImpl<V> {
155 cache_dir: Option<PathBuf>,
156 ttl: Duration,
157 subdirectory: String,
158 _phantom: std::marker::PhantomData<V>,
159}
160
161#[cfg(not(target_arch = "wasm32"))]
162impl<V> FileCacheImpl<V>
163where
164 V: Serialize + for<'de> Deserialize<'de>,
165{
166 #[must_use]
176 pub fn new(subdirectory: impl Into<String>, ttl: Duration) -> Self {
177 let cache_dir = cache_dir();
178 if cache_dir.is_none() {
179 CACHE_UNAVAILABLE_WARNING.get_or_init(|| {
180 warn!("Cache directory unavailable, caching disabled");
181 });
182 }
183 Self::with_dir(cache_dir, subdirectory, ttl)
184 }
185
186 #[must_use]
194 pub fn with_dir(
195 cache_dir: Option<PathBuf>,
196 subdirectory: impl Into<String>,
197 ttl: Duration,
198 ) -> Self {
199 Self {
200 cache_dir,
201 ttl,
202 subdirectory: subdirectory.into(),
203 _phantom: std::marker::PhantomData,
204 }
205 }
206
207 fn is_enabled(&self) -> bool {
209 self.cache_dir.is_some()
210 }
211
212 fn cache_path(&self, key: &str) -> Option<PathBuf> {
217 if key.contains('/') || key.contains('\\') || key.contains("..") {
219 return None;
220 }
221
222 let filename = if std::path::Path::new(key)
223 .extension()
224 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
225 {
226 key.to_string()
227 } else {
228 format!("{key}.json")
229 };
230 self.cache_dir
231 .as_ref()
232 .map(|dir| dir.join(&self.subdirectory).join(filename))
233 }
234
235 #[cfg(test)]
248 pub async fn evict_stale(&self, eviction_days: i64) -> usize {
249 if !self.is_enabled() {
250 return 0;
251 }
252
253 let Some(cache_dir) = &self.cache_dir else {
254 return 0;
255 };
256
257 let subdir = cache_dir.join(&self.subdirectory);
258
259 if !tokio::fs::try_exists(&subdir).await.unwrap_or(false) {
261 return 0;
262 }
263
264 let Ok(mut read_dir) = tokio::fs::read_dir(&subdir).await else {
265 return 0;
266 };
267
268 let mut evicted_count = 0;
269 let cutoff_time = Utc::now() - Duration::days(eviction_days);
270
271 while let Ok(Some(entry)) = read_dir.next_entry().await {
272 let path = entry.path();
273
274 if !path
276 .extension()
277 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
278 {
279 continue;
280 }
281
282 let Ok(contents) = tokio::fs::read_to_string(&path).await else {
283 continue;
284 };
285
286 let Ok(entry_data) = serde_json::from_str::<CacheEntry<serde_json::Value>>(&contents)
287 else {
288 continue;
289 };
290
291 if entry_data.cached_at < cutoff_time && tokio::fs::remove_file(&path).await.is_ok() {
292 debug!("Evicted stale cache file: {}", path.display());
293 evicted_count += 1;
294 }
295 }
296
297 evicted_count
298 }
299}
300
301#[cfg(not(target_arch = "wasm32"))]
302impl<V> FileCache<V> for FileCacheImpl<V>
303where
304 V: Serialize + for<'de> Deserialize<'de>,
305{
306 async fn get(&self, key: &str) -> Result<Option<V>> {
307 if !self.is_enabled() {
308 return Ok(None);
309 }
310
311 let Some(path) = self.cache_path(key) else {
312 return Ok(None);
313 };
314
315 if !tokio::fs::try_exists(&path)
316 .await
317 .with_context(|| format!("Failed to check cache file: {}", path.display()))?
318 {
319 return Ok(None);
320 }
321
322 let contents = tokio::fs::read_to_string(&path)
323 .await
324 .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
325
326 let entry: CacheEntry<V> = serde_json::from_str(&contents)
327 .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
328
329 if entry.is_valid(self.ttl) {
330 Ok(Some(entry.data))
331 } else {
332 Ok(None)
333 }
334 }
335
336 async fn get_stale(&self, key: &str) -> Result<Option<V>> {
337 if !self.is_enabled() {
338 return Ok(None);
339 }
340
341 let Some(path) = self.cache_path(key) else {
342 return Ok(None);
343 };
344
345 if !tokio::fs::try_exists(&path)
346 .await
347 .with_context(|| format!("Failed to check cache file: {}", path.display()))?
348 {
349 return Ok(None);
350 }
351
352 let contents = tokio::fs::read_to_string(&path)
353 .await
354 .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
355
356 let entry: CacheEntry<V> = serde_json::from_str(&contents)
357 .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
358
359 Ok(Some(entry.data))
360 }
361
362 async fn set(&self, key: &str, value: &V) -> Result<()> {
363 if !self.is_enabled() {
364 return Ok(());
365 }
366
367 let Some(path) = self.cache_path(key) else {
368 return Ok(());
369 };
370
371 if let Some(parent) = path.parent() {
373 tokio::fs::create_dir_all(parent).await.with_context(|| {
374 format!("Failed to create cache directory: {}", parent.display())
375 })?;
376 }
377
378 let entry = CacheEntry::new(value);
379 let contents =
380 serde_json::to_string_pretty(&entry).context("Failed to serialize cache entry")?;
381
382 let temp_path = path.with_extension("tmp");
384 tokio::fs::write(&temp_path, contents)
385 .await
386 .with_context(|| format!("Failed to write cache temp file: {}", temp_path.display()))?;
387
388 tokio::fs::rename(&temp_path, &path)
389 .await
390 .with_context(|| format!("Failed to rename cache file: {}", path.display()))?;
391
392 Ok(())
393 }
394
395 #[cfg(test)]
396 async fn remove(&self, key: &str) -> Result<()> {
397 if !self.is_enabled() {
398 return Ok(());
399 }
400
401 let Some(path) = self.cache_path(key) else {
402 return Ok(());
403 };
404
405 if tokio::fs::try_exists(&path)
406 .await
407 .with_context(|| format!("Failed to check cache file: {}", path.display()))?
408 {
409 tokio::fs::remove_file(&path)
410 .await
411 .with_context(|| format!("Failed to remove cache file: {}", path.display()))?;
412 }
413 Ok(())
414 }
415}
416
417#[allow(dead_code)]
424pub(crate) struct InMemoryCache<V> {
425 store: std::sync::Arc<tokio::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>>,
426 _phantom: std::marker::PhantomData<V>,
427}
428
429impl<V> InMemoryCache<V>
430where
431 V: Serialize + serde::de::DeserializeOwned + Send,
432{
433 #[must_use]
435 #[allow(dead_code)]
436 pub fn new() -> Self {
437 Self {
438 store: std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
439 _phantom: std::marker::PhantomData,
440 }
441 }
442}
443
444impl<V> Default for InMemoryCache<V>
445where
446 V: Serialize + serde::de::DeserializeOwned + Send,
447{
448 fn default() -> Self {
449 Self::new()
450 }
451}
452
453impl<V> FileCache<V> for InMemoryCache<V>
454where
455 V: Serialize + serde::de::DeserializeOwned + Send,
456{
457 async fn get(&self, key: &str) -> Result<Option<V>> {
458 let guard = self.store.lock().await;
459 match guard.get(key) {
460 Some(bytes) => {
461 let value: V = serde_json::from_slice(bytes)
462 .with_context(|| format!("Failed to deserialize cache entry for key: {key}"))?;
463 Ok(Some(value))
464 }
465 None => Ok(None),
466 }
467 }
468
469 async fn get_stale(&self, key: &str) -> Result<Option<V>> {
470 self.get(key).await
471 }
472
473 async fn set(&self, key: &str, value: &V) -> Result<()> {
474 let bytes = serde_json::to_vec(value)
475 .with_context(|| format!("Failed to serialize cache entry for key: {key}"))?;
476 self.store.lock().await.insert(key.to_string(), bytes);
477 Ok(())
478 }
479
480 #[cfg(test)]
481 async fn remove(&self, key: &str) -> Result<()> {
482 self.store.lock().await.remove(key);
483 Ok(())
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
492 struct TestData {
493 value: String,
494 count: u32,
495 }
496
497 #[test]
498 fn test_cache_entry_new() {
499 let data = TestData {
500 value: "test".to_string(),
501 count: 42,
502 };
503 let entry = CacheEntry::new(data.clone());
504
505 assert_eq!(entry.data, data);
506 assert!(entry.etag.is_none());
507 }
508
509 #[test]
510 fn test_cache_entry_with_etag() {
511 let data = TestData {
512 value: "test".to_string(),
513 count: 42,
514 };
515 let etag = "abc123".to_string();
516 let entry = CacheEntry::with_etag(data.clone(), etag.clone());
517
518 assert_eq!(entry.data, data);
519 assert_eq!(entry.etag, Some(etag));
520 }
521
522 #[test]
523 fn test_cache_entry_is_valid_within_ttl() {
524 let data = TestData {
525 value: "test".to_string(),
526 count: 42,
527 };
528 let entry = CacheEntry::new(data);
529 let ttl = Duration::hours(1);
530
531 assert!(entry.is_valid(ttl));
532 }
533
534 #[test]
535 fn test_cache_entry_is_valid_expired() {
536 let data = TestData {
537 value: "test".to_string(),
538 count: 42,
539 };
540 let mut entry = CacheEntry::new(data);
541 entry.cached_at = Utc::now() - Duration::hours(2);
543 let ttl = Duration::hours(1);
544
545 assert!(!entry.is_valid(ttl));
546 }
547
548 #[test]
549 fn test_cache_dir_path() {
550 let dir = cache_dir();
551 assert!(dir.is_some());
552 assert!(dir.unwrap().ends_with("aptu"));
553 }
554
555 #[test]
556 fn test_cache_serialization_with_etag() {
557 let data = TestData {
558 value: "test".to_string(),
559 count: 42,
560 };
561 let etag = "xyz789".to_string();
562 let entry = CacheEntry::with_etag(data.clone(), etag.clone());
563
564 let json = serde_json::to_string(&entry).expect("serialize");
565 let parsed: CacheEntry<TestData> = serde_json::from_str(&json).expect("deserialize");
566
567 assert_eq!(parsed.data, data);
568 assert_eq!(parsed.etag, Some(etag));
569 }
570
571 #[tokio::test]
572 async fn test_file_cache_get_set() {
573 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
574 let data = TestData {
575 value: "test".to_string(),
576 count: 42,
577 };
578
579 cache.set("test_key", &data).await.expect("set cache");
581
582 let result = cache.get("test_key").await.expect("get cache");
584 assert!(result.is_some());
585 assert_eq!(result.unwrap(), data);
586
587 cache.remove("test_key").await.ok();
589 }
590
591 #[tokio::test]
592 async fn test_file_cache_get_miss() {
593 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
594
595 let result = cache.get("nonexistent").await.expect("get cache");
596 assert!(result.is_none());
597 }
598
599 #[tokio::test]
600 async fn test_file_cache_get_stale() {
601 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::seconds(0));
602 let data = TestData {
603 value: "stale".to_string(),
604 count: 99,
605 };
606
607 cache.set("stale_key", &data).await.expect("set cache");
609
610 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
612
613 let result = cache.get("stale_key").await.expect("get cache");
615 assert!(result.is_none());
616
617 let stale_result = cache.get_stale("stale_key").await.expect("get stale cache");
619 assert!(stale_result.is_some());
620 assert_eq!(stale_result.unwrap(), data);
621
622 cache.remove("stale_key").await.ok();
624 }
625
626 #[tokio::test]
627 async fn test_file_cache_remove() {
628 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
629 let data = TestData {
630 value: "remove_me".to_string(),
631 count: 1,
632 };
633
634 cache.set("remove_key", &data).await.expect("set cache");
636
637 assert!(cache.get("remove_key").await.expect("get cache").is_some());
639
640 cache.remove("remove_key").await.expect("remove cache");
642
643 assert!(cache.get("remove_key").await.expect("get cache").is_none());
645 }
646
647 #[tokio::test]
648 async fn test_cache_key_rejects_forward_slash() {
649 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
650 let result = cache
651 .get("../etc/passwd")
652 .await
653 .expect("get should succeed");
654 assert!(result.is_none());
655 }
656
657 #[tokio::test]
658 async fn test_cache_key_rejects_backslash() {
659 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
660 let data = TestData {
661 value: "x".to_string(),
662 count: 0,
663 };
664 let result = cache
665 .set("..\\windows\\system32", &data)
666 .await
667 .expect("set should succeed silently");
668 assert_eq!(result, ());
669 }
670
671 #[tokio::test]
672 async fn test_cache_key_rejects_parent_dir() {
673 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
674 let result = cache.get("foo..bar").await.expect("get should succeed");
675 assert!(result.is_none());
676 }
677
678 #[tokio::test]
679 async fn test_disabled_cache_get_returns_none() {
680 let cache: FileCacheImpl<TestData> =
681 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
682 let result = cache.get("any_key").await.expect("get should succeed");
683 assert!(result.is_none());
684 }
685
686 #[tokio::test]
687 async fn test_disabled_cache_set_succeeds_silently() {
688 let cache: FileCacheImpl<TestData> =
689 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
690 let data = TestData {
691 value: "test".to_string(),
692 count: 42,
693 };
694 cache
695 .set("any_key", &data)
696 .await
697 .expect("set should succeed");
698 }
699
700 #[tokio::test]
701 async fn test_disabled_cache_remove_succeeds_silently() {
702 let cache: FileCacheImpl<TestData> =
703 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
704 cache
705 .remove("any_key")
706 .await
707 .expect("remove should succeed");
708 }
709
710 #[tokio::test]
711 async fn test_disabled_cache_get_stale_returns_none() {
712 let cache: FileCacheImpl<TestData> =
713 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
714 let result = cache
715 .get_stale("any_key")
716 .await
717 .expect("get_stale should succeed");
718 assert!(result.is_none());
719 }
720
721 #[tokio::test]
722 async fn test_evict_stale_removes_old_files() {
723 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_evict", Duration::hours(1));
724 let data = TestData {
725 value: "old".to_string(),
726 count: 1,
727 };
728
729 cache.set("old_key", &data).await.expect("set cache");
731
732 if let Some(path) = cache.cache_path("old_key") {
734 let contents = tokio::fs::read_to_string(&path)
735 .await
736 .expect("read cache file");
737 let mut entry: CacheEntry<TestData> =
738 serde_json::from_str(&contents).expect("parse cache entry");
739 entry.cached_at = Utc::now() - Duration::days(10);
740 let new_contents = serde_json::to_string_pretty(&entry).expect("serialize cache entry");
741 tokio::fs::write(&path, new_contents)
742 .await
743 .expect("write cache file");
744 }
745
746 let evicted = cache.evict_stale(7).await;
748 assert_eq!(evicted, 1);
749
750 let result = cache.get("old_key").await.expect("get cache");
752 assert!(result.is_none());
753 }
754
755 #[tokio::test]
756 async fn test_evict_stale_preserves_fresh_files() {
757 let cache: FileCacheImpl<TestData> =
758 FileCacheImpl::new("test_evict_fresh", Duration::hours(1));
759 let data = TestData {
760 value: "fresh".to_string(),
761 count: 2,
762 };
763
764 cache.set("fresh_key", &data).await.expect("set cache");
766
767 let evicted = cache.evict_stale(7).await;
769 assert_eq!(evicted, 0);
770
771 let result = cache.get("fresh_key").await.expect("get cache");
773 assert!(result.is_some());
774 assert_eq!(result.unwrap(), data);
775
776 cache.remove("fresh_key").await.ok();
778 }
779
780 #[tokio::test]
781 async fn test_in_memory_cache_get_set() {
782 let cache = InMemoryCache::<TestData>::new();
783 let data = TestData {
784 value: "hello".to_string(),
785 count: 42,
786 };
787 cache
788 .set("my_key", &data)
789 .await
790 .expect("set should succeed");
791 let result = cache.get("my_key").await.expect("get should succeed");
792 assert_eq!(result, Some(data));
793 }
794
795 #[tokio::test]
796 async fn test_in_memory_cache_get_miss() {
797 let cache = InMemoryCache::<TestData>::new();
798 let result = cache.get("no_such_key").await.expect("get should succeed");
799 assert!(result.is_none());
800 }
801
802 #[tokio::test]
803 async fn test_in_memory_cache_overwrite() {
804 let cache = InMemoryCache::<TestData>::new();
805 let data1 = TestData {
806 value: "first".to_string(),
807 count: 1,
808 };
809 let data2 = TestData {
810 value: "second".to_string(),
811 count: 2,
812 };
813 cache
814 .set("overwrite_key", &data1)
815 .await
816 .expect("first set should succeed");
817 cache
818 .set("overwrite_key", &data2)
819 .await
820 .expect("second set should succeed");
821 let result = cache
822 .get("overwrite_key")
823 .await
824 .expect("get should succeed");
825 assert_eq!(result, Some(data2));
826 }
827}