use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const RELEASES_URL: &str = "https://api.github.com/repos/eljulians/skillfile/releases/latest";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateNotice {
pub current: String,
pub latest: String,
pub url: String,
}
impl std::fmt::Display for UpdateNotice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"A new version of skillfile is available: v{} -> v{}\n{}",
self.current, self.latest, self.url
)
}
}
#[derive(Debug, Serialize, Deserialize)]
struct CacheEntry {
last_check: String,
latest_version: String,
release_url: String,
}
pub fn cache_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("skillfile").join("update-check.json"))
}
pub fn should_check() -> bool {
if std::env::var("SKILLFILE_NO_UPDATE_NOTIFIER").is_ok_and(|v| !v.is_empty()) {
return false;
}
if std::env::var("CI").is_ok_and(|v| v == "true" || v == "1") {
return false;
}
std::io::stderr().is_terminal()
}
pub fn is_newer(current: &str, latest: &str) -> bool {
let current = current.strip_prefix('v').unwrap_or(current);
let latest = latest.strip_prefix('v').unwrap_or(latest);
match (
semver::Version::parse(current),
semver::Version::parse(latest),
) {
(Ok(c), Ok(l)) => l > c,
_ => false,
}
}
fn read_cache_from(path: &std::path::Path) -> Option<CacheEntry> {
let contents = std::fs::read_to_string(path).ok()?;
let entry: CacheEntry = serde_json::from_str(&contents).ok()?;
let last_check = parse_timestamp(&entry.last_check)?;
let elapsed = SystemTime::now().duration_since(last_check).ok()?;
if elapsed < CHECK_INTERVAL {
Some(entry)
} else {
None
}
}
fn read_cache() -> Option<CacheEntry> {
read_cache_from(&cache_path()?)
}
fn write_cache_to(path: &std::path::Path, entry: &CacheEntry) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(entry) {
let _ = std::fs::write(path, json);
}
}
fn write_cache(entry: &CacheEntry) {
if let Some(path) = cache_path() {
write_cache_to(&path, entry);
}
}
fn fetch_latest_release() -> Option<(String, String)> {
let agent = ureq::Agent::new_with_defaults();
let mut response = agent
.get(RELEASES_URL)
.header("User-Agent", "skillfile-update-check")
.header("Accept", "application/vnd.github.v3+json")
.call()
.ok()?;
let body = response.body_mut().read_to_string().ok()?;
let data: serde_json::Value = serde_json::from_str(&body).ok()?;
let tag = data["tag_name"].as_str()?;
let html_url = data["html_url"].as_str().map(ToString::to_string);
let url = html_url
.unwrap_or_else(|| format!("https://github.com/eljulians/skillfile/releases/tag/{tag}"));
Some((tag.to_string(), url))
}
pub fn check_for_update() -> Option<UpdateNotice> {
if let Some(cached) = read_cache() {
return if is_newer(CURRENT_VERSION, &cached.latest_version) {
Some(UpdateNotice {
current: CURRENT_VERSION.to_string(),
latest: cached.latest_version,
url: cached.release_url,
})
} else {
None
};
}
let (tag, url) = fetch_latest_release()?;
let version = tag.strip_prefix('v').unwrap_or(&tag).to_string();
write_cache(&CacheEntry {
last_check: now_timestamp(),
latest_version: version.clone(),
release_url: url.clone(),
});
if is_newer(CURRENT_VERSION, &version) {
Some(UpdateNotice {
current: CURRENT_VERSION.to_string(),
latest: version,
url,
})
} else {
None
}
}
pub fn spawn_check() -> mpsc::Receiver<Option<UpdateNotice>> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let notice = check_for_update();
let _ = tx.send(notice);
});
rx
}
fn now_timestamp() -> String {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string()
}
fn parse_timestamp(s: &str) -> Option<SystemTime> {
let secs: u64 = s.parse().ok()?;
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_newer_detects_minor_bump() {
assert!(is_newer("1.0.0", "1.1.0"));
}
#[test]
fn is_newer_detects_major_bump() {
assert!(is_newer("1.0.0", "2.0.0"));
}
#[test]
fn is_newer_detects_patch_bump() {
assert!(is_newer("1.0.0", "1.0.1"));
}
#[test]
fn is_newer_returns_false_when_same() {
assert!(!is_newer("1.0.0", "1.0.0"));
}
#[test]
fn is_newer_returns_false_when_current_is_greater() {
assert!(!is_newer("1.1.0", "1.0.0"));
assert!(!is_newer("2.0.0", "1.0.0"));
}
#[test]
fn is_newer_strips_v_prefix() {
assert!(is_newer("v1.0.0", "v1.1.0"));
assert!(is_newer("1.0.0", "v1.1.0"));
assert!(is_newer("v1.0.0", "1.1.0"));
}
#[test]
fn is_newer_returns_false_on_invalid_semver() {
assert!(!is_newer("not-a-version", "1.0.0"));
assert!(!is_newer("1.0.0", "not-a-version"));
assert!(!is_newer("abc", "def"));
}
#[test]
fn is_newer_handles_prerelease() {
assert!(is_newer("1.0.0-alpha", "1.0.0"));
assert!(!is_newer("1.0.0", "1.0.0-alpha"));
}
#[test]
fn should_check_blocked_by_no_update_notifier() {
let key = "SKILLFILE_NO_UPDATE_NOTIFIER";
let original = std::env::var(key).ok();
std::env::set_var(key, "1");
assert!(!should_check());
std::env::set_var(key, "yes");
assert!(!should_check());
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[test]
fn should_check_blocked_by_ci_true() {
let key = "CI";
let original = std::env::var(key).ok();
std::env::set_var(key, "true");
assert!(!should_check());
std::env::set_var(key, "1");
assert!(!should_check());
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[test]
fn should_check_not_blocked_by_empty_notifier_var() {
let key = "SKILLFILE_NO_UPDATE_NOTIFIER";
let original = std::env::var(key).ok();
std::env::set_var(key, "");
let _ = should_check();
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[test]
fn update_notice_display_contains_versions_and_url() {
let notice = UpdateNotice {
current: "1.0.0".to_string(),
latest: "1.1.0".to_string(),
url: "https://github.com/eljulians/skillfile/releases/tag/v1.1.0".to_string(),
};
let s = notice.to_string();
assert!(s.contains("v1.0.0 -> v1.1.0"));
assert!(s.contains("https://github.com/eljulians/skillfile/releases/tag/v1.1.0"));
}
#[test]
fn update_notice_display_starts_with_new_version_message() {
let notice = UpdateNotice {
current: "1.0.1".to_string(),
latest: "2.0.0".to_string(),
url: "https://example.com/release".to_string(),
};
assert!(notice.to_string().starts_with("A new version of skillfile"));
}
#[test]
fn now_timestamp_is_parseable() {
let ts = now_timestamp();
let parsed = parse_timestamp(&ts);
assert!(
parsed.is_some(),
"now_timestamp() should produce a parseable value"
);
}
#[test]
fn parse_timestamp_valid_unix() {
let t = parse_timestamp("1710000000");
assert!(t.is_some());
}
#[test]
fn parse_timestamp_invalid_returns_none() {
assert!(parse_timestamp("not-a-number").is_none());
assert!(parse_timestamp("").is_none());
}
#[test]
fn timestamp_round_trip() {
let ts = now_timestamp();
let parsed = parse_timestamp(&ts).unwrap();
let elapsed = SystemTime::now().duration_since(parsed).unwrap();
assert!(elapsed.as_secs() < 5);
}
#[test]
fn cache_entry_serialization_round_trip() {
let entry = CacheEntry {
last_check: "1710000000".to_string(),
latest_version: "1.2.3".to_string(),
release_url: "https://example.com/release".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
let deserialized: CacheEntry = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.last_check, "1710000000");
assert_eq!(deserialized.latest_version, "1.2.3");
assert_eq!(deserialized.release_url, "https://example.com/release");
}
#[test]
fn cache_path_ends_with_expected_filename() {
if let Some(path) = cache_path() {
assert!(
path.ends_with("skillfile/update-check.json"),
"unexpected cache path: {path:?}"
);
}
}
#[test]
fn write_cache_to_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested/dir/update-check.json");
let entry = CacheEntry {
last_check: now_timestamp(),
latest_version: "99.99.99".to_string(),
release_url: "https://example.com".to_string(),
};
write_cache_to(&path, &entry);
assert!(path.exists(), "cache file should be created");
}
#[test]
fn read_cache_from_returns_none_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent.json");
assert!(read_cache_from(&path).is_none());
}
#[test]
fn read_cache_from_returns_entry_when_fresh() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("update-check.json");
let entry = CacheEntry {
last_check: now_timestamp(),
latest_version: "99.88.77".to_string(),
release_url: "https://example.com/fresh".to_string(),
};
write_cache_to(&path, &entry);
let cached = read_cache_from(&path);
assert!(cached.is_some(), "fresh cache entry should be readable");
let cached = cached.unwrap();
assert_eq!(cached.latest_version, "99.88.77");
assert_eq!(cached.release_url, "https://example.com/fresh");
}
#[test]
fn read_cache_from_returns_none_when_stale() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("update-check.json");
let stale_time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
- 48 * 60 * 60;
let entry = CacheEntry {
last_check: stale_time.to_string(),
latest_version: "99.88.77".to_string(),
release_url: "https://example.com/stale".to_string(),
};
write_cache_to(&path, &entry);
assert!(
read_cache_from(&path).is_none(),
"stale cache entry (48h old) should be ignored"
);
}
#[test]
fn read_cache_from_returns_none_on_malformed_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("update-check.json");
std::fs::write(&path, "not valid json").unwrap();
assert!(read_cache_from(&path).is_none());
}
#[test]
fn read_cache_from_returns_none_on_invalid_timestamp() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("update-check.json");
let entry = CacheEntry {
last_check: "not-a-number".to_string(),
latest_version: "1.0.0".to_string(),
release_url: "https://example.com".to_string(),
};
write_cache_to(&path, &entry);
assert!(read_cache_from(&path).is_none());
}
#[test]
fn spawn_check_returns_receiver_without_panic() {
let rx = spawn_check();
std::thread::sleep(Duration::from_millis(200));
let _ = rx.try_recv();
}
}