use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
use tracing::{debug, info};
use crate::config::GlobalConfig;
use crate::upgrade::SelfUpdater;
#[derive(Debug, Serialize, Deserialize)]
pub struct VersionCheckCache {
pub latest_version: String,
pub current_version: String,
pub checked_at: DateTime<Utc>,
pub update_available: bool,
pub notified: bool,
#[serde(default)]
pub notification_count: u32,
}
impl VersionCheckCache {
pub fn new(current_version: String, latest_version: String) -> Self {
let update_available = {
let current = semver::Version::parse(¤t_version).ok();
let latest = semver::Version::parse(&latest_version).ok();
match (current, latest) {
(Some(c), Some(l)) => l > c,
_ => false,
}
};
Self {
latest_version,
current_version,
checked_at: Utc::now(),
update_available,
notified: false,
notification_count: 0,
}
}
pub fn is_valid(&self, interval_seconds: u64) -> bool {
let age = Utc::now() - self.checked_at;
age.num_seconds() < interval_seconds as i64
}
pub const fn mark_notified(&mut self) {
self.notified = true;
self.notification_count += 1;
}
pub fn should_notify(&self) -> bool {
if !self.update_available {
return false;
}
if !self.notified {
return true;
}
let hours_since_check = (Utc::now() - self.checked_at).num_hours();
let backoff_hours = 24 * (1 << self.notification_count.min(3));
hours_since_check >= i64::from(backoff_hours)
}
}
pub struct VersionChecker {
cache_path: PathBuf,
updater: SelfUpdater,
config: GlobalConfig,
}
impl VersionChecker {
pub async fn new() -> Result<Self> {
let config = GlobalConfig::load().await?;
let updater = SelfUpdater::new();
let cache_path = if let Ok(path) = std::env::var("AGPM_CONFIG_PATH") {
PathBuf::from(path)
.parent()
.map_or_else(|| PathBuf::from(".agpm"), std::path::Path::to_path_buf)
.join(".version_cache")
} else {
dirs::home_dir()
.context("Could not determine home directory")?
.join(".agpm")
.join(".version_cache")
};
Ok(Self {
cache_path,
updater,
config,
})
}
pub fn with_cache_dir(mut self, cache_dir: PathBuf) -> Self {
self.cache_path = cache_dir.join(".version_cache");
self
}
pub async fn check_for_updates_if_needed(&self) -> Result<Option<String>> {
if !self.config.upgrade.check_on_startup && self.config.upgrade.check_interval == 0 {
debug!("Automatic update checking is disabled");
return Ok(None);
}
let mut cache = self.load_cache().await?;
let should_check = match &cache {
None => true,
Some(c) => !c.is_valid(self.config.upgrade.check_interval),
};
if should_check {
debug!("Performing automatic update check");
match self.updater.check_for_update().await {
Ok(Some(latest_version)) => {
let mut new_cache = VersionCheckCache::new(
self.updater.current_version().to_string(),
latest_version.clone(),
);
let should_notify = match &cache {
None => true,
Some(old) => {
old.latest_version != latest_version || !old.notified
}
};
if should_notify {
new_cache.mark_notified();
self.save_cache(&new_cache).await?;
info!(
"Update available: {} -> {}",
self.updater.current_version(),
latest_version
);
return Ok(Some(latest_version));
}
self.save_cache(&new_cache).await?;
}
Ok(None) => {
let new_cache = VersionCheckCache::new(
self.updater.current_version().to_string(),
self.updater.current_version().to_string(),
);
self.save_cache(&new_cache).await?;
debug!("No update available, cache updated");
}
Err(e) => {
debug!("Update check failed: {}", e);
}
}
} else if let Some(ref mut c) = cache {
if c.should_notify() {
c.mark_notified();
self.save_cache(c).await?;
info!("Update available (reminder): {} -> {}", c.current_version, c.latest_version);
return Ok(Some(c.latest_version.clone()));
}
}
Ok(None)
}
pub async fn check_now(&self) -> Result<Option<String>> {
debug!("Performing explicit update check");
let result = self.updater.check_for_update().await?;
let cache = VersionCheckCache::new(
self.updater.current_version().to_string(),
result.as_ref().unwrap_or(&self.updater.current_version().to_string()).to_string(),
);
self.save_cache(&cache).await?;
Ok(result)
}
async fn load_cache(&self) -> Result<Option<VersionCheckCache>> {
if !self.cache_path.exists() {
debug!("No version cache found");
return Ok(None);
}
let content =
fs::read_to_string(&self.cache_path).await.context("Failed to read version cache")?;
let cache: VersionCheckCache =
serde_json::from_str(&content).context("Failed to parse version cache")?;
Ok(Some(cache))
}
async fn save_cache(&self, cache: &VersionCheckCache) -> Result<()> {
let content =
serde_json::to_string_pretty(cache).context("Failed to serialize version cache")?;
if let Some(parent) = self.cache_path.parent() {
fs::create_dir_all(parent).await.context("Failed to create cache directory")?;
}
fs::write(&self.cache_path, content).await.context("Failed to write version cache")?;
debug!("Saved version check to cache");
Ok(())
}
pub async fn clear_cache(&self) -> Result<()> {
if self.cache_path.exists() {
fs::remove_file(&self.cache_path).await.context("Failed to remove version cache")?;
debug!("Cleared version cache");
}
Ok(())
}
pub fn display_update_notification(latest_version: &str) {
use colored::Colorize;
let current_version = env!("CARGO_PKG_VERSION");
eprintln!();
eprintln!("{}", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".bright_cyan());
eprintln!("{} A new version of AGPM is available!", "📦".bright_cyan());
eprintln!();
eprintln!(" Current version: {}", current_version.yellow());
eprintln!(" Latest version: {}", latest_version.green().bold());
eprintln!();
eprintln!(" Run {} to upgrade", "agpm upgrade".cyan().bold());
eprintln!();
eprintln!(" To disable automatic update checks, run:");
eprintln!(" {}", "agpm config set upgrade.check_interval 0".dimmed());
eprintln!("{}", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".bright_cyan());
eprintln!();
}
pub fn format_version_info(current: &str, latest: Option<&str>) -> String {
match latest {
Some(v) if v != current => {
format!("Current version: {current}\nLatest version: {v} (update available)")
}
_ => format!("Current version: {current} (up to date)"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_cache_validity() {
let cache = VersionCheckCache::new("1.0.0".to_string(), "1.1.0".to_string());
assert!(cache.is_valid(3600));
assert!(!cache.is_valid(0));
}
#[tokio::test]
async fn test_notification_logic() {
let mut cache = VersionCheckCache::new("1.0.0".to_string(), "1.1.0".to_string());
assert!(cache.should_notify());
cache.mark_notified();
assert!(!cache.should_notify());
assert_eq!(cache.notification_count, 1);
}
#[tokio::test]
async fn test_cache_save_load() -> Result<()> {
let temp_dir = TempDir::new()?;
unsafe {
std::env::set_var("AGPM_CONFIG_PATH", temp_dir.path().join("config.toml"));
}
let cache = VersionCheckCache::new("1.0.0".to_string(), "1.1.0".to_string());
let cache_path = temp_dir.path().join(".version_cache");
let content = serde_json::to_string_pretty(&cache)?;
tokio::fs::write(&cache_path, content).await.with_context(|| {
format!("Failed to write version cache file: {}", cache_path.display())
})?;
let loaded_content = tokio::fs::read_to_string(&cache_path).await.with_context(|| {
format!("Failed to read version cache file: {}", cache_path.display())
})?;
let loaded: VersionCheckCache = serde_json::from_str(&loaded_content)?;
assert_eq!(loaded.current_version, "1.0.0");
assert_eq!(loaded.latest_version, "1.1.0");
assert!(loaded.update_available);
unsafe {
std::env::remove_var("AGPM_CONFIG_PATH");
}
Ok(())
}
}