agpm_cli/upgrade/version_check.rs
1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use tokio::fs;
6use tracing::{debug, info};
7
8use crate::config::GlobalConfig;
9use crate::upgrade::SelfUpdater;
10
11/// Cached version information with notification tracking.
12///
13/// This structure stores version check results along with timestamps
14/// and notification state to provide intelligent update prompting.
15///
16/// # Fields
17///
18/// * `latest_version` - The latest version string from GitHub releases
19/// * `current_version` - The version that was running when cached
20/// * `checked_at` - UTC timestamp when this information was fetched
21/// * `update_available` - Whether an update was available at check time
22/// * `notified` - Whether the user has been notified about this update
23/// * `notification_count` - Number of times user has been notified
24///
25/// # Serialization
26///
27/// This struct is serialized to JSON for persistent caching between AGPM runs.
28#[derive(Debug, Serialize, Deserialize)]
29pub struct VersionCheckCache {
30 /// The latest version string from GitHub releases (e.g., "0.4.0").
31 pub latest_version: String,
32 /// The version that was running when this cache was created.
33 pub current_version: String,
34 /// UTC timestamp when this version information was fetched.
35 pub checked_at: DateTime<Utc>,
36 /// Whether an update was available at the time of check.
37 pub update_available: bool,
38 /// Whether the user has been notified about this specific update.
39 pub notified: bool,
40 /// Number of times the user has been notified about this update.
41 #[serde(default)]
42 pub notification_count: u32,
43}
44
45impl VersionCheckCache {
46 /// Create a new cache entry from version information.
47 pub fn new(current_version: String, latest_version: String) -> Self {
48 let update_available = {
49 let current = semver::Version::parse(¤t_version).ok();
50 let latest = semver::Version::parse(&latest_version).ok();
51
52 match (current, latest) {
53 (Some(c), Some(l)) => l > c,
54 _ => false,
55 }
56 };
57
58 Self {
59 latest_version,
60 current_version,
61 checked_at: Utc::now(),
62 update_available,
63 notified: false,
64 notification_count: 0,
65 }
66 }
67
68 /// Check if the cache is still valid based on the given interval.
69 pub fn is_valid(&self, interval_seconds: u64) -> bool {
70 let age = Utc::now() - self.checked_at;
71 age.num_seconds() < interval_seconds as i64
72 }
73
74 /// Mark this update as notified and increment the count.
75 pub const fn mark_notified(&mut self) {
76 self.notified = true;
77 self.notification_count += 1;
78 }
79
80 /// Check if we should notify about this update.
81 ///
82 /// Implements a backoff strategy to avoid notification fatigue:
83 /// - First notification: immediate
84 /// - Subsequent notifications: with increasing intervals
85 pub fn should_notify(&self) -> bool {
86 if !self.update_available {
87 return false;
88 }
89
90 if !self.notified {
91 return true;
92 }
93
94 // Implement exponential backoff for re-notifications
95 // Don't re-notify more than once per day after initial notification
96 let hours_since_check = (Utc::now() - self.checked_at).num_hours();
97 let backoff_hours = 24 * (1 << self.notification_count.min(3)); // 24h, 48h, 96h, 192h max
98
99 hours_since_check >= i64::from(backoff_hours)
100 }
101}
102
103/// Version checking and caching system with automatic update notifications.
104///
105/// `VersionChecker` provides intelligent caching of version information and
106/// automatic update checking based on user configuration. It manages notification
107/// state to avoid alert fatigue while ensuring users are aware of updates.
108///
109/// # Features
110///
111/// - **Automatic Checking**: Checks for updates based on configured intervals
112/// - **Smart Caching**: Reduces GitHub API calls with intelligent cache management
113/// - **Notification Tracking**: Avoids repeated notifications for the same update
114/// - **Configurable Behavior**: Respects user preferences for update checking
115///
116/// # Caching Strategy
117///
118/// The version checker implements a sophisticated caching strategy:
119/// - Stores version information with timestamps
120/// - Tracks notification state to avoid alert fatigue
121/// - Uses configurable intervals for cache expiration
122/// - Implements exponential backoff for re-notifications
123pub struct VersionChecker {
124 /// Path to the version cache file.
125 cache_path: PathBuf,
126 /// The self-updater instance for version checking.
127 updater: SelfUpdater,
128 /// Global configuration with upgrade settings.
129 config: GlobalConfig,
130}
131
132impl VersionChecker {
133 /// Create a new `VersionChecker` with configuration from global settings.
134 ///
135 /// Loads the global configuration and sets up the version checker with
136 /// appropriate cache paths and update settings.
137 ///
138 /// # Returns
139 ///
140 /// - `Ok(VersionChecker)` - Successfully created with loaded configuration
141 /// - `Err(error)` - Failed to load configuration or determine cache path
142 ///
143 /// # Cache Location
144 ///
145 /// The cache file is stored at:
146 /// - Unix/macOS: `~/.agpm/.version_cache`
147 /// - Windows: `%LOCALAPPDATA%\agpm\.version_cache`
148 pub async fn new() -> Result<Self> {
149 let config = GlobalConfig::load().await?;
150 let updater = SelfUpdater::new();
151
152 // Determine cache path based on configuration directory
153 let cache_path = if let Ok(path) = std::env::var("AGPM_CONFIG_PATH") {
154 PathBuf::from(path)
155 .parent()
156 .map_or_else(|| PathBuf::from(".agpm"), std::path::Path::to_path_buf)
157 .join(".version_cache")
158 } else {
159 dirs::home_dir()
160 .context("Could not determine home directory")?
161 .join(".agpm")
162 .join(".version_cache")
163 };
164
165 Ok(Self {
166 cache_path,
167 updater,
168 config,
169 })
170 }
171
172 /// Create a new `VersionChecker` with custom cache directory.
173 ///
174 /// # Arguments
175 ///
176 /// * `cache_dir` - Directory where the version cache file will be stored
177 pub fn with_cache_dir(mut self, cache_dir: PathBuf) -> Self {
178 self.cache_path = cache_dir.join(".version_cache");
179 self
180 }
181
182 /// Check for updates automatically based on configuration.
183 ///
184 /// This is the main entry point for automatic update checking. It:
185 /// 1. Checks if automatic updates are enabled in configuration
186 /// 2. Loads and validates the cache
187 /// 3. Performs a new check if cache is expired
188 /// 4. Returns version info if user should be notified
189 ///
190 /// # Returns
191 ///
192 /// - `Ok(Some(version))` - Update available and user should be notified
193 /// - `Ok(None)` - No update or notification not needed
194 /// - `Err(error)` - Error during check (non-fatal, logged)
195 pub async fn check_for_updates_if_needed(&self) -> Result<Option<String>> {
196 // Check if automatic checking is disabled
197 if !self.config.upgrade.check_on_startup && self.config.upgrade.check_interval == 0 {
198 debug!("Automatic update checking is disabled");
199 return Ok(None);
200 }
201
202 // Load existing cache
203 let mut cache = self.load_cache().await?;
204
205 // Determine if we need a new check
206 let should_check = match &cache {
207 None => true,
208 Some(c) => !c.is_valid(self.config.upgrade.check_interval),
209 };
210
211 if should_check {
212 debug!("Performing automatic update check");
213
214 // Perform the check
215 match self.updater.check_for_update().await {
216 Ok(Some(latest_version)) => {
217 // Create new cache entry
218 let mut new_cache = VersionCheckCache::new(
219 self.updater.current_version().to_string(),
220 latest_version.clone(),
221 );
222
223 // Check if we should notify
224 let should_notify = match &cache {
225 None => true,
226 Some(old) => {
227 // New update available or not notified about current one
228 old.latest_version != latest_version || !old.notified
229 }
230 };
231
232 if should_notify {
233 new_cache.mark_notified();
234 self.save_cache(&new_cache).await?;
235
236 info!(
237 "Update available: {} -> {}",
238 self.updater.current_version(),
239 latest_version
240 );
241 return Ok(Some(latest_version));
242 }
243 // Update cache without notification
244 self.save_cache(&new_cache).await?;
245 }
246 Ok(None) => {
247 // No update available, update cache
248 let new_cache = VersionCheckCache::new(
249 self.updater.current_version().to_string(),
250 self.updater.current_version().to_string(),
251 );
252 self.save_cache(&new_cache).await?;
253 debug!("No update available, cache updated");
254 }
255 Err(e) => {
256 // Don't fail the command if update check fails
257 debug!("Update check failed: {}", e);
258 }
259 }
260 } else if let Some(ref mut c) = cache {
261 // Cache is still valid, check if we should re-notify
262 if c.should_notify() {
263 c.mark_notified();
264 self.save_cache(c).await?;
265
266 info!("Update available (reminder): {} -> {}", c.current_version, c.latest_version);
267 return Ok(Some(c.latest_version.clone()));
268 }
269 }
270
271 Ok(None)
272 }
273
274 /// Perform an explicit update check, bypassing the cache.
275 ///
276 /// This method always queries GitHub for the latest version,
277 /// regardless of cache state. Used for manual update checks.
278 ///
279 /// # Returns
280 ///
281 /// - `Ok(Some(version))` - New version available
282 /// - `Ok(None)` - Already on latest version
283 /// - `Err(error)` - Check failed
284 pub async fn check_now(&self) -> Result<Option<String>> {
285 debug!("Performing explicit update check");
286
287 let result = self.updater.check_for_update().await?;
288
289 // Update cache with the result
290 let cache = VersionCheckCache::new(
291 self.updater.current_version().to_string(),
292 result.as_ref().unwrap_or(&self.updater.current_version().to_string()).to_string(),
293 );
294 self.save_cache(&cache).await?;
295
296 Ok(result)
297 }
298
299 /// Load the version cache from disk.
300 async fn load_cache(&self) -> Result<Option<VersionCheckCache>> {
301 if !self.cache_path.exists() {
302 debug!("No version cache found");
303 return Ok(None);
304 }
305
306 let content =
307 fs::read_to_string(&self.cache_path).await.context("Failed to read version cache")?;
308
309 let cache: VersionCheckCache =
310 serde_json::from_str(&content).context("Failed to parse version cache")?;
311
312 Ok(Some(cache))
313 }
314
315 /// Save the version cache to disk.
316 async fn save_cache(&self, cache: &VersionCheckCache) -> Result<()> {
317 let content =
318 serde_json::to_string_pretty(cache).context("Failed to serialize version cache")?;
319
320 // Ensure cache directory exists
321 if let Some(parent) = self.cache_path.parent() {
322 fs::create_dir_all(parent).await.context("Failed to create cache directory")?;
323 }
324
325 fs::write(&self.cache_path, content).await.context("Failed to write version cache")?;
326
327 debug!("Saved version check to cache");
328 Ok(())
329 }
330
331 /// Clear the version cache by removing the cache file.
332 ///
333 /// Removes cached version information, forcing subsequent version
334 /// checks to fetch fresh data from GitHub.
335 pub async fn clear_cache(&self) -> Result<()> {
336 if self.cache_path.exists() {
337 fs::remove_file(&self.cache_path).await.context("Failed to remove version cache")?;
338 debug!("Cleared version cache");
339 }
340 Ok(())
341 }
342
343 /// Display a user-friendly update notification.
344 ///
345 /// Shows an attractive notification banner informing the user
346 /// about the available update with instructions on how to upgrade.
347 ///
348 /// # Arguments
349 ///
350 /// * `latest_version` - The new version available for upgrade
351 pub fn display_update_notification(latest_version: &str) {
352 use colored::Colorize;
353
354 let current_version = env!("CARGO_PKG_VERSION");
355
356 eprintln!();
357 eprintln!("{}", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".bright_cyan());
358 eprintln!("{} A new version of AGPM is available!", "📦".bright_cyan());
359 eprintln!();
360 eprintln!(" Current version: {}", current_version.yellow());
361 eprintln!(" Latest version: {}", latest_version.green().bold());
362 eprintln!();
363 eprintln!(" Run {} to upgrade", "agpm upgrade".cyan().bold());
364 eprintln!();
365 eprintln!(" To disable automatic update checks, run:");
366 eprintln!(" {}", "agpm config set upgrade.check_interval 0".dimmed());
367 eprintln!("{}", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".bright_cyan());
368 eprintln!();
369 }
370
371 /// Format version information for status display.
372 ///
373 /// Creates a human-readable string showing the current version and,
374 /// if available, the latest version with update availability.
375 pub fn format_version_info(current: &str, latest: Option<&str>) -> String {
376 match latest {
377 Some(v) if v != current => {
378 format!("Current version: {current}\nLatest version: {v} (update available)")
379 }
380 _ => format!("Current version: {current} (up to date)"),
381 }
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use tempfile::TempDir;
389
390 #[tokio::test]
391 async fn test_cache_validity() {
392 let cache = VersionCheckCache::new("1.0.0".to_string(), "1.1.0".to_string());
393
394 // Should be valid for 1 hour
395 assert!(cache.is_valid(3600));
396
397 // Should not be valid for 0 seconds
398 assert!(!cache.is_valid(0));
399 }
400
401 #[tokio::test]
402 async fn test_notification_logic() {
403 let mut cache = VersionCheckCache::new("1.0.0".to_string(), "1.1.0".to_string());
404
405 // First time should notify
406 assert!(cache.should_notify());
407
408 // After marking as notified, shouldn't notify immediately
409 cache.mark_notified();
410 assert!(!cache.should_notify());
411
412 // Notification count should increase
413 assert_eq!(cache.notification_count, 1);
414 }
415
416 #[tokio::test]
417 async fn test_cache_save_load() -> Result<()> {
418 let temp_dir = TempDir::new()?;
419 unsafe {
420 std::env::set_var("AGPM_CONFIG_PATH", temp_dir.path().join("config.toml"));
421 }
422
423 // Can't test the full VersionChecker without mocking, but can test cache directly
424 let cache = VersionCheckCache::new("1.0.0".to_string(), "1.1.0".to_string());
425
426 let cache_path = temp_dir.path().join(".version_cache");
427 let content = serde_json::to_string_pretty(&cache)?;
428 tokio::fs::write(&cache_path, content).await.with_context(|| {
429 format!("Failed to write version cache file: {}", cache_path.display())
430 })?;
431
432 let loaded_content = tokio::fs::read_to_string(&cache_path).await.with_context(|| {
433 format!("Failed to read version cache file: {}", cache_path.display())
434 })?;
435 let loaded: VersionCheckCache = serde_json::from_str(&loaded_content)?;
436
437 assert_eq!(loaded.current_version, "1.0.0");
438 assert_eq!(loaded.latest_version, "1.1.0");
439 assert!(loaded.update_available);
440
441 unsafe {
442 std::env::remove_var("AGPM_CONFIG_PATH");
443 }
444 Ok(())
445 }
446}