use semver::Version;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
const CACHE_TTL: Duration = Duration::from_secs(24 * 3600);
const RELEASES_URL: &str = "https://api.github.com/repos/7xuanlu/origin/releases/latest";
static MEMORY_FALLBACK: Mutex<Option<CacheEntry>> = Mutex::new(None);
#[derive(Serialize, Deserialize, Debug, Clone)]
struct CacheEntry {
latest_tag: String,
checked_at_secs: u64,
}
fn cache_path() -> Option<PathBuf> {
let base = std::env::var_os("ORIGIN_MCP_CACHE_DIR")
.map(PathBuf::from)
.or_else(|| dirs::cache_dir().map(|d| d.join("origin-mcp")))?;
std::fs::create_dir_all(&base).ok()?;
Some(base.join("version-check.json"))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn load_cache() -> Option<CacheEntry> {
if let Some(path) = cache_path() {
if let Ok(bytes) = std::fs::read(&path) {
if let Ok(entry) = serde_json::from_slice::<CacheEntry>(&bytes) {
if now_secs().saturating_sub(entry.checked_at_secs) < CACHE_TTL.as_secs() {
return Some(entry);
}
}
}
}
let guard = MEMORY_FALLBACK.lock().ok()?;
let entry = guard.as_ref()?;
if now_secs().saturating_sub(entry.checked_at_secs) < CACHE_TTL.as_secs() {
Some(entry.clone())
} else {
None
}
}
fn store_cache(entry: &CacheEntry) {
if let Some(path) = cache_path() {
if let Ok(bytes) = serde_json::to_vec(entry) {
if std::fs::write(&path, bytes).is_ok() {
return;
}
}
}
if let Ok(mut guard) = MEMORY_FALLBACK.lock() {
*guard = Some(entry.clone());
}
}
async fn fetch_latest_tag() -> Option<String> {
let resp = reqwest::Client::new()
.get(RELEASES_URL)
.header(
"User-Agent",
concat!("origin-mcp/", env!("CARGO_PKG_VERSION")),
)
.timeout(Duration::from_secs(3))
.send()
.await
.ok()?;
let body: serde_json::Value = resp.json().await.ok()?;
body["tag_name"]
.as_str()
.map(|s| s.trim_start_matches('v').to_string())
}
pub async fn check() -> Option<String> {
let mcp_version = env!("CARGO_PKG_VERSION");
let mcp = Version::parse(mcp_version).ok()?;
let latest_tag = match load_cache() {
Some(entry) => entry.latest_tag,
None => {
let tag = fetch_latest_tag().await?;
store_cache(&CacheEntry {
latest_tag: tag.clone(),
checked_at_secs: now_secs(),
});
tag
}
};
let latest = Version::parse(&latest_tag).ok()?;
if latest > mcp {
Some(format!(
"A newer origin-mcp is available (v{latest}, you are on v{mcp}). \
Run `brew upgrade origin-mcp`."
))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static CACHE_LOCK: Mutex<()> = Mutex::new(());
fn set_temp_cache(label: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("origin-mcp-test-{label}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::env::set_var("ORIGIN_MCP_CACHE_DIR", &dir);
dir
}
#[test]
fn cache_path_under_user_cache_dir() {
let _g = CACHE_LOCK.lock().unwrap();
std::env::remove_var("ORIGIN_MCP_CACHE_DIR");
let p = cache_path().expect("cache dir should resolve on this platform");
assert!(p.ends_with("origin-mcp/version-check.json"), "got {p:?}");
}
#[test]
fn cache_round_trip_within_ttl() {
let _g = CACHE_LOCK.lock().unwrap();
let dir = set_temp_cache("round-trip");
let entry = CacheEntry {
latest_tag: "9.9.9".to_string(),
checked_at_secs: now_secs(),
};
store_cache(&entry);
let loaded = load_cache().expect("cache should load");
assert_eq!(loaded.latest_tag, "9.9.9");
let _ = std::fs::remove_dir_all(&dir);
std::env::remove_var("ORIGIN_MCP_CACHE_DIR");
}
#[test]
fn cache_expires_after_ttl() {
let _g = CACHE_LOCK.lock().unwrap();
let dir = set_temp_cache("expires");
let entry = CacheEntry {
latest_tag: "9.9.9".to_string(),
checked_at_secs: now_secs().saturating_sub(CACHE_TTL.as_secs() + 60),
};
store_cache(&entry);
assert!(
load_cache().is_none(),
"expired entry should not be returned"
);
let _ = std::fs::remove_dir_all(&dir);
std::env::remove_var("ORIGIN_MCP_CACHE_DIR");
}
}