Skip to main content

pi/
version_check.rs

1//! Background version check — queries GitHub releases for newer versions.
2//!
3//! Checks are non-blocking, cached for 24 hours, and configurable via
4//! `check_for_updates` in settings.json.
5
6use std::path::{Path, PathBuf};
7
8/// Current crate version (from Cargo.toml).
9pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
10
11/// How long to cache the version check result (24 hours).
12const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
13
14/// Result of a version check.
15#[derive(Debug, Clone)]
16pub enum VersionCheckResult {
17    /// A newer version is available.
18    UpdateAvailable { latest: String },
19    /// Already on the latest (or newer) version.
20    UpToDate,
21    /// Check failed (network error, parse error, etc.) — fail silently.
22    Failed,
23}
24
25/// Compare two semver-like version strings (e.g. "0.1.0" vs "0.2.0").
26///
27/// Returns `true` if `latest` is strictly newer than `current`.
28#[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        // Strip pre-release suffix (e.g. "1.2.3-dev")
33        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
47/// Path to the version check cache file.
48fn 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/// Read a cached version if the cache is fresh (within TTL).
54#[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
74/// Write a version to the cache file.
75pub 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/// Check the latest version from cache or return None if cache is stale/missing.
87///
88/// The actual HTTP check is performed separately (by the caller spawning
89/// a background task with the HTTP client).
90#[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/// Parse the latest version from a GitHub releases API JSON response.
102///
103/// Expects the response from `https://api.github.com/repos/OWNER/REPO/releases/latest`.
104#[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    // Strip leading 'v' if present
109    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        // Pre-release suffix is stripped for comparison
144        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            /// `is_newer` is irreflexive: no version is newer than itself.
214            #[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            /// `is_newer` is asymmetric: if a > b then !(b > a).
225            #[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            /// Leading 'v' prefix is stripped transparently.
239            #[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            /// Pre-release suffix is ignored for comparison.
255            #[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                // Same version regardless of suffix
265                assert!(!is_newer(&plain, &pre));
266                assert!(!is_newer(&pre, &plain));
267            }
268
269            /// Garbage strings never report newer.
270            #[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            /// `parse_github_release_version` extracts tag_name.
277            #[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            /// Missing `tag_name` returns None.
284            #[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            /// Invalid JSON returns None.
292            #[test]
293            fn parse_github_release_invalid_json(s in "[^{}]{1,30}") {
294                assert_eq!(parse_github_release_version(&s), None);
295            }
296
297            /// Cache round-trip preserves version string.
298            #[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            /// Major version bumps are always detected.
307            #[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            /// Two-component versions default patch to 0.
320            #[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                // Both should parse the same, so neither is newer
329                assert!(!is_newer(&v2, &v3));
330                assert!(!is_newer(&v3, &v2));
331                // But a bumped version IS newer
332                let bumped = format!("{major}.{}.0", minor + bump);
333                assert!(is_newer(&v2, &bumped));
334            }
335
336            /// Strict patch bumps are transitive for well-formed versions.
337            #[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}