use std::path::{Path, PathBuf};
pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
#[derive(Debug, Clone)]
pub enum VersionCheckResult {
UpdateAvailable { latest: String },
UpToDate,
Failed,
}
#[must_use]
pub fn is_newer(current: &str, latest: &str) -> bool {
let parse = |v: &str| -> Option<(u32, u32, u32)> {
let v = v.strip_prefix('v').unwrap_or(v);
let v = v.split('-').next()?;
let mut parts = v.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
Some((major, minor, patch))
};
match (parse(current), parse(latest)) {
(Some(c), Some(l)) => l > c,
_ => false,
}
}
fn cache_path() -> PathBuf {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
config_dir.join("pi").join(".version_check_cache")
}
#[must_use]
pub fn read_cached_version() -> Option<String> {
read_cached_version_at(&cache_path())
}
fn read_cached_version_at(path: &Path) -> Option<String> {
let metadata = std::fs::metadata(path).ok()?;
let modified = metadata.modified().ok()?;
let age = modified.elapsed().ok()?;
if age.as_secs() > CACHE_TTL_SECS {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
let version = content.trim().to_string();
if version.is_empty() {
return None;
}
Some(version)
}
pub fn write_cached_version(version: &str) {
write_cached_version_at(&cache_path(), version);
}
fn write_cached_version_at(path: &Path, version: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, version);
}
#[must_use]
pub fn check_cached() -> VersionCheckResult {
read_cached_version().map_or(VersionCheckResult::Failed, |latest| {
if is_newer(CURRENT_VERSION, &latest) {
VersionCheckResult::UpdateAvailable { latest }
} else {
VersionCheckResult::UpToDate
}
})
}
#[must_use]
pub fn parse_github_release_version(json: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(json).ok()?;
let tag = value.get("tag_name")?.as_str()?;
let version = tag.strip_prefix('v').unwrap_or(tag);
Some(version.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_newer_basic() {
assert!(is_newer("0.1.0", "0.2.0"));
assert!(is_newer("0.1.0", "1.0.0"));
assert!(is_newer("1.0.0", "1.0.1"));
}
#[test]
fn is_newer_same_version() {
assert!(!is_newer("1.0.0", "1.0.0"));
}
#[test]
fn is_newer_current_is_newer() {
assert!(!is_newer("2.0.0", "1.0.0"));
}
#[test]
fn is_newer_with_v_prefix() {
assert!(is_newer("v0.1.0", "v0.2.0"));
assert!(is_newer("0.1.0", "v0.2.0"));
assert!(is_newer("v0.1.0", "0.2.0"));
}
#[test]
fn is_newer_with_prerelease() {
assert!(!is_newer("1.2.3-dev", "1.2.3"));
assert!(is_newer("1.2.3-dev", "1.3.0"));
}
#[test]
fn is_newer_invalid_versions() {
assert!(!is_newer("not-a-version", "1.0.0"));
assert!(!is_newer("1.0.0", "not-a-version"));
assert!(!is_newer("", ""));
}
#[test]
fn parse_github_release_version_valid() {
let json = r#"{"tag_name": "v0.2.0", "name": "Release 0.2.0"}"#;
assert_eq!(
parse_github_release_version(json),
Some("0.2.0".to_string())
);
}
#[test]
fn parse_github_release_version_no_v_prefix() {
let json = r#"{"tag_name": "0.2.0"}"#;
assert_eq!(
parse_github_release_version(json),
Some("0.2.0".to_string())
);
}
#[test]
fn parse_github_release_version_invalid_json() {
assert_eq!(parse_github_release_version("not json"), None);
}
#[test]
fn parse_github_release_version_missing_tag() {
let json = r#"{"name": "Release"}"#;
assert_eq!(parse_github_release_version(json), None);
}
#[test]
fn cache_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cache");
write_cached_version_at(&path, "1.2.3");
assert_eq!(read_cached_version_at(&path), Some("1.2.3".to_string()));
}
#[test]
fn cache_missing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent");
assert_eq!(read_cached_version_at(&path), None);
}
#[test]
fn cache_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cache");
std::fs::write(&path, "").unwrap();
assert_eq!(read_cached_version_at(&path), None);
}
mod proptest_version_check {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn is_newer_irreflexive(
major in 0..100u32,
minor in 0..100u32,
patch in 0..100u32
) {
let v = format!("{major}.{minor}.{patch}");
assert!(!is_newer(&v, &v));
}
#[test]
fn is_newer_asymmetric(
major in 0..50u32,
minor in 0..50u32,
patch in 0..50u32,
bump in 1..10u32
) {
let older = format!("{major}.{minor}.{patch}");
let newer = format!("{major}.{minor}.{}", patch + bump);
assert!(is_newer(&older, &newer));
assert!(!is_newer(&newer, &older));
}
#[test]
fn v_prefix_transparent(
major in 0..100u32,
minor in 0..100u32,
patch in 0..100u32,
bump in 1..10u32
) {
let older = format!("{major}.{minor}.{patch}");
let newer = format!("{major}.{minor}.{}", patch + bump);
assert_eq!(
is_newer(&older, &newer),
is_newer(&format!("v{older}"), &format!("v{newer}"))
);
}
#[test]
fn prerelease_stripped(
major in 0..100u32,
minor in 0..100u32,
patch in 0..100u32,
suffix in "[a-z]{1,8}"
) {
let plain = format!("{major}.{minor}.{patch}");
let pre = format!("{major}.{minor}.{patch}-{suffix}");
assert!(!is_newer(&plain, &pre));
assert!(!is_newer(&pre, &plain));
}
#[test]
fn garbage_never_newer(s in "\\PC{1,30}") {
assert!(!is_newer(&s, "1.0.0") || s.contains('.'));
assert!(!is_newer("1.0.0", &s) || s.contains('.'));
}
#[test]
fn parse_github_release_extracts_tag(ver in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}") {
let json = format!(r#"{{"tag_name": "v{ver}"}}"#);
assert_eq!(parse_github_release_version(&json), Some(ver));
}
#[test]
fn parse_github_release_no_tag(key in "[a-z_]{1,10}") {
prop_assume!(key != "tag_name");
let json = format!(r#"{{"{key}": "v1.0.0"}}"#);
assert_eq!(parse_github_release_version(&json), None);
}
#[test]
fn parse_github_release_invalid_json(s in "[^{}]{1,30}") {
assert_eq!(parse_github_release_version(&s), None);
}
#[test]
fn cache_round_trip_preserves(ver in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}") {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cache");
write_cached_version_at(&path, &ver);
assert_eq!(read_cached_version_at(&path), Some(ver));
}
#[test]
fn major_bump_detected(
major in 0..50u32,
minor in 0..100u32,
patch in 0..100u32,
bump in 1..10u32
) {
let older = format!("{major}.{minor}.{patch}");
let newer = format!("{}.0.0", major + bump);
assert!(is_newer(&older, &newer));
}
#[test]
fn two_component_version(
major in 0..100u32,
minor in 0..100u32,
bump in 1..10u32
) {
let v2 = format!("{major}.{minor}");
let v3 = format!("{major}.{minor}.0");
assert!(!is_newer(&v2, &v3));
assert!(!is_newer(&v3, &v2));
let bumped = format!("{major}.{}.0", minor + bump);
assert!(is_newer(&v2, &bumped));
}
#[test]
fn patch_bump_transitivity(
major in 0..100u32,
minor in 0..100u32,
patch in 0..100u32,
bump_a in 1..10u32,
bump_b in 1..10u32
) {
let base = format!("{major}.{minor}.{patch}");
let mid = format!("{major}.{minor}.{}", patch + bump_a);
let top = format!("{major}.{minor}.{}", patch + bump_a + bump_b);
assert!(is_newer(&base, &mid));
assert!(is_newer(&mid, &top));
assert!(is_newer(&base, &top));
}
}
}
}