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/// Generates a cache key for repository metadata (labels and milestones).
87///
88/// # Arguments
89///
90/// * `owner` - Repository owner
91/// * `repo` - Repository name
92///
93/// # Returns
94///
95/// A cache key string in the format `repo_metadata/{owner}_{repo}.json`
96#[must_use]
97pub fn cache_key_repo_metadata(owner: &str, repo: &str) -> String {
98    format!("repo_metadata/{owner}_{repo}.json")
99}
100
101#[must_use]
102pub fn cache_key_issues(owner: &str, repo: &str) -> String {
103    format!("issues/{owner}_{repo}.json")
104}
105
106/// Read a cache entry from disk.
107///
108/// # Arguments
109///
110/// * `key` - Cache key (relative path within cache directory)
111///
112/// # Returns
113///
114/// The deserialized cache entry, or `None` if the file doesn't exist.
115///
116/// # Errors
117///
118/// Returns an error if the file exists but cannot be read or parsed.
119pub fn read_cache<T: for<'de> Deserialize<'de>>(key: &str) -> Result<Option<CacheEntry<T>>> {
120    let path = cache_dir().join(key);
121
122    if !path.exists() {
123        return Ok(None);
124    }
125
126    let contents = fs::read_to_string(&path)
127        .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
128
129    let entry: CacheEntry<T> = serde_json::from_str(&contents)
130        .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
131
132    Ok(Some(entry))
133}
134
135/// Write a cache entry to disk.
136///
137/// Creates parent directories if they don't exist.
138/// Uses atomic write pattern (write to temp, rename) to prevent corruption.
139///
140/// # Arguments
141///
142/// * `key` - Cache key (relative path within cache directory)
143/// * `entry` - Cache entry to write
144///
145/// # Errors
146///
147/// Returns an error if the file cannot be written.
148pub fn write_cache<T: Serialize>(key: &str, entry: &CacheEntry<T>) -> Result<()> {
149    let path = cache_dir().join(key);
150
151    // Create parent directories if needed
152    if let Some(parent) = path.parent() {
153        fs::create_dir_all(parent)
154            .with_context(|| format!("Failed to create cache directory: {}", parent.display()))?;
155    }
156
157    let contents =
158        serde_json::to_string_pretty(entry).context("Failed to serialize cache entry")?;
159
160    // Atomic write: write to temp file, then rename
161    let temp_path = path.with_extension("tmp");
162    fs::write(&temp_path, contents)
163        .with_context(|| format!("Failed to write cache temp file: {}", temp_path.display()))?;
164
165    fs::rename(&temp_path, &path)
166        .with_context(|| format!("Failed to rename cache file: {}", path.display()))?;
167
168    Ok(())
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176    struct TestData {
177        value: String,
178        count: u32,
179    }
180
181    #[test]
182    fn test_cache_entry_new() {
183        let data = TestData {
184            value: "test".to_string(),
185            count: 42,
186        };
187        let entry = CacheEntry::new(data.clone());
188
189        assert_eq!(entry.data, data);
190        assert!(entry.etag.is_none());
191    }
192
193    #[test]
194    fn test_cache_entry_with_etag() {
195        let data = TestData {
196            value: "test".to_string(),
197            count: 42,
198        };
199        let etag = "abc123".to_string();
200        let entry = CacheEntry::with_etag(data.clone(), etag.clone());
201
202        assert_eq!(entry.data, data);
203        assert_eq!(entry.etag, Some(etag));
204    }
205
206    #[test]
207    fn test_cache_entry_is_valid_within_ttl() {
208        let data = TestData {
209            value: "test".to_string(),
210            count: 42,
211        };
212        let entry = CacheEntry::new(data);
213        let ttl = Duration::hours(1);
214
215        assert!(entry.is_valid(ttl));
216    }
217
218    #[test]
219    fn test_cache_entry_is_valid_expired() {
220        let data = TestData {
221            value: "test".to_string(),
222            count: 42,
223        };
224        let mut entry = CacheEntry::new(data);
225        // Manually set cached_at to 2 hours ago
226        entry.cached_at = Utc::now() - Duration::hours(2);
227        let ttl = Duration::hours(1);
228
229        assert!(!entry.is_valid(ttl));
230    }
231
232    #[test]
233    fn test_cache_key_issues() {
234        let key = cache_key_issues("owner", "repo");
235        assert_eq!(key, "issues/owner_repo.json");
236    }
237
238    #[test]
239    fn test_cache_dir_path() {
240        let dir = cache_dir();
241        assert!(dir.ends_with("cache"));
242    }
243
244    #[test]
245    fn test_cache_serialization_with_etag() {
246        let data = TestData {
247            value: "test".to_string(),
248            count: 42,
249        };
250        let etag = "xyz789".to_string();
251        let entry = CacheEntry::with_etag(data.clone(), etag.clone());
252
253        let json = serde_json::to_string(&entry).expect("serialize");
254        let parsed: CacheEntry<TestData> = serde_json::from_str(&json).expect("deserialize");
255
256        assert_eq!(parsed.data, data);
257        assert_eq!(parsed.etag, Some(etag));
258    }
259
260    #[test]
261    fn test_read_cache_nonexistent() {
262        let result: Result<Option<CacheEntry<TestData>>> = read_cache("nonexistent/file.json");
263        assert!(result.is_ok());
264        assert!(result.unwrap().is_none());
265    }
266
267    #[test]
268    fn test_write_and_read_cache() {
269        let data = TestData {
270            value: "cached".to_string(),
271            count: 99,
272        };
273        let entry = CacheEntry::new(data.clone());
274        let key = "test/data.json";
275
276        // Write cache
277        write_cache(key, &entry).expect("write cache");
278
279        // Read cache
280        let read_entry: CacheEntry<TestData> =
281            read_cache(key).expect("read cache").expect("cache exists");
282
283        assert_eq!(read_entry.data, data);
284        assert_eq!(read_entry.etag, entry.etag);
285
286        // Cleanup
287        let path = cache_dir().join(key);
288        if path.exists() {
289            fs::remove_file(path).ok();
290        }
291    }
292}