aptu_core/
cache.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! TTL-based file caching for GitHub API responses.
4//!
5//! Stores issue and repository data as JSON files with embedded metadata
6//! (timestamp, optional etag). Cache entries are validated against TTL settings
7//! from configuration.
8
9use std::fs;
10use std::path::PathBuf;
11
12use anyhow::{Context, Result};
13use chrono::{DateTime, Duration, Utc};
14use serde::{Deserialize, Serialize};
15
16use crate::config::data_dir;
17
18/// A cached entry with metadata.
19///
20/// Wraps cached data with timestamp and optional etag for validation.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CacheEntry<T> {
23    /// The cached data.
24    pub data: T,
25    /// When the entry was cached.
26    pub cached_at: DateTime<Utc>,
27    /// Optional `ETag` for future conditional requests.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub etag: Option<String>,
30}
31
32impl<T> CacheEntry<T> {
33    /// Create a new cache entry.
34    pub fn new(data: T) -> Self {
35        Self {
36            data,
37            cached_at: Utc::now(),
38            etag: None,
39        }
40    }
41
42    /// Create a new cache entry with an etag.
43    pub fn with_etag(data: T, etag: String) -> Self {
44        Self {
45            data,
46            cached_at: Utc::now(),
47            etag: Some(etag),
48        }
49    }
50
51    /// Check if this entry is still valid based on TTL.
52    ///
53    /// # Arguments
54    ///
55    /// * `ttl` - Time-to-live duration
56    ///
57    /// # Returns
58    ///
59    /// `true` if the entry is within its TTL, `false` if expired.
60    pub fn is_valid(&self, ttl: Duration) -> bool {
61        let now = Utc::now();
62        now.signed_duration_since(self.cached_at) < ttl
63    }
64}
65
66/// Returns the cache directory.
67///
68/// - Linux: `~/.local/share/aptu/cache`
69/// - macOS: `~/Library/Application Support/aptu/cache`
70/// - Windows: `C:\Users\<User>\AppData\Local\aptu\cache`
71#[must_use]
72pub fn cache_dir() -> PathBuf {
73    data_dir().join("cache")
74}
75
76/// Generate a cache key for an issue list.
77///
78/// # Arguments
79///
80/// * `owner` - Repository owner
81/// * `repo` - Repository name
82///
83/// # Returns
84///
85/// Cache key in format: `issues/{owner}_{repo}.json`
86#[must_use]
87pub fn cache_key_issues(owner: &str, repo: &str) -> String {
88    format!("issues/{owner}_{repo}.json")
89}
90
91/// Read a cache entry from disk.
92///
93/// # Arguments
94///
95/// * `key` - Cache key (relative path within cache directory)
96///
97/// # Returns
98///
99/// The deserialized cache entry, or `None` if the file doesn't exist.
100///
101/// # Errors
102///
103/// Returns an error if the file exists but cannot be read or parsed.
104pub fn read_cache<T: for<'de> Deserialize<'de>>(key: &str) -> Result<Option<CacheEntry<T>>> {
105    let path = cache_dir().join(key);
106
107    if !path.exists() {
108        return Ok(None);
109    }
110
111    let contents = fs::read_to_string(&path)
112        .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
113
114    let entry: CacheEntry<T> = serde_json::from_str(&contents)
115        .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
116
117    Ok(Some(entry))
118}
119
120/// Write a cache entry to disk.
121///
122/// Creates parent directories if they don't exist.
123/// Uses atomic write pattern (write to temp, rename) to prevent corruption.
124///
125/// # Arguments
126///
127/// * `key` - Cache key (relative path within cache directory)
128/// * `entry` - Cache entry to write
129///
130/// # Errors
131///
132/// Returns an error if the file cannot be written.
133pub fn write_cache<T: Serialize>(key: &str, entry: &CacheEntry<T>) -> Result<()> {
134    let path = cache_dir().join(key);
135
136    // Create parent directories if needed
137    if let Some(parent) = path.parent() {
138        fs::create_dir_all(parent)
139            .with_context(|| format!("Failed to create cache directory: {}", parent.display()))?;
140    }
141
142    let contents =
143        serde_json::to_string_pretty(entry).context("Failed to serialize cache entry")?;
144
145    // Atomic write: write to temp file, then rename
146    let temp_path = path.with_extension("tmp");
147    fs::write(&temp_path, contents)
148        .with_context(|| format!("Failed to write cache temp file: {}", temp_path.display()))?;
149
150    fs::rename(&temp_path, &path)
151        .with_context(|| format!("Failed to rename cache file: {}", path.display()))?;
152
153    Ok(())
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161    struct TestData {
162        value: String,
163        count: u32,
164    }
165
166    #[test]
167    fn test_cache_entry_new() {
168        let data = TestData {
169            value: "test".to_string(),
170            count: 42,
171        };
172        let entry = CacheEntry::new(data.clone());
173
174        assert_eq!(entry.data, data);
175        assert!(entry.etag.is_none());
176    }
177
178    #[test]
179    fn test_cache_entry_with_etag() {
180        let data = TestData {
181            value: "test".to_string(),
182            count: 42,
183        };
184        let etag = "abc123".to_string();
185        let entry = CacheEntry::with_etag(data.clone(), etag.clone());
186
187        assert_eq!(entry.data, data);
188        assert_eq!(entry.etag, Some(etag));
189    }
190
191    #[test]
192    fn test_cache_entry_is_valid_within_ttl() {
193        let data = TestData {
194            value: "test".to_string(),
195            count: 42,
196        };
197        let entry = CacheEntry::new(data);
198        let ttl = Duration::hours(1);
199
200        assert!(entry.is_valid(ttl));
201    }
202
203    #[test]
204    fn test_cache_entry_is_valid_expired() {
205        let data = TestData {
206            value: "test".to_string(),
207            count: 42,
208        };
209        let mut entry = CacheEntry::new(data);
210        // Manually set cached_at to 2 hours ago
211        entry.cached_at = Utc::now() - Duration::hours(2);
212        let ttl = Duration::hours(1);
213
214        assert!(!entry.is_valid(ttl));
215    }
216
217    #[test]
218    fn test_cache_key_issues() {
219        let key = cache_key_issues("owner", "repo");
220        assert_eq!(key, "issues/owner_repo.json");
221    }
222
223    #[test]
224    fn test_cache_dir_path() {
225        let dir = cache_dir();
226        assert!(dir.ends_with("cache"));
227    }
228
229    #[test]
230    fn test_cache_serialization_with_etag() {
231        let data = TestData {
232            value: "test".to_string(),
233            count: 42,
234        };
235        let etag = "xyz789".to_string();
236        let entry = CacheEntry::with_etag(data.clone(), etag.clone());
237
238        let json = serde_json::to_string(&entry).expect("serialize");
239        let parsed: CacheEntry<TestData> = serde_json::from_str(&json).expect("deserialize");
240
241        assert_eq!(parsed.data, data);
242        assert_eq!(parsed.etag, Some(etag));
243    }
244
245    #[test]
246    fn test_read_cache_nonexistent() {
247        let result: Result<Option<CacheEntry<TestData>>> = read_cache("nonexistent/file.json");
248        assert!(result.is_ok());
249        assert!(result.unwrap().is_none());
250    }
251
252    #[test]
253    fn test_write_and_read_cache() {
254        let data = TestData {
255            value: "cached".to_string(),
256            count: 99,
257        };
258        let entry = CacheEntry::new(data.clone());
259        let key = "test/data.json";
260
261        // Write cache
262        write_cache(key, &entry).expect("write cache");
263
264        // Read cache
265        let read_entry: CacheEntry<TestData> =
266            read_cache(key).expect("read cache").expect("cache exists");
267
268        assert_eq!(read_entry.data, data);
269        assert_eq!(read_entry.etag, entry.etag);
270
271        // Cleanup
272        let path = cache_dir().join(key);
273        if path.exists() {
274            fs::remove_file(path).ok();
275        }
276    }
277}