1use serde::{Deserialize, Serialize};
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::errors::UpdateKitError;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CacheEntry {
10 pub latest_version: String,
11 pub current_version_at_check: String,
12 pub last_checked_at: String,
13 pub source: String,
14 pub etag: Option<String>,
15 pub release_url: Option<String>,
16 pub release_notes: Option<String>,
17}
18
19fn cache_file_path(cache_dir: &Path, app_name: &str) -> std::path::PathBuf {
21 cache_dir.join(app_name).join("update-check.json")
22}
23
24fn now_ms() -> String {
26 SystemTime::now()
27 .duration_since(UNIX_EPOCH)
28 .unwrap_or_default()
29 .as_millis()
30 .to_string()
31}
32
33pub fn read_cache(cache_dir: &Path, app_name: &str) -> Option<CacheEntry> {
36 let path = cache_file_path(cache_dir, app_name);
37 let data = std::fs::read_to_string(&path).ok()?;
38 let entry: CacheEntry = serde_json::from_str(&data).ok()?;
39
40 if entry.latest_version.is_empty()
42 || entry.current_version_at_check.is_empty()
43 || entry.last_checked_at.is_empty()
44 || entry.source.is_empty()
45 {
46 return None;
47 }
48
49 Some(entry)
50}
51
52pub fn write_cache(
55 cache_dir: &Path,
56 app_name: &str,
57 entry: &CacheEntry,
58) -> Result<(), UpdateKitError> {
59 let dir = cache_dir.join(app_name);
60 std::fs::create_dir_all(&dir).map_err(|e| {
61 UpdateKitError::CacheError(format!("Failed to create cache directory: {}", e))
62 })?;
63
64 let path = cache_file_path(cache_dir, app_name);
65 let json = serde_json::to_string_pretty(entry)
66 .map_err(|e| UpdateKitError::CacheError(format!("Failed to serialize cache: {}", e)))?;
67
68 let temp_path = dir.join("update-check.json.tmp");
70 std::fs::write(&temp_path, json.as_bytes()).map_err(|e| {
71 UpdateKitError::CacheError(format!("Failed to write temp cache file: {}", e))
72 })?;
73
74 std::fs::rename(&temp_path, &path).map_err(|e| {
75 UpdateKitError::CacheError(format!("Failed to rename cache file: {}", e))
76 })?;
77
78 Ok(())
79}
80
81pub fn is_cache_stale(entry: &CacheEntry, interval_ms: u64) -> bool {
83 let checked_at: u128 = match entry.last_checked_at.parse() {
84 Ok(v) => v,
85 Err(_) => return true, };
87
88 let now: u128 = SystemTime::now()
89 .duration_since(UNIX_EPOCH)
90 .unwrap_or_default()
91 .as_millis();
92
93 now.saturating_sub(checked_at) >= interval_ms as u128
94}
95
96pub fn clear_cache(cache_dir: &Path, app_name: &str) -> Result<(), UpdateKitError> {
98 let path = cache_file_path(cache_dir, app_name);
99 match std::fs::remove_file(&path) {
100 Ok(()) => Ok(()),
101 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
102 Err(e) => Err(UpdateKitError::CacheError(format!(
103 "Failed to clear cache: {}",
104 e
105 ))),
106 }
107}
108
109pub fn create_cache_entry(
111 version: &str,
112 current_version: &str,
113 source_name: &str,
114 etag: Option<String>,
115 release_url: Option<String>,
116 release_notes: Option<String>,
117) -> CacheEntry {
118 CacheEntry {
119 latest_version: version.to_string(),
120 current_version_at_check: current_version.to_string(),
121 last_checked_at: now_ms(),
122 source: source_name.to_string(),
123 etag,
124 release_url,
125 release_notes,
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use tempfile::TempDir;
133
134 fn sample_entry() -> CacheEntry {
135 CacheEntry {
136 latest_version: "2.0.0".into(),
137 current_version_at_check: "1.0.0".into(),
138 last_checked_at: now_ms(),
139 source: "github".into(),
140 etag: Some("abc123".into()),
141 release_url: Some("https://github.com/test/test/releases/tag/v2.0.0".into()),
142 release_notes: Some("Bug fixes".into()),
143 }
144 }
145
146 #[test]
147 fn test_write_and_read_roundtrip() {
148 let tmp = TempDir::new().unwrap();
149 let entry = sample_entry();
150
151 write_cache(tmp.path(), "my-app", &entry).unwrap();
152 let read_back = read_cache(tmp.path(), "my-app").unwrap();
153
154 assert_eq!(read_back.latest_version, entry.latest_version);
155 assert_eq!(
156 read_back.current_version_at_check,
157 entry.current_version_at_check
158 );
159 assert_eq!(read_back.source, entry.source);
160 assert_eq!(read_back.etag, entry.etag);
161 assert_eq!(read_back.release_url, entry.release_url);
162 assert_eq!(read_back.release_notes, entry.release_notes);
163 }
164
165 #[test]
166 fn test_missing_cache_returns_none() {
167 let tmp = TempDir::new().unwrap();
168 assert!(read_cache(tmp.path(), "nonexistent-app").is_none());
169 }
170
171 #[test]
172 fn test_stale_detection() {
173 let mut entry = sample_entry();
174 let ten_seconds_ago = SystemTime::now()
176 .duration_since(UNIX_EPOCH)
177 .unwrap()
178 .as_millis()
179 - 10_000;
180 entry.last_checked_at = ten_seconds_ago.to_string();
181
182 assert!(is_cache_stale(&entry, 5_000));
184 }
185
186 #[test]
187 fn test_fresh_cache_not_stale() {
188 let entry = sample_entry(); assert!(!is_cache_stale(&entry, 3_600_000));
192 }
193
194 #[test]
195 fn test_clear_removes_file() {
196 let tmp = TempDir::new().unwrap();
197 let entry = sample_entry();
198
199 write_cache(tmp.path(), "my-app", &entry).unwrap();
200 assert!(read_cache(tmp.path(), "my-app").is_some());
201
202 clear_cache(tmp.path(), "my-app").unwrap();
203 assert!(read_cache(tmp.path(), "my-app").is_none());
204 }
205
206 #[test]
207 fn test_clear_nonexistent_is_ok() {
208 let tmp = TempDir::new().unwrap();
209 assert!(clear_cache(tmp.path(), "no-such-app").is_ok());
210 }
211
212 #[test]
213 fn test_create_cache_entry() {
214 let entry = create_cache_entry(
215 "3.0.0",
216 "2.0.0",
217 "npm",
218 Some("etag123".into()),
219 None,
220 None,
221 );
222 assert_eq!(entry.latest_version, "3.0.0");
223 assert_eq!(entry.current_version_at_check, "2.0.0");
224 assert_eq!(entry.source, "npm");
225 assert_eq!(entry.etag, Some("etag123".into()));
226 assert!(!entry.last_checked_at.is_empty());
227 }
228
229 #[test]
230 fn test_invalid_json_returns_none() {
231 let tmp = TempDir::new().unwrap();
232 let dir = tmp.path().join("bad-app");
233 std::fs::create_dir_all(&dir).unwrap();
234 std::fs::write(dir.join("update-check.json"), "not valid json").unwrap();
235 assert!(read_cache(tmp.path(), "bad-app").is_none());
236 }
237
238 #[test]
239 fn test_empty_fields_returns_none() {
240 let tmp = TempDir::new().unwrap();
241 let entry = CacheEntry {
242 latest_version: "".into(),
243 current_version_at_check: "1.0.0".into(),
244 last_checked_at: now_ms(),
245 source: "github".into(),
246 etag: None,
247 release_url: None,
248 release_notes: None,
249 };
250 let dir = tmp.path().join("empty-app");
251 std::fs::create_dir_all(&dir).unwrap();
252 let json = serde_json::to_string(&entry).unwrap();
253 std::fs::write(dir.join("update-check.json"), json).unwrap();
254 assert!(read_cache(tmp.path(), "empty-app").is_none());
255 }
256}