1use crate::error::{Error, Result};
7use crate::http::client::Client;
8use semver::{BuildMetadata, Version};
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
14
15const 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#[derive(Debug, Clone)]
23pub enum VersionCheckResult {
24 UpdateAvailable { latest: String },
26 UpToDate,
28 Failed,
30}
31
32#[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
68fn 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#[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
95pub 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
107pub 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#[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#[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
537 fn parse_github_release_invalid_json(s in "[^{}]{1,30}") {
538 assert_eq!(parse_github_release_version(&s), None);
539 }
540
541 #[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 #[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 #[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 assert!(!is_newer(&v2, &v3));
574 assert!(!is_newer(&v3, &v2));
575 let bumped = format!("{major}.{}.0", minor + bump);
577 assert!(is_newer(&v2, &bumped));
578 }
579
580 #[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}