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