Skip to main content

seshat_cli/
version_cache.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5pub struct VersionCache {
6    pub latest_version: String,
7    pub checked_at: String,
8    #[serde(default)]
9    pub has_assets: Option<bool>,
10}
11
12impl VersionCache {
13    pub fn cache_dir() -> Option<PathBuf> {
14        Some(dirs::data_dir()?.join("seshat"))
15    }
16
17    pub fn cache_path() -> Option<PathBuf> {
18        Some(Self::cache_dir()?.join("version-check.json"))
19    }
20
21    pub fn read_from_path(path: &std::path::Path) -> Option<Self> {
22        let content = std::fs::read_to_string(path).ok()?;
23        if content.trim().is_empty() {
24            return None;
25        }
26        serde_json::from_str(&content).ok()
27    }
28
29    pub fn read() -> Option<Self> {
30        Self::read_from_path(&Self::cache_path()?)
31    }
32
33    pub fn write(&self) -> Result<(), std::io::Error> {
34        let path = Self::cache_path().ok_or_else(|| {
35            std::io::Error::new(
36                std::io::ErrorKind::NotFound,
37                "could not determine cache path",
38            )
39        })?;
40        self.write_to_path(&path)
41    }
42
43    pub fn write_to_path(&self, path: &std::path::Path) -> Result<(), std::io::Error> {
44        if let Some(parent) = path.parent() {
45            std::fs::create_dir_all(parent)?;
46        }
47        let json = serde_json::to_string_pretty(self)
48            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
49        std::fs::write(path, json)
50    }
51
52    pub fn is_fresh(&self) -> bool {
53        chrono::DateTime::parse_from_rfc3339(&self.checked_at)
54            .ok()
55            .map(|checked_time| {
56                let now = chrono::Utc::now();
57                let age = now.signed_duration_since(checked_time);
58                age.num_hours() < 24
59            })
60            .unwrap_or(false)
61    }
62
63    pub fn new(latest_version: String) -> Self {
64        Self {
65            latest_version,
66            checked_at: chrono::Utc::now().to_rfc3339(),
67            has_assets: None,
68        }
69    }
70
71    pub fn with_assets(latest_version: String, has_assets: bool) -> Self {
72        Self {
73            latest_version,
74            checked_at: chrono::Utc::now().to_rfc3339(),
75            has_assets: Some(has_assets),
76        }
77    }
78
79    pub fn expired_at(version: &str, hours_ago: i64) -> Self {
80        Self {
81            latest_version: version.to_owned(),
82            checked_at: (chrono::Utc::now() - chrono::Duration::hours(hours_ago)).to_rfc3339(),
83            has_assets: None,
84        }
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use std::io::Write;
92    use tempfile::NamedTempFile;
93
94    fn cache_json(version: &str, rfc3339: &str) -> String {
95        format!(
96            r#"{{"latest_version":"{}","checked_at":"{}"}}"#,
97            version, rfc3339
98        )
99    }
100
101    #[test]
102    fn fresh_cache_is_fresh() {
103        let cache = VersionCache::new("1.0.0".to_owned());
104        assert!(cache.is_fresh());
105    }
106
107    #[test]
108    fn old_cache_is_stale() {
109        let cache = VersionCache::expired_at("1.0.0", 25);
110        assert!(!cache.is_fresh());
111    }
112
113    #[test]
114    fn exactly_24h_is_stale() {
115        let cache = VersionCache::expired_at("1.0.0", 24);
116        assert!(!cache.is_fresh());
117    }
118
119    #[test]
120    fn missing_file_returns_none() {
121        let result = VersionCache::read_from_path(std::path::Path::new("/nonexistent/cache.json"));
122        assert!(result.is_none());
123    }
124
125    #[test]
126    fn empty_file_returns_none() {
127        let mut file = NamedTempFile::new().unwrap();
128        write!(file, "").unwrap();
129        let result = VersionCache::read_from_path(file.path());
130        assert!(result.is_none());
131    }
132
133    #[test]
134    fn whitespace_only_file_returns_none() {
135        let mut file = NamedTempFile::new().unwrap();
136        write!(file, "   \n  ").unwrap();
137        let result = VersionCache::read_from_path(file.path());
138        assert!(result.is_none());
139    }
140
141    #[test]
142    fn corrupted_json_returns_none() {
143        let mut file = NamedTempFile::new().unwrap();
144        write!(file, "not json").unwrap();
145        let result = VersionCache::read_from_path(file.path());
146        assert!(result.is_none());
147    }
148
149    #[test]
150    fn missing_required_fields_returns_none() {
151        let mut file = NamedTempFile::new().unwrap();
152        write!(file, r#"{{"wrong_field":"x"}}"#).unwrap();
153        let result = VersionCache::read_from_path(file.path());
154        assert!(result.is_none());
155    }
156
157    #[test]
158    fn valid_cache_reads_correctly() {
159        let mut file = NamedTempFile::new().unwrap();
160        let now_rfc3339 = chrono::Utc::now().to_rfc3339();
161        write!(file, "{}", cache_json("2.0.0", &now_rfc3339)).unwrap();
162        let result = VersionCache::read_from_path(file.path());
163        assert!(result.is_some());
164        let cache = result.unwrap();
165        assert_eq!(cache.latest_version, "2.0.0");
166        assert_eq!(cache.checked_at, now_rfc3339);
167    }
168
169    #[test]
170    fn write_creates_directories_and_file() {
171        let dir = tempfile::tempdir().unwrap();
172        let path = dir.path().join("subdir").join("nested").join("cache.json");
173        let cache = VersionCache::new("3.0.0".to_owned());
174        cache.write_to_path(&path).unwrap();
175        assert!(path.exists());
176        let read_back = VersionCache::read_from_path(&path).unwrap();
177        assert_eq!(read_back.latest_version, "3.0.0");
178    }
179
180    #[test]
181    fn write_then_read_roundtrips() {
182        let dir = tempfile::tempdir().unwrap();
183        let path = dir.path().join("cache.json");
184        let cache = VersionCache::new("4.0.0".to_owned());
185        cache.write_to_path(&path).unwrap();
186        let read_back = VersionCache::read_from_path(&path).unwrap();
187        assert_eq!(read_back.latest_version, "4.0.0");
188        assert_eq!(read_back.checked_at, cache.checked_at);
189    }
190
191    #[test]
192    fn invalid_timestamp_is_stale() {
193        let cache = VersionCache {
194            latest_version: "1.0.0".to_owned(),
195            checked_at: "not-a-timestamp".to_owned(),
196            has_assets: None,
197        };
198        assert!(!cache.is_fresh());
199    }
200
201    #[test]
202    fn new_creates_with_current_timestamp() {
203        let before = chrono::Utc::now() - chrono::Duration::seconds(1);
204        let cache = VersionCache::new("1.0.0".to_owned());
205        let after = chrono::Utc::now() + chrono::Duration::seconds(1);
206
207        let parsed = chrono::DateTime::parse_from_rfc3339(&cache.checked_at).unwrap();
208        assert!(parsed > before);
209        assert!(parsed < after);
210    }
211
212    #[test]
213    fn cache_path_is_in_data_dir() {
214        let path = VersionCache::cache_path();
215        assert!(path.is_some());
216        let p = path.unwrap();
217        assert!(p.ends_with("version-check.json"));
218        assert!(p.to_string_lossy().contains("seshat"));
219    }
220}