Skip to main content

update_kit/checker/
cache.rs

1use serde::{Deserialize, Serialize};
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::errors::UpdateKitError;
6
7/// A cached update check result.
8#[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
19/// Returns the path to the cache file for the given app.
20fn cache_file_path(cache_dir: &Path, app_name: &str) -> std::path::PathBuf {
21    cache_dir.join(app_name).join("update-check.json")
22}
23
24/// Returns the current time as milliseconds since the Unix epoch, as a string.
25fn now_ms() -> String {
26    SystemTime::now()
27        .duration_since(UNIX_EPOCH)
28        .unwrap_or_default()
29        .as_millis()
30        .to_string()
31}
32
33/// Read the cached update check for the given app.
34/// Returns `None` on any error (missing file, invalid JSON, etc.).
35pub 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    // Validate required fields are non-empty
41    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
52/// Write a cache entry atomically (temp file + rename).
53/// Creates directories if needed.
54pub 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    // Write to a temp file in the same directory, then rename for atomicity.
69    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
81/// Check if a cache entry is stale based on the given interval (in milliseconds).
82pub 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, // Unparseable timestamp is considered stale
86    };
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
96/// Delete the cache file for the given app. Ignores "not found" errors.
97pub 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
109/// Create a new `CacheEntry` with the current timestamp.
110pub 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        // Set last_checked_at to 10 seconds ago
175        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        // With a 5-second interval, it should be stale
183        assert!(is_cache_stale(&entry, 5_000));
184    }
185
186    #[test]
187    fn test_fresh_cache_not_stale() {
188        let entry = sample_entry(); // last_checked_at = now
189
190        // With a 1-hour interval, it should be fresh
191        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}