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(&current_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}