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 crate::error::{Error, Result};
7use crate::http::client::Client;
8use semver::{BuildMetadata, Version};
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12/// Current crate version (from Cargo.toml).
13pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
14
15/// How long to cache the version check result (24 hours).
16const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
17const RELEASE_CHECK_TIMEOUT: Duration = Duration::from_secs(10);
18const RELEASES_URL: &str =
19    "https://api.github.com/repos/Dicklesworthstone/pi_agent_rust/releases/latest";
20
21/// Result of a version check.
22#[derive(Debug, Clone)]
23pub enum VersionCheckResult {
24    /// A newer version is available.
25    UpdateAvailable { latest: String },
26    /// Already on the latest (or newer) version.
27    UpToDate,
28    /// Check failed (network error, parse error, etc.) — fail silently.
29    Failed,
30}
31
32/// Compare two semver-like version strings (e.g. "0.1.0" vs "0.2.0").
33///
34/// Returns `true` if `latest` is strictly newer than `current`.
35#[must_use]
36pub fn is_newer(current: &str, latest: &str) -> bool {
37    match (parse_semver_like(current), parse_semver_like(latest)) {
38        (Some(current), Some(latest)) => latest > current,
39        _ => false,
40    }
41}
42
43fn parse_semver_like(version: &str) -> Option<Version> {
44    let version = version.strip_prefix('v').unwrap_or(version).trim();
45    if version.is_empty() {
46        return None;
47    }
48    if let Ok(parsed) = Version::parse(version) {
49        return Some(strip_build_metadata(parsed));
50    }
51
52    let suffix_idx = version.find(['-', '+']).unwrap_or(version.len());
53    let (core, suffix) = version.split_at(suffix_idx);
54    if core.split('.').count() != 2 {
55        return None;
56    }
57
58    Version::parse(&format!("{core}.0{suffix}"))
59        .ok()
60        .map(strip_build_metadata)
61}
62
63fn strip_build_metadata(mut version: Version) -> Version {
64    version.build = BuildMetadata::EMPTY;
65    version
66}
67
68/// Path to the version check cache file.
69fn cache_path() -> PathBuf {
70    let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
71    config_dir.join("pi").join(".version_check_cache")
72}
73
74/// Read a cached version if the cache is fresh (within TTL).
75#[must_use]
76pub fn read_cached_version() -> Option<String> {
77    read_cached_version_at(&cache_path())
78}
79
80fn read_cached_version_at(path: &Path) -> Option<String> {
81    let metadata = std::fs::metadata(path).ok()?;
82    let modified = metadata.modified().ok()?;
83    let age = modified.elapsed().ok()?;
84    if age.as_secs() > CACHE_TTL_SECS {
85        return None;
86    }
87    let content = std::fs::read_to_string(path).ok()?;
88    let version = content.trim().to_string();
89    if version.is_empty() {
90        return None;
91    }
92    Some(version)
93}
94
95/// Write a version to the cache file.
96pub fn write_cached_version(version: &str) {
97    write_cached_version_at(&cache_path(), version);
98}
99
100fn write_cached_version_at(path: &Path, version: &str) {
101    if let Some(parent) = path.parent() {
102        let _ = std::fs::create_dir_all(parent);
103    }
104    let _ = std::fs::write(path, version);
105}
106
107/// Refresh the cached latest-release version when the cache is missing, stale,
108/// or malformed. Returns the resulting status after the refresh attempt.
109pub async fn refresh_cache_if_stale(client: &Client) -> VersionCheckResult {
110    refresh_cache_if_stale_at(&cache_path(), client, CURRENT_VERSION, RELEASES_URL).await
111}
112
113async fn refresh_cache_if_stale_at(
114    path: &Path,
115    client: &Client,
116    current_version: &str,
117    release_url: &str,
118) -> VersionCheckResult {
119    if let Some(latest) = read_cached_version_at(path) {
120        if parse_semver_like(&latest).is_some() {
121            return version_status_for(current_version, latest);
122        }
123    }
124
125    match fetch_latest_release_version_from_url(client, release_url).await {
126        Ok(latest) => {
127            write_cached_version_at(path, &latest);
128            version_status_for(current_version, latest)
129        }
130        Err(err) => {
131            tracing::debug!(error = %err, "background version check failed");
132            VersionCheckResult::Failed
133        }
134    }
135}
136
137fn version_status_for(current_version: &str, latest: String) -> VersionCheckResult {
138    if is_newer(current_version, &latest) {
139        VersionCheckResult::UpdateAvailable { latest }
140    } else {
141        VersionCheckResult::UpToDate
142    }
143}
144
145async fn fetch_latest_release_version_from_url(
146    client: &Client,
147    release_url: &str,
148) -> Result<String> {
149    let response = client
150        .get(release_url)
151        .timeout(RELEASE_CHECK_TIMEOUT)
152        .header("Accept", "application/vnd.github+json")
153        .header("X-GitHub-Api-Version", "2022-11-28")
154        .send()
155        .await?;
156    let status = response.status();
157    let body = response.text().await?;
158    if status != 200 {
159        return Err(Error::api(format!(
160            "GitHub release lookup failed with status {status}"
161        )));
162    }
163
164    parse_github_release_version(&body)
165        .ok_or_else(|| Error::api("GitHub release lookup response missing tag_name".to_string()))
166}
167
168/// Check the latest version from cache or return None if cache is stale/missing.
169///
170/// The actual HTTP check is performed separately (by the caller spawning
171/// a background task with the HTTP client).
172#[must_use]
173pub fn check_cached() -> VersionCheckResult {
174    check_cached_at(&cache_path(), CURRENT_VERSION)
175}
176
177fn check_cached_at(path: &Path, current_version: &str) -> VersionCheckResult {
178    let Some(latest) = read_cached_version_at(path) else {
179        return VersionCheckResult::Failed;
180    };
181
182    match (
183        parse_semver_like(current_version),
184        parse_semver_like(&latest),
185    ) {
186        (Some(current), Some(latest_version)) => {
187            if latest_version > current {
188                VersionCheckResult::UpdateAvailable { latest }
189            } else {
190                VersionCheckResult::UpToDate
191            }
192        }
193        _ => VersionCheckResult::Failed,
194    }
195}
196
197/// Parse the latest version from a GitHub releases API JSON response.
198///
199/// Expects the response from `https://api.github.com/repos/OWNER/REPO/releases/latest`.
200#[must_use]
201pub fn parse_github_release_version(json: &str) -> Option<String> {
202    let value: serde_json::Value = serde_json::from_str(json).ok()?;
203    let tag = value.get("tag_name")?.as_str()?;
204    // Strip leading 'v' if present
205    let version = tag.strip_prefix('v').unwrap_or(tag);
206    if version.trim().is_empty() {
207        return None;
208    }
209    Some(version.to_string())
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use std::io::{Read, Write};
216    use std::net::TcpListener;
217    use std::thread;
218
219    fn spawn_release_server(status: u16, body: &'static str) -> (String, thread::JoinHandle<()>) {
220        let listener = TcpListener::bind("127.0.0.1:0").expect("bind release server");
221        let addr = listener.local_addr().expect("release server addr");
222        let body = body.to_string();
223        let handle = thread::spawn(move || {
224            let (mut stream, _) = listener.accept().expect("accept release request");
225            let mut request = [0u8; 2048];
226            let _ = stream.read(&mut request);
227            let status_text = match status {
228                200 => "OK",
229                404 => "Not Found",
230                500 => "Internal Server Error",
231                _ => "Test Response",
232            };
233            let response = format!(
234                "HTTP/1.1 {status} {status_text}\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}",
235                body.len(),
236                body
237            );
238            stream
239                .write_all(response.as_bytes())
240                .expect("write release response");
241        });
242        (format!("http://{addr}/releases/latest"), handle)
243    }
244
245    #[test]
246    fn is_newer_basic() {
247        assert!(is_newer("0.1.0", "0.2.0"));
248        assert!(is_newer("0.1.0", "1.0.0"));
249        assert!(is_newer("1.0.0", "1.0.1"));
250    }
251
252    #[test]
253    fn is_newer_same_version() {
254        assert!(!is_newer("1.0.0", "1.0.0"));
255    }
256
257    #[test]
258    fn is_newer_current_is_newer() {
259        assert!(!is_newer("2.0.0", "1.0.0"));
260    }
261
262    #[test]
263    fn is_newer_with_v_prefix() {
264        assert!(is_newer("v0.1.0", "v0.2.0"));
265        assert!(is_newer("0.1.0", "v0.2.0"));
266        assert!(is_newer("v0.1.0", "0.2.0"));
267    }
268
269    #[test]
270    fn is_newer_with_prerelease() {
271        assert!(is_newer("1.2.3-dev", "1.2.3"));
272        assert!(is_newer("1.2.3-dev", "1.3.0"));
273        assert!(!is_newer("1.2.3", "1.2.3-dev"));
274    }
275
276    #[test]
277    fn is_newer_ignores_build_metadata() {
278        assert!(!is_newer("1.2.3+build.1", "1.2.3+build.2"));
279        assert!(!is_newer("1.2.3", "1.2.3+build.2"));
280    }
281
282    #[test]
283    fn is_newer_invalid_versions() {
284        assert!(!is_newer("not-a-version", "1.0.0"));
285        assert!(!is_newer("1.0.0", "not-a-version"));
286        assert!(!is_newer("", ""));
287    }
288
289    #[test]
290    fn parse_github_release_version_valid() {
291        let json = r#"{"tag_name": "v0.2.0", "name": "Release 0.2.0"}"#;
292        assert_eq!(
293            parse_github_release_version(json),
294            Some("0.2.0".to_string())
295        );
296    }
297
298    #[test]
299    fn parse_github_release_version_no_v_prefix() {
300        let json = r#"{"tag_name": "0.2.0"}"#;
301        assert_eq!(
302            parse_github_release_version(json),
303            Some("0.2.0".to_string())
304        );
305    }
306
307    #[test]
308    fn parse_github_release_version_invalid_json() {
309        assert_eq!(parse_github_release_version("not json"), None);
310    }
311
312    #[test]
313    fn parse_github_release_version_missing_tag() {
314        let json = r#"{"name": "Release"}"#;
315        assert_eq!(parse_github_release_version(json), None);
316    }
317
318    #[test]
319    fn parse_github_release_version_rejects_empty_tag() {
320        assert_eq!(parse_github_release_version(r#"{"tag_name": ""}"#), None);
321        assert_eq!(parse_github_release_version(r#"{"tag_name": "v"}"#), None);
322    }
323
324    #[test]
325    fn cache_round_trip() {
326        let dir = tempfile::tempdir().unwrap();
327        let path = dir.path().join("cache");
328
329        write_cached_version_at(&path, "1.2.3");
330        assert_eq!(read_cached_version_at(&path), Some("1.2.3".to_string()));
331    }
332
333    #[test]
334    fn cache_missing_file() {
335        let dir = tempfile::tempdir().unwrap();
336        let path = dir.path().join("nonexistent");
337        assert_eq!(read_cached_version_at(&path), None);
338    }
339
340    #[test]
341    fn cache_empty_file() {
342        let dir = tempfile::tempdir().unwrap();
343        let path = dir.path().join("cache");
344        std::fs::write(&path, "").unwrap();
345        assert_eq!(read_cached_version_at(&path), None);
346    }
347
348    #[test]
349    fn check_cached_invalid_cached_version_fails() {
350        let dir = tempfile::tempdir().unwrap();
351        let path = dir.path().join("cache");
352        write_cached_version_at(&path, "not-a-version");
353        assert!(matches!(
354            check_cached_at(&path, "1.2.3"),
355            VersionCheckResult::Failed
356        ));
357    }
358
359    #[test]
360    fn refresh_cache_if_stale_fetches_and_writes_latest_release() {
361        asupersync::test_utils::run_test(|| async {
362            let dir = tempfile::tempdir().expect("tempdir");
363            let path = dir.path().join("cache");
364            let (url, server) = spawn_release_server(200, r#"{"tag_name":"v9.9.9"}"#);
365
366            let client = Client::new();
367            let result = refresh_cache_if_stale_at(&path, &client, "1.0.0", &url).await;
368
369            assert!(matches!(
370                result,
371                VersionCheckResult::UpdateAvailable { latest } if latest == "9.9.9"
372            ));
373            assert_eq!(read_cached_version_at(&path), Some("9.9.9".to_string()));
374            server.join().expect("join release server");
375        });
376    }
377
378    #[test]
379    fn refresh_cache_if_stale_uses_fresh_cache_without_network() {
380        asupersync::test_utils::run_test(|| async {
381            let dir = tempfile::tempdir().expect("tempdir");
382            let path = dir.path().join("cache");
383            write_cached_version_at(&path, "1.2.3");
384
385            let client = Client::new();
386            let result = refresh_cache_if_stale_at(
387                &path,
388                &client,
389                "1.0.0",
390                "http://127.0.0.1:9/releases/latest",
391            )
392            .await;
393
394            assert!(matches!(
395                result,
396                VersionCheckResult::UpdateAvailable { latest } if latest == "1.2.3"
397            ));
398            assert_eq!(read_cached_version_at(&path), Some("1.2.3".to_string()));
399        });
400    }
401
402    #[test]
403    fn refresh_cache_if_stale_replaces_malformed_cache() {
404        asupersync::test_utils::run_test(|| async {
405            let dir = tempfile::tempdir().expect("tempdir");
406            let path = dir.path().join("cache");
407            write_cached_version_at(&path, "definitely-not-a-version");
408            let (url, server) = spawn_release_server(200, r#"{"tag_name":"v2.1.0"}"#);
409
410            let client = Client::new();
411            let result = refresh_cache_if_stale_at(&path, &client, "2.0.0", &url).await;
412
413            assert!(matches!(
414                result,
415                VersionCheckResult::UpdateAvailable { latest } if latest == "2.1.0"
416            ));
417            assert_eq!(read_cached_version_at(&path), Some("2.1.0".to_string()));
418            server.join().expect("join release server");
419        });
420    }
421
422    #[test]
423    fn refresh_cache_if_stale_fail_closed_on_invalid_release_payload() {
424        asupersync::test_utils::run_test(|| async {
425            let dir = tempfile::tempdir().expect("tempdir");
426            let path = dir.path().join("cache");
427            let (url, server) = spawn_release_server(200, r#"{"name":"missing tag"}"#);
428
429            let client = Client::new();
430            let result = refresh_cache_if_stale_at(&path, &client, "1.0.0", &url).await;
431
432            assert!(matches!(result, VersionCheckResult::Failed));
433            assert_eq!(read_cached_version_at(&path), None);
434            server.join().expect("join release server");
435        });
436    }
437
438    mod proptest_version_check {
439        use super::*;
440        use proptest::prelude::*;
441
442        proptest! {
443            /// `is_newer` is irreflexive: no version is newer than itself.
444            #[test]
445            fn is_newer_irreflexive(
446                major in 0..100u32,
447                minor in 0..100u32,
448                patch in 0..100u32
449            ) {
450                let v = format!("{major}.{minor}.{patch}");
451                assert!(!is_newer(&v, &v));
452            }
453
454            /// `is_newer` is asymmetric: if a > b then !(b > a).
455            #[test]
456            fn is_newer_asymmetric(
457                major in 0..50u32,
458                minor in 0..50u32,
459                patch in 0..50u32,
460                bump in 1..10u32
461            ) {
462                let older = format!("{major}.{minor}.{patch}");
463                let newer = format!("{major}.{minor}.{}", patch + bump);
464                assert!(is_newer(&older, &newer));
465                assert!(!is_newer(&newer, &older));
466            }
467
468            /// Leading 'v' prefix is stripped transparently.
469            #[test]
470            fn v_prefix_transparent(
471                major in 0..100u32,
472                minor in 0..100u32,
473                patch in 0..100u32,
474                bump in 1..10u32
475            ) {
476                let older = format!("{major}.{minor}.{patch}");
477                let newer = format!("{major}.{minor}.{}", patch + bump);
478                assert_eq!(
479                    is_newer(&older, &newer),
480                    is_newer(&format!("v{older}"), &format!("v{newer}"))
481                );
482            }
483
484            /// A stable release must outrank the matching prerelease.
485            #[test]
486            fn release_outranks_prerelease(
487                major in 0..100u32,
488                minor in 0..100u32,
489                patch in 0..100u32,
490                suffix in "[a-z]{1,8}"
491            ) {
492                let plain = format!("{major}.{minor}.{patch}");
493                let pre = format!("{major}.{minor}.{patch}-{suffix}");
494                assert!(!is_newer(&plain, &pre));
495                assert!(is_newer(&pre, &plain));
496            }
497
498            /// Build metadata must not change ordering.
499            #[test]
500            fn build_metadata_does_not_change_ordering(
501                major in 0..100u32,
502                minor in 0..100u32,
503                patch in 0..100u32,
504                build_a in "[a-z0-9]{1,8}",
505                build_b in "[a-z0-9]{1,8}"
506            ) {
507                let with_a = format!("{major}.{minor}.{patch}+{build_a}");
508                let with_b = format!("{major}.{minor}.{patch}+{build_b}");
509                assert!(!is_newer(&with_a, &with_b));
510                assert!(!is_newer(&with_b, &with_a));
511            }
512
513            /// Garbage strings never report newer.
514            #[test]
515            fn garbage_never_newer(s in "\\PC{1,30}") {
516                assert!(!is_newer(&s, "1.0.0") || s.contains('.'));
517                assert!(!is_newer("1.0.0", &s) || s.contains('.'));
518            }
519
520            /// `parse_github_release_version` extracts tag_name.
521            #[test]
522            fn parse_github_release_extracts_tag(ver in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}") {
523                let json = format!(r#"{{"tag_name": "v{ver}"}}"#);
524                assert_eq!(parse_github_release_version(&json), Some(ver));
525            }
526
527            /// Missing `tag_name` returns None.
528            #[test]
529            fn parse_github_release_no_tag(key in "[a-z_]{1,10}") {
530                prop_assume!(key != "tag_name");
531                let json = format!(r#"{{"{key}": "v1.0.0"}}"#);
532                assert_eq!(parse_github_release_version(&json), None);
533            }
534
535            /// Invalid JSON returns None.
536            #[test]
537            fn parse_github_release_invalid_json(s in "[^{}]{1,30}") {
538                assert_eq!(parse_github_release_version(&s), None);
539            }
540
541            /// Cache round-trip preserves version string.
542            #[test]
543            fn cache_round_trip_preserves(ver in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}") {
544                let dir = tempfile::tempdir().unwrap();
545                let path = dir.path().join("cache");
546                write_cached_version_at(&path, &ver);
547                assert_eq!(read_cached_version_at(&path), Some(ver));
548            }
549
550            /// Major version bumps are always detected.
551            #[test]
552            fn major_bump_detected(
553                major in 0..50u32,
554                minor in 0..100u32,
555                patch in 0..100u32,
556                bump in 1..10u32
557            ) {
558                let older = format!("{major}.{minor}.{patch}");
559                let newer = format!("{}.0.0", major + bump);
560                assert!(is_newer(&older, &newer));
561            }
562
563            /// Two-component versions default patch to 0.
564            #[test]
565            fn two_component_version(
566                major in 0..100u32,
567                minor in 0..100u32,
568                bump in 1..10u32
569            ) {
570                let v2 = format!("{major}.{minor}");
571                let v3 = format!("{major}.{minor}.0");
572                // Both should parse the same, so neither is newer
573                assert!(!is_newer(&v2, &v3));
574                assert!(!is_newer(&v3, &v2));
575                // But a bumped version IS newer
576                let bumped = format!("{major}.{}.0", minor + bump);
577                assert!(is_newer(&v2, &bumped));
578            }
579
580            /// Strict patch bumps are transitive for well-formed versions.
581            #[test]
582            fn patch_bump_transitivity(
583                major in 0..100u32,
584                minor in 0..100u32,
585                patch in 0..100u32,
586                bump_a in 1..10u32,
587                bump_b in 1..10u32
588            ) {
589                let base = format!("{major}.{minor}.{patch}");
590                let mid = format!("{major}.{minor}.{}", patch + bump_a);
591                let top = format!("{major}.{minor}.{}", patch + bump_a + bump_b);
592
593                assert!(is_newer(&base, &mid));
594                assert!(is_newer(&mid, &top));
595                assert!(is_newer(&base, &top));
596            }
597        }
598    }
599}