use std::path::PathBuf;
use tracing::{debug, info, warn};
fn cache_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "jarvish")
.map(|p| p.config_dir().join("update_check.json"))
}
const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
#[derive(serde::Serialize, serde::Deserialize)]
struct UpdateCache {
checked_at: u64,
latest_version: String,
}
fn read_cache() -> Option<String> {
let path = cache_path()?;
let content = std::fs::read_to_string(&path).ok()?;
let cache: UpdateCache = serde_json::from_str(&content).ok()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
if now - cache.checked_at < CACHE_TTL_SECS {
debug!(
latest = %cache.latest_version,
age_secs = now - cache.checked_at,
"Using cached update check result"
);
Some(cache.latest_version)
} else {
debug!("Update check cache expired");
None
}
}
fn write_cache(version: &str) {
let Some(path) = cache_path() else { return };
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cache = UpdateCache {
checked_at: now,
latest_version: version.to_string(),
};
if let Ok(json) = serde_json::to_string(&cache) {
let _ = std::fs::write(&path, json);
debug!(path = %path.display(), "Update check cache written");
}
}
pub async fn check_for_update_notification() -> Option<String> {
let current = env!("CARGO_PKG_VERSION");
if let Some(latest) = read_cache() {
return build_notification(current, &latest);
}
let latest = match tokio::task::spawn_blocking(fetch_latest_version).await {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
debug!(error = %e, "Background update check failed");
return None;
}
Err(e) => {
warn!(error = %e, "Background update check task panicked");
return None;
}
};
write_cache(&latest);
build_notification(current, &latest)
}
fn fetch_latest_version() -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
info!("Checking for updates from GitHub Releases...");
let release = self_update::backends::github::Update::configure()
.repo_owner("tominaga-h")
.repo_name("jarvis-shell")
.bin_name("jarvish")
.current_version(self_update::cargo_crate_version!())
.build()?;
let latest = release.get_latest_release()?;
let version = latest.version.trim_start_matches('v').to_string();
info!(latest_version = %version, "Update check complete");
Ok(version)
}
fn build_notification(current: &str, latest: &str) -> Option<String> {
let latest_clean = latest.trim_start_matches('v');
if latest_clean == current {
return None;
}
let current_parts: Vec<u32> = current.split('.').filter_map(|s| s.parse().ok()).collect();
let latest_parts: Vec<u32> = latest_clean
.split('.')
.filter_map(|s| s.parse().ok())
.collect();
if latest_parts <= current_parts {
return None;
}
let is_homebrew = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.map(|s| s.contains("/Cellar/") || s.contains("/homebrew/"))
.unwrap_or(false);
let update_cmd = if is_homebrew {
"`brew upgrade jarvish`"
} else {
"`update`"
};
Some(format!(
" New version available: v{latest_clean} (current: v{current}). Run {update_cmd} to update."
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notification_newer_version_available() {
let result = build_notification("1.6.3", "1.7.0");
assert!(result.is_some());
let msg = result.unwrap();
assert!(msg.contains("v1.7.0"));
assert!(msg.contains("v1.6.3"));
assert!(msg.contains("`update`"));
}
#[test]
fn notification_same_version_returns_none() {
assert!(build_notification("1.7.0", "1.7.0").is_none());
}
#[test]
fn notification_older_version_returns_none() {
assert!(build_notification("1.7.0", "1.6.3").is_none());
}
#[test]
fn notification_strips_v_prefix() {
let result = build_notification("1.6.3", "v1.7.0");
assert!(result.is_some());
assert!(result.unwrap().contains("v1.7.0"));
}
#[test]
fn notification_major_version_bump() {
let result = build_notification("1.7.0", "2.0.0");
assert!(result.is_some());
}
#[test]
fn notification_patch_version_bump() {
let result = build_notification("1.7.0", "1.7.1");
assert!(result.is_some());
}
#[test]
fn notification_equal_major_minor_no_bump() {
assert!(build_notification("1.7.1", "1.7.0").is_none());
}
#[test]
fn cache_path_returns_some() {
let path = cache_path();
assert!(path.is_some());
let path = path.unwrap();
assert!(path.to_str().unwrap().contains("jarvish"));
assert!(path.to_str().unwrap().contains("update_check.json"));
}
#[test]
fn cache_write_and_read_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let cache_file = tmp.path().join("update_check.json");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let cache = UpdateCache {
checked_at: now,
latest_version: "1.8.0".to_string(),
};
let json = serde_json::to_string(&cache).unwrap();
std::fs::write(&cache_file, &json).unwrap();
let content = std::fs::read_to_string(&cache_file).unwrap();
let loaded: UpdateCache = serde_json::from_str(&content).unwrap();
assert_eq!(loaded.latest_version, "1.8.0");
assert_eq!(loaded.checked_at, now);
}
#[test]
fn cache_ttl_expired() {
let tmp = tempfile::TempDir::new().unwrap();
let cache_file = tmp.path().join("update_check.json");
let expired_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
- CACHE_TTL_SECS
- 3600;
let cache = UpdateCache {
checked_at: expired_time,
latest_version: "1.8.0".to_string(),
};
let json = serde_json::to_string(&cache).unwrap();
std::fs::write(&cache_file, json).unwrap();
let content = std::fs::read_to_string(&cache_file).unwrap();
let loaded: UpdateCache = serde_json::from_str(&content).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(now - loaded.checked_at >= CACHE_TTL_SECS);
}
#[test]
fn cache_ttl_valid() {
let recent_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
- 3600;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(now - recent_time < CACHE_TTL_SECS);
}
#[test]
fn cache_invalid_json_returns_none() {
let tmp = tempfile::TempDir::new().unwrap();
let cache_file = tmp.path().join("update_check.json");
std::fs::write(&cache_file, "invalid json").unwrap();
let content = std::fs::read_to_string(&cache_file).unwrap();
let result: Result<UpdateCache, _> = serde_json::from_str(&content);
assert!(result.is_err());
}
#[test]
fn notification_with_v_prefix_on_both() {
let result = build_notification("1.6.3", "v1.7.0");
assert!(result.is_some());
let msg = result.unwrap();
assert!(!msg.contains("vv"));
}
#[test]
fn notification_pre_release_parts_ignored() {
let result = build_notification("1.7.0", "1.8.0-beta");
assert!(result.is_some());
}
#[test]
fn notification_empty_version_returns_none() {
assert!(build_notification("1.0.0", "").is_none());
}
#[test]
fn cache_serialization_roundtrip() {
let cache = UpdateCache {
checked_at: 1700000000,
latest_version: "1.8.0".to_string(),
};
let json = serde_json::to_string(&cache).unwrap();
let loaded: UpdateCache = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.checked_at, 1700000000);
assert_eq!(loaded.latest_version, "1.8.0");
}
#[test]
fn cache_missing_field_returns_error() {
let json = r#"{"latest_version": "1.8.0"}"#;
let result: Result<UpdateCache, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn cache_extra_field_is_ignored() {
let json = r#"{"checked_at": 1700000000, "latest_version": "1.8.0", "extra": true}"#;
let result: Result<UpdateCache, _> = serde_json::from_str(json);
assert!(result.is_ok());
}
#[test]
fn cache_ttl_is_24_hours() {
assert_eq!(CACHE_TTL_SECS, 86400);
}
#[test]
#[ignore]
fn fetch_latest_version_from_github() {
let result = fetch_latest_version();
assert!(result.is_ok());
let version = result.unwrap();
assert!(
version.split('.').count() >= 2,
"version should be semver-like: {version}"
);
}
}