use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
const CRATE_NAME: &str = "markdown-tui-explorer";
const CRATES_IO_URL: &str =
"https://crates.io/api/v1/crates/markdown-tui-explorer";
const USER_AGENT: &str = concat!(
"markdown-tui-explorer/",
env!("CARGO_PKG_VERSION"),
" (version-check; https://github.com/leboiko/markdown-reader)"
);
const CACHE_STALE_SECS: u64 = 60 * 60 * 24;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionCheckCache {
pub checked_at: u64,
pub latest_version: String,
}
fn cache_path() -> Option<PathBuf> {
let mut p = dirs::cache_dir()?;
p.push("markdown-tui-explorer");
p.push("last-version-check.json");
Some(p)
}
pub(crate) fn read_cache() -> Option<VersionCheckCache> {
let path = cache_path()?;
let text = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&text).ok()
}
pub(crate) fn write_cache(cache: &VersionCheckCache) {
let Some(path) = cache_path() else { return };
if let Some(parent) = path.parent()
&& std::fs::create_dir_all(parent).is_err()
{
return;
}
let Ok(text) = serde_json::to_string(cache) else {
return;
};
let _ = std::fs::write(&path, text.as_bytes());
}
#[derive(Debug, Deserialize)]
struct CratesIoResponse {
#[serde(rename = "crate")]
krate: CratesIoCrate,
}
#[derive(Debug, Deserialize)]
struct CratesIoCrate {
max_version: String,
}
fn fetch_latest_version() -> Option<String> {
use ureq::config::Config;
let agent: ureq::Agent = Config::builder()
.timeout_global(Some(Duration::from_secs(10)))
.user_agent(USER_AGENT)
.build()
.into();
let response = agent.get(CRATES_IO_URL).call().ok()?;
let body: CratesIoResponse = response.into_body().read_json().ok()?;
let version = body.krate.max_version;
if version.is_empty() {
return None;
}
Some(version)
}
pub fn is_newer(candidate: &str, current: &str) -> bool {
let Ok(c) = semver::Version::parse(candidate) else {
return false;
};
let Ok(cur) = semver::Version::parse(current) else {
return false;
};
c > cur
}
pub fn spawn_background_check_if_due(check_for_updates: bool) {
if !check_for_updates {
return;
}
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cache_is_fresh = read_cache()
.map(|c| now_secs.saturating_sub(c.checked_at) < CACHE_STALE_SECS)
.unwrap_or(false);
if cache_is_fresh {
return;
}
std::thread::Builder::new()
.name("version-check".into())
.spawn(move || {
let Some(latest) = fetch_latest_version() else {
return;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
write_cache(&VersionCheckCache {
checked_at: now,
latest_version: latest,
});
})
.ok();
}
pub fn print_upgrade_notice_if_outdated(current_version: &str, check_for_updates: bool) {
if !check_for_updates {
return;
}
let Some(cache) = read_cache() else { return };
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now_secs.saturating_sub(cache.checked_at) >= CACHE_STALE_SECS {
return;
}
if is_newer(&cache.latest_version, current_version) {
#[cfg(not(test))]
{
use std::io::Write as _;
let bar = "\u{2500}".repeat(55);
let _ = writeln!(
std::io::stderr(),
"\n{bar}\n \
{CRATE_NAME} {current_version} \u{2192} {} available\n\n \
Upgrade with:\n \
cargo install {CRATE_NAME}\n \
Or download a pre-built binary:\n \
https://github.com/leboiko/markdown-reader/releases/latest\n{bar}",
cache.latest_version,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[test]
fn cache_serializes_round_trip() {
let original = VersionCheckCache {
checked_at: 1_700_000_000,
latest_version: "1.99.0".into(),
};
let json = serde_json::to_string(&original).expect("serialize");
let decoded: VersionCheckCache = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded.checked_at, original.checked_at);
assert_eq!(decoded.latest_version, original.latest_version);
}
#[test]
fn version_comparison_detects_newer() {
assert!(is_newer("1.34.50", "1.34.33"));
assert!(is_newer("2.0.0", "1.99.9"));
assert!(!is_newer("1.34.33", "1.34.33"));
assert!(!is_newer("1.0.0", "1.34.33"));
}
#[test]
fn version_comparison_handles_malformed_input() {
assert!(!is_newer("not-a-version", "1.0.0"));
assert!(!is_newer("1.0.0", "not-a-version"));
assert!(!is_newer("", "1.0.0"));
}
#[test]
fn print_skipped_when_cache_missing() {
let result = is_newer("99.99.99", "1.0.0");
assert!(result, "is_newer should return true for obviously newer version");
print_upgrade_notice_if_outdated("1.0.0", true);
}
#[test]
fn print_skipped_when_check_disabled() {
print_upgrade_notice_if_outdated("1.0.0", false);
}
#[test]
fn print_skipped_when_versions_equal() {
assert!(!is_newer("1.34.33", "1.34.33"));
}
#[test]
fn print_skipped_when_cached_is_older() {
assert!(!is_newer("1.0.0", "1.34.33"));
}
#[test]
fn write_and_read_cache_round_trips() {
let Some(_path) = cache_path() else { return };
let entry = VersionCheckCache {
checked_at: now_secs(),
latest_version: "99.99.99".into(),
};
write_cache(&entry);
let read_back = read_cache();
if let Some(rb) = read_back {
assert_eq!(rb.latest_version, "99.99.99");
}
}
#[test]
fn print_skipped_when_cache_stale() {
let stale_ts = now_secs().saturating_sub(CACHE_STALE_SECS + 3600);
let cache = VersionCheckCache {
checked_at: stale_ts,
latest_version: "99.99.99".into(),
};
let age = now_secs().saturating_sub(cache.checked_at);
assert!(
age >= CACHE_STALE_SECS,
"test cache should be considered stale"
);
assert!(is_newer(&cache.latest_version, "1.0.0"));
}
#[test]
fn print_message_when_cached_is_newer() {
let fresh_cache = VersionCheckCache {
checked_at: now_secs(),
latest_version: "99.99.99".into(),
};
let age = now_secs().saturating_sub(fresh_cache.checked_at);
assert!(age < CACHE_STALE_SECS, "fresh cache should not be stale");
assert!(is_newer(&fresh_cache.latest_version, "1.34.33"));
print_upgrade_notice_if_outdated("1.34.33", true);
}
}