1use std::fs;
10use std::path::PathBuf;
11use std::sync::Once;
12
13use anyhow::{Context, Result};
14use chrono::{DateTime, Duration, Utc};
15use serde::{Deserialize, Serialize};
16use tracing::warn;
17
18static CACHE_UNAVAILABLE_WARNING: Once = Once::new();
20
21pub const DEFAULT_ISSUE_TTL_MINS: i64 = 60;
23
24pub const DEFAULT_REPO_TTL_HOURS: i64 = 24;
26
27pub const DEFAULT_MODEL_TTL_SECS: u64 = 86400;
29
30pub const DEFAULT_SECURITY_TTL_DAYS: i64 = 7;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CacheEntry<T> {
38 pub data: T,
40 pub cached_at: DateTime<Utc>,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub etag: Option<String>,
45}
46
47impl<T> CacheEntry<T> {
48 pub fn new(data: T) -> Self {
50 Self {
51 data,
52 cached_at: Utc::now(),
53 etag: None,
54 }
55 }
56
57 pub fn with_etag(data: T, etag: String) -> Self {
59 Self {
60 data,
61 cached_at: Utc::now(),
62 etag: Some(etag),
63 }
64 }
65
66 pub fn is_valid(&self, ttl: Duration) -> bool {
76 let now = Utc::now();
77 now.signed_duration_since(self.cached_at) < ttl
78 }
79}
80
81#[must_use]
89pub fn cache_dir() -> Option<PathBuf> {
90 dirs::cache_dir().map(|dir| dir.join("aptu"))
91}
92
93pub trait FileCache<V> {
97 fn get(&self, key: &str) -> Result<Option<V>>;
107
108 fn get_stale(&self, key: &str) -> Result<Option<V>>;
118
119 fn set(&self, key: &str, value: &V) -> Result<()>;
126
127 fn remove(&self, key: &str) -> Result<()>;
133}
134
135pub struct FileCacheImpl<V> {
140 cache_dir: Option<PathBuf>,
141 ttl: Duration,
142 subdirectory: String,
143 _phantom: std::marker::PhantomData<V>,
144}
145
146impl<V> FileCacheImpl<V>
147where
148 V: Serialize + for<'de> Deserialize<'de>,
149{
150 #[must_use]
160 pub fn new(subdirectory: impl Into<String>, ttl: Duration) -> Self {
161 let cache_dir = cache_dir();
162 if cache_dir.is_none() {
163 CACHE_UNAVAILABLE_WARNING.call_once(|| {
164 warn!("Cache directory unavailable, caching disabled");
165 });
166 }
167 Self::with_dir(cache_dir, subdirectory, ttl)
168 }
169
170 #[must_use]
178 pub fn with_dir(
179 cache_dir: Option<PathBuf>,
180 subdirectory: impl Into<String>,
181 ttl: Duration,
182 ) -> Self {
183 Self {
184 cache_dir,
185 ttl,
186 subdirectory: subdirectory.into(),
187 _phantom: std::marker::PhantomData,
188 }
189 }
190
191 fn is_enabled(&self) -> bool {
193 self.cache_dir.is_some()
194 }
195
196 fn cache_path(&self, key: &str) -> Option<PathBuf> {
203 assert!(
205 !key.contains('/') && !key.contains('\\') && !key.contains(".."),
206 "cache key must not contain path separators or '..': {key}"
207 );
208
209 let filename = if std::path::Path::new(key)
210 .extension()
211 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
212 {
213 key.to_string()
214 } else {
215 format!("{key}.json")
216 };
217 self.cache_dir
218 .as_ref()
219 .map(|dir| dir.join(&self.subdirectory).join(filename))
220 }
221}
222
223impl<V> FileCache<V> for FileCacheImpl<V>
224where
225 V: Serialize + for<'de> Deserialize<'de>,
226{
227 fn get(&self, key: &str) -> Result<Option<V>> {
228 if !self.is_enabled() {
229 return Ok(None);
230 }
231
232 let Some(path) = self.cache_path(key) else {
233 return Ok(None);
234 };
235
236 if !path.exists() {
237 return Ok(None);
238 }
239
240 let contents = fs::read_to_string(&path)
241 .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
242
243 let entry: CacheEntry<V> = serde_json::from_str(&contents)
244 .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
245
246 if entry.is_valid(self.ttl) {
247 Ok(Some(entry.data))
248 } else {
249 Ok(None)
250 }
251 }
252
253 fn get_stale(&self, key: &str) -> Result<Option<V>> {
254 if !self.is_enabled() {
255 return Ok(None);
256 }
257
258 let Some(path) = self.cache_path(key) else {
259 return Ok(None);
260 };
261
262 if !path.exists() {
263 return Ok(None);
264 }
265
266 let contents = fs::read_to_string(&path)
267 .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
268
269 let entry: CacheEntry<V> = serde_json::from_str(&contents)
270 .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
271
272 Ok(Some(entry.data))
273 }
274
275 fn set(&self, key: &str, value: &V) -> Result<()> {
276 if !self.is_enabled() {
277 return Ok(());
278 }
279
280 let Some(path) = self.cache_path(key) else {
281 return Ok(());
282 };
283
284 if let Some(parent) = path.parent() {
286 fs::create_dir_all(parent).with_context(|| {
287 format!("Failed to create cache directory: {}", parent.display())
288 })?;
289 }
290
291 let entry = CacheEntry::new(value);
292 let contents =
293 serde_json::to_string_pretty(&entry).context("Failed to serialize cache entry")?;
294
295 let temp_path = path.with_extension("tmp");
297 fs::write(&temp_path, contents)
298 .with_context(|| format!("Failed to write cache temp file: {}", temp_path.display()))?;
299
300 fs::rename(&temp_path, &path)
301 .with_context(|| format!("Failed to rename cache file: {}", path.display()))?;
302
303 Ok(())
304 }
305
306 fn remove(&self, key: &str) -> Result<()> {
307 if !self.is_enabled() {
308 return Ok(());
309 }
310
311 let Some(path) = self.cache_path(key) else {
312 return Ok(());
313 };
314
315 if path.exists() {
316 fs::remove_file(&path)
317 .with_context(|| format!("Failed to remove cache file: {}", path.display()))?;
318 }
319 Ok(())
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
328 struct TestData {
329 value: String,
330 count: u32,
331 }
332
333 #[test]
334 fn test_cache_entry_new() {
335 let data = TestData {
336 value: "test".to_string(),
337 count: 42,
338 };
339 let entry = CacheEntry::new(data.clone());
340
341 assert_eq!(entry.data, data);
342 assert!(entry.etag.is_none());
343 }
344
345 #[test]
346 fn test_cache_entry_with_etag() {
347 let data = TestData {
348 value: "test".to_string(),
349 count: 42,
350 };
351 let etag = "abc123".to_string();
352 let entry = CacheEntry::with_etag(data.clone(), etag.clone());
353
354 assert_eq!(entry.data, data);
355 assert_eq!(entry.etag, Some(etag));
356 }
357
358 #[test]
359 fn test_cache_entry_is_valid_within_ttl() {
360 let data = TestData {
361 value: "test".to_string(),
362 count: 42,
363 };
364 let entry = CacheEntry::new(data);
365 let ttl = Duration::hours(1);
366
367 assert!(entry.is_valid(ttl));
368 }
369
370 #[test]
371 fn test_cache_entry_is_valid_expired() {
372 let data = TestData {
373 value: "test".to_string(),
374 count: 42,
375 };
376 let mut entry = CacheEntry::new(data);
377 entry.cached_at = Utc::now() - Duration::hours(2);
379 let ttl = Duration::hours(1);
380
381 assert!(!entry.is_valid(ttl));
382 }
383
384 #[test]
385 fn test_cache_dir_path() {
386 let dir = cache_dir();
387 assert!(dir.is_some());
388 assert!(dir.unwrap().ends_with("aptu"));
389 }
390
391 #[test]
392 fn test_cache_serialization_with_etag() {
393 let data = TestData {
394 value: "test".to_string(),
395 count: 42,
396 };
397 let etag = "xyz789".to_string();
398 let entry = CacheEntry::with_etag(data.clone(), etag.clone());
399
400 let json = serde_json::to_string(&entry).expect("serialize");
401 let parsed: CacheEntry<TestData> = serde_json::from_str(&json).expect("deserialize");
402
403 assert_eq!(parsed.data, data);
404 assert_eq!(parsed.etag, Some(etag));
405 }
406
407 #[test]
408 fn test_file_cache_get_set() {
409 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
410 let data = TestData {
411 value: "test".to_string(),
412 count: 42,
413 };
414
415 cache.set("test_key", &data).expect("set cache");
417
418 let result = cache.get("test_key").expect("get cache");
420 assert!(result.is_some());
421 assert_eq!(result.unwrap(), data);
422
423 cache.remove("test_key").ok();
425 }
426
427 #[test]
428 fn test_file_cache_get_miss() {
429 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
430
431 let result = cache.get("nonexistent").expect("get cache");
432 assert!(result.is_none());
433 }
434
435 #[test]
436 fn test_file_cache_get_stale() {
437 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::seconds(0));
438 let data = TestData {
439 value: "stale".to_string(),
440 count: 99,
441 };
442
443 cache.set("stale_key", &data).expect("set cache");
445
446 std::thread::sleep(std::time::Duration::from_millis(10));
448
449 let result = cache.get("stale_key").expect("get cache");
451 assert!(result.is_none());
452
453 let stale_result = cache.get_stale("stale_key").expect("get stale cache");
455 assert!(stale_result.is_some());
456 assert_eq!(stale_result.unwrap(), data);
457
458 cache.remove("stale_key").ok();
460 }
461
462 #[test]
463 fn test_file_cache_remove() {
464 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
465 let data = TestData {
466 value: "remove_me".to_string(),
467 count: 1,
468 };
469
470 cache.set("remove_key", &data).expect("set cache");
472
473 assert!(cache.get("remove_key").expect("get cache").is_some());
475
476 cache.remove("remove_key").expect("remove cache");
478
479 assert!(cache.get("remove_key").expect("get cache").is_none());
481 }
482
483 #[test]
484 #[should_panic(expected = "cache key must not contain path separators")]
485 fn test_cache_key_rejects_forward_slash() {
486 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
487 let _ = cache.get("../etc/passwd");
488 }
489
490 #[test]
491 #[should_panic(expected = "cache key must not contain path separators")]
492 fn test_cache_key_rejects_backslash() {
493 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
494 let _ = cache.get("..\\windows\\system32");
495 }
496
497 #[test]
498 #[should_panic(expected = "cache key must not contain path separators")]
499 fn test_cache_key_rejects_parent_dir() {
500 let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
501 let _ = cache.get("foo..bar");
502 }
503
504 #[test]
505 fn test_disabled_cache_get_returns_none() {
506 let cache: FileCacheImpl<TestData> =
507 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
508 let result = cache.get("any_key").expect("get should succeed");
509 assert!(result.is_none());
510 }
511
512 #[test]
513 fn test_disabled_cache_set_succeeds_silently() {
514 let cache: FileCacheImpl<TestData> =
515 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
516 let data = TestData {
517 value: "test".to_string(),
518 count: 42,
519 };
520 cache.set("any_key", &data).expect("set should succeed");
521 }
522
523 #[test]
524 fn test_disabled_cache_remove_succeeds_silently() {
525 let cache: FileCacheImpl<TestData> =
526 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
527 cache.remove("any_key").expect("remove should succeed");
528 }
529
530 #[test]
531 fn test_disabled_cache_get_stale_returns_none() {
532 let cache: FileCacheImpl<TestData> =
533 FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
534 let result = cache
535 .get_stale("any_key")
536 .expect("get_stale should succeed");
537 assert!(result.is_none());
538 }
539}