1use std::fs;
10use std::path::PathBuf;
11
12use anyhow::{Context, Result};
13use chrono::{DateTime, Duration, Utc};
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct CacheEntry<T> {
21 pub data: T,
23 pub cached_at: DateTime<Utc>,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub etag: Option<String>,
28}
29
30impl<T> CacheEntry<T> {
31 pub fn new(data: T) -> Self {
33 Self {
34 data,
35 cached_at: Utc::now(),
36 etag: None,
37 }
38 }
39
40 pub fn with_etag(data: T, etag: String) -> Self {
42 Self {
43 data,
44 cached_at: Utc::now(),
45 etag: Some(etag),
46 }
47 }
48
49 pub fn is_valid(&self, ttl: Duration) -> bool {
59 let now = Utc::now();
60 now.signed_duration_since(self.cached_at) < ttl
61 }
62}
63
64#[must_use]
70pub fn cache_dir() -> PathBuf {
71 dirs::cache_dir()
72 .expect("Failed to determine cache directory")
73 .join("aptu")
74}
75
76#[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]
103pub fn cache_key_issues(owner: &str, repo: &str) -> String {
104 format!("issues/{owner}_{repo}.json")
105}
106
107pub 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
136pub fn write_cache<T: Serialize>(key: &str, entry: &CacheEntry<T>) -> Result<()> {
150 let path = cache_dir().join(key);
151
152 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 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 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("aptu"));
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(key, &entry).expect("write cache");
279
280 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 let path = cache_dir().join(key);
289 if path.exists() {
290 fs::remove_file(path).ok();
291 }
292 }
293}