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]
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
106pub 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
135pub fn write_cache<T: Serialize>(key: &str, entry: &CacheEntry<T>) -> Result<()> {
149 let path = cache_dir().join(key);
150
151 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 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 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(key, &entry).expect("write cache");
278
279 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 let path = cache_dir().join(key);
288 if path.exists() {
289 fs::remove_file(path).ok();
290 }
291 }
292}