seshat_cli/
version_cache.rs1use 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}