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#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
422 struct TestData {
423 value: String,
424 count: u32,
425 }
426
427 #[test]
428 fn test_cache_entry_new() {
429 let data = TestData {
430 value: "test".to_string(),
431 count: 42,
432 };
433 let entry = CacheEntry::new(data.clone());
434
435 assert_eq!(entry.data, data);
436 assert!(entry.etag.is_none());
437 }
438
439 #[test]
440 fn test_cache_entry_with_etag() {
441 let data = TestData {
442 value: "test".to_string(),
443 count: 42,
444 };
445 let etag = "abc123".to_string();
446 let entry = CacheEntry::with_etag(data.clone(), etag.clone());
447
448 assert_eq!(entry.data, data);
449 assert_eq!(entry.etag, Some(etag));
450 }
451
452 #[test]
453 fn test_cache_entry_is_valid_within_ttl() {
454 let data = TestData {
455 value: "test".to_string(),
456 count: 42,
457 };
458 let entry = CacheEntry::new(data);
459 let ttl = Duration::hours(1);
460
461 assert!(entry.is_valid(ttl));
462 }
463
464 #[test]
465 fn test_cache_entry_is_valid_expired() {
466 let data = TestData {
467 value: "test".to_string(),
468 count: 42,
469 };
470 let mut entry = CacheEntry::new(data);
471 entry.cached_at = Utc::now() - Duration::hours(2);
473 let ttl = Duration::hours(1);
474
475 assert!(!entry.is_valid(ttl));
476 }
477
478 #[test]
479 fn test_cache_dir_path() {
480 let dir = cache_dir();
481 assert!(dir.is_some());
482 assert!(dir.unwrap().ends_with("aptu"));
483 }
484
485 #[test]
486 fn test_cache_serialization_with_etag() {
487 let data = TestData {
488 value: "test".to_string(),
489 count: 42,
490 };
491 let etag = "xyz789".to_string();
492 let entry = CacheEntry::with_etag(data.clone(), etag.clone());
493
494 let json = serde_json::to_string(&entry).expect("serialize");
495 let parsed: CacheEntry<TestData> = serde_json::from_str(&json).expect("deserialize");
496
497 assert_eq!(parsed.data, data);
498 assert_eq!(parsed.etag, Some(etag));
499 }
500
501 #[tokio::test]
502 async fn test_file_cache_get_set() {
503 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
504 let data = TestData {
505 value: "test".to_string(),
506 count: 42,
507 };
508
509 cache.set("test_key", &data).await.expect("set cache");
511
512 let result = cache.get("test_key").await.expect("get cache");
514 assert!(result.is_some());
515 assert_eq!(result.unwrap(), data);
516
517 cache.remove("test_key").await.ok();
519 }
520
521 #[tokio::test]
522 async fn test_file_cache_get_miss() {
523 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
524
525 let result = cache.get("nonexistent").await.expect("get cache");
526 assert!(result.is_none());
527 }
528
529 #[tokio::test]
530 async fn test_file_cache_get_stale() {
531 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::seconds(0));
532 let data = TestData {
533 value: "stale".to_string(),
534 count: 99,
535 };
536
537 cache.set("stale_key", &data).await.expect("set cache");
539
540 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
542
543 let result = cache.get("stale_key").await.expect("get cache");
545 assert!(result.is_none());
546
547 let stale_result = cache.get_stale("stale_key").await.expect("get stale cache");
549 assert!(stale_result.is_some());
550 assert_eq!(stale_result.unwrap(), data);
551
552 cache.remove("stale_key").await.ok();
554 }
555
556 #[tokio::test]
557 async fn test_file_cache_remove() {
558 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
559 let data = TestData {
560 value: "remove_me".to_string(),
561 count: 1,
562 };
563
564 cache.set("remove_key", &data).await.expect("set cache");
566
567 assert!(cache.get("remove_key").await.expect("get cache").is_some());
569
570 cache.remove("remove_key").await.expect("remove cache");
572
573 assert!(cache.get("remove_key").await.expect("get cache").is_none());
575 }
576
577 #[tokio::test]
578 async fn test_cache_key_rejects_forward_slash() {
579 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
580 let result = cache
581 .get("../etc/passwd")
582 .await
583 .expect("get should succeed");
584 assert!(result.is_none());
585 }
586
587 #[tokio::test]
588 async fn test_cache_key_rejects_backslash() {
589 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
590 let data = TestData {
591 value: "x".to_string(),
592 count: 0,
593 };
594 let result = cache
595 .set("..\\windows\\system32", &data)
596 .await
597 .expect("set should succeed silently");
598 assert_eq!(result, ());
599 }
600
601 #[tokio::test]
602 async fn test_cache_key_rejects_parent_dir() {
603 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
604 let result = cache.get("foo..bar").await.expect("get should succeed");
605 assert!(result.is_none());
606 }
607
608 #[tokio::test]
609 async fn test_disabled_cache_get_returns_none() {
610 let cache: FileCacheImpl<TestData> =
611 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
612 let result = cache.get("any_key").await.expect("get should succeed");
613 assert!(result.is_none());
614 }
615
616 #[tokio::test]
617 async fn test_disabled_cache_set_succeeds_silently() {
618 let cache: FileCacheImpl<TestData> =
619 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
620 let data = TestData {
621 value: "test".to_string(),
622 count: 42,
623 };
624 cache
625 .set("any_key", &data)
626 .await
627 .expect("set should succeed");
628 }
629
630 #[tokio::test]
631 async fn test_disabled_cache_remove_succeeds_silently() {
632 let cache: FileCacheImpl<TestData> =
633 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
634 cache
635 .remove("any_key")
636 .await
637 .expect("remove should succeed");
638 }
639
640 #[tokio::test]
641 async fn test_disabled_cache_get_stale_returns_none() {
642 let cache: FileCacheImpl<TestData> =
643 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
644 let result = cache
645 .get_stale("any_key")
646 .await
647 .expect("get_stale should succeed");
648 assert!(result.is_none());
649 }
650
651 #[tokio::test]
652 async fn test_evict_stale_removes_old_files() {
653 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_evict", Duration::hours(1));
654 let data = TestData {
655 value: "old".to_string(),
656 count: 1,
657 };
658
659 cache.set("old_key", &data).await.expect("set cache");
661
662 if let Some(path) = cache.cache_path("old_key") {
664 let contents = tokio::fs::read_to_string(&path)
665 .await
666 .expect("read cache file");
667 let mut entry: CacheEntry<TestData> =
668 serde_json::from_str(&contents).expect("parse cache entry");
669 entry.cached_at = Utc::now() - Duration::days(10);
670 let new_contents = serde_json::to_string_pretty(&entry).expect("serialize cache entry");
671 tokio::fs::write(&path, new_contents)
672 .await
673 .expect("write cache file");
674 }
675
676 let evicted = cache.evict_stale(7).await;
678 assert_eq!(evicted, 1);
679
680 let result = cache.get("old_key").await.expect("get cache");
682 assert!(result.is_none());
683 }
684
685 #[tokio::test]
686 async fn test_evict_stale_preserves_fresh_files() {
687 let cache: FileCacheImpl<TestData> =
688 FileCacheImpl::new("test_evict_fresh", Duration::hours(1));
689 let data = TestData {
690 value: "fresh".to_string(),
691 count: 2,
692 };
693
694 cache.set("fresh_key", &data).await.expect("set cache");
696
697 let evicted = cache.evict_stale(7).await;
699 assert_eq!(evicted, 0);
700
701 let result = cache.get("fresh_key").await.expect("get cache");
703 assert!(result.is_some());
704 assert_eq!(result.unwrap(), data);
705
706 cache.remove("fresh_key").await.ok();
708 }
709}