1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CacheEntry<T> {
23 pub data: T,
25 pub cached_at: DateTime<Utc>,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub etag: Option<String>,
30}
31
32impl<T> CacheEntry<T> {
33 pub fn new(data: T) -> Self {
35 Self {
36 data,
37 cached_at: Utc::now(),
38 etag: None,
39 }
40 }
41
42 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 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#[must_use]
72pub fn cache_dir() -> PathBuf {
73 data_dir().join("cache")
74}
75
76#[must_use]
87pub fn cache_key_issues(owner: &str, repo: &str) -> String {
88 format!("issues/{owner}_{repo}.json")
89}
90
91pub 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
120pub fn write_cache<T: Serialize>(key: &str, entry: &CacheEntry<T>) -> Result<()> {
134 let path = cache_dir().join(key);
135
136 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 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 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(key, &entry).expect("write cache");
263
264 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 let path = cache_dir().join(key);
273 if path.exists() {
274 fs::remove_file(path).ok();
275 }
276 }
277}