1use std::path::{Path, PathBuf};
7
8pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
10
11const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
13
14#[derive(Debug, Clone)]
16pub enum VersionCheckResult {
17 UpdateAvailable { latest: String },
19 UpToDate,
21 Failed,
23}
24
25#[must_use]
29pub fn is_newer(current: &str, latest: &str) -> bool {
30 let parse = |v: &str| -> Option<(u32, u32, u32)> {
31 let v = v.strip_prefix('v').unwrap_or(v);
32 let v = v.split('-').next()?;
34 let mut parts = v.splitn(3, '.');
35 let major = parts.next()?.parse().ok()?;
36 let minor = parts.next()?.parse().ok()?;
37 let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
38 Some((major, minor, patch))
39 };
40
41 match (parse(current), parse(latest)) {
42 (Some(c), Some(l)) => l > c,
43 _ => false,
44 }
45}
46
47fn cache_path() -> PathBuf {
49 let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
50 config_dir.join("pi").join(".version_check_cache")
51}
52
53#[must_use]
55pub fn read_cached_version() -> Option<String> {
56 read_cached_version_at(&cache_path())
57}
58
59fn read_cached_version_at(path: &Path) -> Option<String> {
60 let metadata = std::fs::metadata(path).ok()?;
61 let modified = metadata.modified().ok()?;
62 let age = modified.elapsed().ok()?;
63 if age.as_secs() > CACHE_TTL_SECS {
64 return None;
65 }
66 let content = std::fs::read_to_string(path).ok()?;
67 let version = content.trim().to_string();
68 if version.is_empty() {
69 return None;
70 }
71 Some(version)
72}
73
74pub fn write_cached_version(version: &str) {
76 write_cached_version_at(&cache_path(), version);
77}
78
79fn write_cached_version_at(path: &Path, version: &str) {
80 if let Some(parent) = path.parent() {
81 let _ = std::fs::create_dir_all(parent);
82 }
83 let _ = std::fs::write(path, version);
84}
85
86#[must_use]
91pub fn check_cached() -> VersionCheckResult {
92 read_cached_version().map_or(VersionCheckResult::Failed, |latest| {
93 if is_newer(CURRENT_VERSION, &latest) {
94 VersionCheckResult::UpdateAvailable { latest }
95 } else {
96 VersionCheckResult::UpToDate
97 }
98 })
99}
100
101#[must_use]
105pub fn parse_github_release_version(json: &str) -> Option<String> {
106 let value: serde_json::Value = serde_json::from_str(json).ok()?;
107 let tag = value.get("tag_name")?.as_str()?;
108 let version = tag.strip_prefix('v').unwrap_or(tag);
110 Some(version.to_string())
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn is_newer_basic() {
119 assert!(is_newer("0.1.0", "0.2.0"));
120 assert!(is_newer("0.1.0", "1.0.0"));
121 assert!(is_newer("1.0.0", "1.0.1"));
122 }
123
124 #[test]
125 fn is_newer_same_version() {
126 assert!(!is_newer("1.0.0", "1.0.0"));
127 }
128
129 #[test]
130 fn is_newer_current_is_newer() {
131 assert!(!is_newer("2.0.0", "1.0.0"));
132 }
133
134 #[test]
135 fn is_newer_with_v_prefix() {
136 assert!(is_newer("v0.1.0", "v0.2.0"));
137 assert!(is_newer("0.1.0", "v0.2.0"));
138 assert!(is_newer("v0.1.0", "0.2.0"));
139 }
140
141 #[test]
142 fn is_newer_with_prerelease() {
143 assert!(!is_newer("1.2.3-dev", "1.2.3"));
145 assert!(is_newer("1.2.3-dev", "1.3.0"));
146 }
147
148 #[test]
149 fn is_newer_invalid_versions() {
150 assert!(!is_newer("not-a-version", "1.0.0"));
151 assert!(!is_newer("1.0.0", "not-a-version"));
152 assert!(!is_newer("", ""));
153 }
154
155 #[test]
156 fn parse_github_release_version_valid() {
157 let json = r#"{"tag_name": "v0.2.0", "name": "Release 0.2.0"}"#;
158 assert_eq!(
159 parse_github_release_version(json),
160 Some("0.2.0".to_string())
161 );
162 }
163
164 #[test]
165 fn parse_github_release_version_no_v_prefix() {
166 let json = r#"{"tag_name": "0.2.0"}"#;
167 assert_eq!(
168 parse_github_release_version(json),
169 Some("0.2.0".to_string())
170 );
171 }
172
173 #[test]
174 fn parse_github_release_version_invalid_json() {
175 assert_eq!(parse_github_release_version("not json"), None);
176 }
177
178 #[test]
179 fn parse_github_release_version_missing_tag() {
180 let json = r#"{"name": "Release"}"#;
181 assert_eq!(parse_github_release_version(json), None);
182 }
183
184 #[test]
185 fn cache_round_trip() {
186 let dir = tempfile::tempdir().unwrap();
187 let path = dir.path().join("cache");
188
189 write_cached_version_at(&path, "1.2.3");
190 assert_eq!(read_cached_version_at(&path), Some("1.2.3".to_string()));
191 }
192
193 #[test]
194 fn cache_missing_file() {
195 let dir = tempfile::tempdir().unwrap();
196 let path = dir.path().join("nonexistent");
197 assert_eq!(read_cached_version_at(&path), None);
198 }
199
200 #[test]
201 fn cache_empty_file() {
202 let dir = tempfile::tempdir().unwrap();
203 let path = dir.path().join("cache");
204 std::fs::write(&path, "").unwrap();
205 assert_eq!(read_cached_version_at(&path), None);
206 }
207
208 mod proptest_version_check {
209 use super::*;
210 use proptest::prelude::*;
211
212 proptest! {
213 #[test]
215 fn is_newer_irreflexive(
216 major in 0..100u32,
217 minor in 0..100u32,
218 patch in 0..100u32
219 ) {
220 let v = format!("{major}.{minor}.{patch}");
221 assert!(!is_newer(&v, &v));
222 }
223
224 #[test]
226 fn is_newer_asymmetric(
227 major in 0..50u32,
228 minor in 0..50u32,
229 patch in 0..50u32,
230 bump in 1..10u32
231 ) {
232 let older = format!("{major}.{minor}.{patch}");
233 let newer = format!("{major}.{minor}.{}", patch + bump);
234 assert!(is_newer(&older, &newer));
235 assert!(!is_newer(&newer, &older));
236 }
237
238 #[test]
240 fn v_prefix_transparent(
241 major in 0..100u32,
242 minor in 0..100u32,
243 patch in 0..100u32,
244 bump in 1..10u32
245 ) {
246 let older = format!("{major}.{minor}.{patch}");
247 let newer = format!("{major}.{minor}.{}", patch + bump);
248 assert_eq!(
249 is_newer(&older, &newer),
250 is_newer(&format!("v{older}"), &format!("v{newer}"))
251 );
252 }
253
254 #[test]
256 fn prerelease_stripped(
257 major in 0..100u32,
258 minor in 0..100u32,
259 patch in 0..100u32,
260 suffix in "[a-z]{1,8}"
261 ) {
262 let plain = format!("{major}.{minor}.{patch}");
263 let pre = format!("{major}.{minor}.{patch}-{suffix}");
264 assert!(!is_newer(&plain, &pre));
266 assert!(!is_newer(&pre, &plain));
267 }
268
269 #[test]
271 fn garbage_never_newer(s in "\\PC{1,30}") {
272 assert!(!is_newer(&s, "1.0.0") || s.contains('.'));
273 assert!(!is_newer("1.0.0", &s) || s.contains('.'));
274 }
275
276 #[test]
278 fn parse_github_release_extracts_tag(ver in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}") {
279 let json = format!(r#"{{"tag_name": "v{ver}"}}"#);
280 assert_eq!(parse_github_release_version(&json), Some(ver));
281 }
282
283 #[test]
285 fn parse_github_release_no_tag(key in "[a-z_]{1,10}") {
286 prop_assume!(key != "tag_name");
287 let json = format!(r#"{{"{key}": "v1.0.0"}}"#);
288 assert_eq!(parse_github_release_version(&json), None);
289 }
290
291 #[test]
293 fn parse_github_release_invalid_json(s in "[^{}]{1,30}") {
294 assert_eq!(parse_github_release_version(&s), None);
295 }
296
297 #[test]
299 fn cache_round_trip_preserves(ver in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}") {
300 let dir = tempfile::tempdir().unwrap();
301 let path = dir.path().join("cache");
302 write_cached_version_at(&path, &ver);
303 assert_eq!(read_cached_version_at(&path), Some(ver));
304 }
305
306 #[test]
308 fn major_bump_detected(
309 major in 0..50u32,
310 minor in 0..100u32,
311 patch in 0..100u32,
312 bump in 1..10u32
313 ) {
314 let older = format!("{major}.{minor}.{patch}");
315 let newer = format!("{}.0.0", major + bump);
316 assert!(is_newer(&older, &newer));
317 }
318
319 #[test]
321 fn two_component_version(
322 major in 0..100u32,
323 minor in 0..100u32,
324 bump in 1..10u32
325 ) {
326 let v2 = format!("{major}.{minor}");
327 let v3 = format!("{major}.{minor}.0");
328 assert!(!is_newer(&v2, &v3));
330 assert!(!is_newer(&v3, &v2));
331 let bumped = format!("{major}.{}.0", minor + bump);
333 assert!(is_newer(&v2, &bumped));
334 }
335
336 #[test]
338 fn patch_bump_transitivity(
339 major in 0..100u32,
340 minor in 0..100u32,
341 patch in 0..100u32,
342 bump_a in 1..10u32,
343 bump_b in 1..10u32
344 ) {
345 let base = format!("{major}.{minor}.{patch}");
346 let mid = format!("{major}.{minor}.{}", patch + bump_a);
347 let top = format!("{major}.{minor}.{}", patch + bump_a + bump_b);
348
349 assert!(is_newer(&base, &mid));
350 assert!(is_newer(&mid, &top));
351 assert!(is_newer(&base, &top));
352 }
353 }
354 }
355}