Skip to main content

par_term_update/
update_checker.rs

1//! Automatic update checking for par-term.
2//!
3//! This module handles checking GitHub releases for new versions of par-term.
4//! It respects the configured check frequency (daily, weekly, monthly, or never)
5//! and can notify users when updates are available.
6
7use chrono::{DateTime, Utc};
8use par_term_config::{Config, UpdateCheckFrequency};
9use parking_lot::Mutex;
10use semver::Version;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::{Duration, Instant};
14
15/// Repository for update checks
16const REPO: &str = "paulrobello/par-term";
17
18/// GitHub API URL for latest release
19const RELEASE_API_URL: &str = "https://api.github.com/repos/paulrobello/par-term/releases/latest";
20
21/// Information about an available update
22#[derive(Debug, Clone)]
23pub struct UpdateInfo {
24    /// The new version available
25    pub version: String,
26    /// Release notes/body from GitHub
27    pub release_notes: Option<String>,
28    /// URL to the release page
29    pub release_url: String,
30    /// When the release was published
31    pub published_at: Option<String>,
32}
33
34/// Result of an update check
35#[derive(Debug, Clone)]
36pub enum UpdateCheckResult {
37    /// No update available - current version is latest
38    UpToDate,
39    /// A new version is available
40    UpdateAvailable(UpdateInfo),
41    /// Update check is disabled
42    Disabled,
43    /// Check was skipped (not enough time since last check)
44    Skipped,
45    /// Error occurred during check
46    Error(String),
47}
48
49/// Manages update checking with periodic checks while running
50pub struct UpdateChecker {
51    /// Current application version (set by main crate to avoid subcrate version mismatch)
52    current_version: &'static str,
53    /// Last check result (shared for UI access)
54    last_result: Arc<Mutex<Option<UpdateCheckResult>>>,
55    /// Whether a check is currently in progress
56    check_in_progress: Arc<AtomicBool>,
57    /// Time of last check attempt (for rate limiting)
58    last_check_time: Arc<Mutex<Option<Instant>>>,
59    /// Minimum time between checks (prevents hammering the API)
60    min_check_interval: Duration,
61}
62
63impl UpdateChecker {
64    /// Create a new update checker with the application version from the main crate.
65    ///
66    /// Pass `env!("CARGO_PKG_VERSION")` from the binary crate so the version
67    /// resolves to the app version rather than this subcrate's version.
68    pub fn new(current_version: &'static str) -> Self {
69        Self {
70            current_version,
71            last_result: Arc::new(Mutex::new(None)),
72            check_in_progress: Arc::new(AtomicBool::new(false)),
73            last_check_time: Arc::new(Mutex::new(None)),
74            // Don't check more than once per hour even if forced
75            min_check_interval: Duration::from_secs(3600),
76        }
77    }
78
79    /// Get the last check result
80    pub fn last_result(&self) -> Option<UpdateCheckResult> {
81        self.last_result.lock().clone()
82    }
83
84    /// Check if it's time to perform an update check based on config
85    pub fn should_check(&self, config: &Config) -> bool {
86        // Never check if disabled
87        if config.updates.update_check_frequency == UpdateCheckFrequency::Never {
88            return false;
89        }
90
91        // Get duration since last check based on config
92        let Some(check_interval_secs) = config.updates.update_check_frequency.as_seconds() else {
93            return false;
94        };
95
96        // Check if we have a last check timestamp
97        let Some(ref last_check_str) = config.updates.last_update_check else {
98            // Never checked before, should check
99            return true;
100        };
101
102        // Parse the timestamp
103        let Ok(last_check) = DateTime::parse_from_rfc3339(last_check_str) else {
104            // Invalid timestamp, should check
105            return true;
106        };
107
108        // Check if enough time has passed
109        let now = Utc::now();
110        let elapsed = now.signed_duration_since(last_check.with_timezone(&Utc));
111        let elapsed_secs = elapsed.num_seconds();
112
113        elapsed_secs >= check_interval_secs as i64
114    }
115
116    /// Check if we're rate-limited (prevent hammering the API)
117    fn is_rate_limited(&self) -> bool {
118        let last_time = self.last_check_time.lock();
119        if let Some(last) = *last_time {
120            return last.elapsed() < self.min_check_interval;
121        }
122        false
123    }
124
125    /// Perform an update check (blocking)
126    ///
127    /// Returns the check result and whether the config should be updated
128    /// (to save the new last_update_check timestamp).
129    pub fn check_now(&self, config: &Config, force: bool) -> (UpdateCheckResult, bool) {
130        // Check if disabled
131        if config.updates.update_check_frequency == UpdateCheckFrequency::Never && !force {
132            return (UpdateCheckResult::Disabled, false);
133        }
134
135        // Check if we should skip based on timing
136        if !force && !self.should_check(config) {
137            return (UpdateCheckResult::Skipped, false);
138        }
139
140        // Check rate limiting (even for forced checks)
141        if !force && self.is_rate_limited() {
142            return (UpdateCheckResult::Skipped, false);
143        }
144
145        // Prevent concurrent checks
146        if self
147            .check_in_progress
148            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
149            .is_err()
150        {
151            return (UpdateCheckResult::Skipped, false);
152        }
153
154        // Update last check time
155        *self.last_check_time.lock() = Some(Instant::now());
156
157        // Perform the actual check
158        let result = self.perform_check(config);
159
160        // Store result
161        *self.last_result.lock() = Some(result.clone());
162
163        // Release the check lock
164        self.check_in_progress.store(false, Ordering::SeqCst);
165
166        // Should save config if check was successful (to update timestamp)
167        let should_save = !matches!(result, UpdateCheckResult::Error(_));
168
169        (result, should_save)
170    }
171
172    /// Perform the actual HTTP request and version comparison
173    fn perform_check(&self, config: &Config) -> UpdateCheckResult {
174        // Get current version (set by main crate, not this subcrate's CARGO_PKG_VERSION)
175        let current_version_str = self.current_version;
176        let current_version = match Version::parse(current_version_str) {
177            Ok(v) => v,
178            Err(e) => {
179                return UpdateCheckResult::Error(format!(
180                    "Failed to parse current version '{}': {}",
181                    current_version_str, e
182                ));
183            }
184        };
185
186        // Fetch latest release info from GitHub
187        let release_info = match fetch_latest_release() {
188            Ok(info) => info,
189            Err(e) => return UpdateCheckResult::Error(e),
190        };
191
192        // Parse the release version (strip leading 'v' if present)
193        let version_str = release_info
194            .version
195            .strip_prefix('v')
196            .unwrap_or(&release_info.version);
197        let latest_version = match Version::parse(version_str) {
198            Ok(v) => v,
199            Err(e) => {
200                return UpdateCheckResult::Error(format!(
201                    "Failed to parse latest version '{}': {}",
202                    release_info.version, e
203                ));
204            }
205        };
206
207        // Compare versions
208        if latest_version > current_version {
209            // Check if user skipped this version
210            if let Some(ref skipped) = config.updates.skipped_version
211                && (skipped == version_str || skipped == &release_info.version)
212            {
213                return UpdateCheckResult::UpToDate;
214            }
215
216            UpdateCheckResult::UpdateAvailable(release_info)
217        } else {
218            UpdateCheckResult::UpToDate
219        }
220    }
221}
222
223/// Fetch the latest release information from GitHub API
224pub fn fetch_latest_release() -> Result<UpdateInfo, String> {
225    // Validate at call time so any future change to RELEASE_API_URL is caught.
226    crate::http::validate_update_url(RELEASE_API_URL)?;
227
228    let mut body = crate::http::agent()
229        .get(RELEASE_API_URL)
230        .header("User-Agent", "par-term")
231        .header("Accept", "application/vnd.github+json")
232        .call()
233        .map_err(|e| {
234            format!(
235                "Failed to fetch latest release info from GitHub: {}. \
236                 Check your internet connection. \
237                 You can view the latest release at: https://github.com/{}/releases/latest",
238                e, REPO
239            )
240        })?
241        .into_body();
242
243    let body_str = body
244        .with_config()
245        .limit(crate::http::MAX_API_RESPONSE_SIZE)
246        .read_to_string()
247        .map_err(|e| format!("Failed to read response body: {}", e))?;
248
249    // Parse JSON properly using serde_json
250    let json: serde_json::Value =
251        serde_json::from_str(&body_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
252
253    let version = json
254        .get("tag_name")
255        .and_then(|v| v.as_str())
256        .map(|s| s.to_string())
257        .ok_or_else(|| "Could not find tag_name in release response".to_string())?;
258
259    let release_url = json
260        .get("html_url")
261        .and_then(|v| v.as_str())
262        .map(|s| s.to_string())
263        .unwrap_or_else(|| format!("https://github.com/{}/releases/latest", REPO));
264
265    let release_notes = json
266        .get("body")
267        .and_then(|v| v.as_str())
268        .map(|s| s.to_string());
269
270    let published_at = json
271        .get("published_at")
272        .and_then(|v| v.as_str())
273        .map(|s| s.to_string());
274
275    Ok(UpdateInfo {
276        version,
277        release_notes,
278        release_url,
279        published_at,
280    })
281}
282
283/// Get the current timestamp in ISO 8601 format
284pub fn current_timestamp() -> String {
285    Utc::now().to_rfc3339()
286}
287
288/// Format a timestamp for display
289pub fn format_timestamp(timestamp: &str) -> String {
290    match DateTime::parse_from_rfc3339(timestamp) {
291        Ok(dt) => dt.format("%Y-%m-%d %H:%M").to_string(),
292        Err(_) => timestamp.to_string(),
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_version_comparison() {
302        let v1 = Version::parse("0.5.0").unwrap();
303        let v2 = Version::parse("0.6.0").unwrap();
304        assert!(v2 > v1);
305
306        let v3 = Version::parse("1.0.0").unwrap();
307        assert!(v3 > v2);
308    }
309
310    #[test]
311    fn test_json_parsing_with_serde() {
312        // Test that serde_json correctly parses GitHub release JSON
313        let json_str = r#"{"tag_name":"v0.6.0","html_url":"https://example.com","body":"Release notes","published_at":"2024-01-01T00:00:00Z"}"#;
314        let json: serde_json::Value = serde_json::from_str(json_str).unwrap();
315
316        assert_eq!(
317            json.get("tag_name").and_then(|v| v.as_str()),
318            Some("v0.6.0")
319        );
320        assert_eq!(
321            json.get("html_url").and_then(|v| v.as_str()),
322            Some("https://example.com")
323        );
324        assert_eq!(json.get("missing").and_then(|v| v.as_str()), None);
325    }
326
327    #[test]
328    fn test_json_parsing_with_escapes() {
329        // Test that serde_json correctly handles escaped characters
330        let json_str = r#"{"body":"Line 1\nLine 2\tTabbed"}"#;
331        let json: serde_json::Value = serde_json::from_str(json_str).unwrap();
332
333        assert_eq!(
334            json.get("body").and_then(|v| v.as_str()),
335            Some("Line 1\nLine 2\tTabbed")
336        );
337    }
338
339    #[test]
340    fn test_update_check_frequency_seconds() {
341        assert_eq!(UpdateCheckFrequency::Never.as_seconds(), None);
342        assert_eq!(UpdateCheckFrequency::Hourly.as_seconds(), Some(3600));
343        assert_eq!(UpdateCheckFrequency::Daily.as_seconds(), Some(86400));
344        assert_eq!(UpdateCheckFrequency::Weekly.as_seconds(), Some(604800));
345        assert_eq!(UpdateCheckFrequency::Monthly.as_seconds(), Some(2592000));
346    }
347
348    #[test]
349    fn test_should_check_never() {
350        let checker = UpdateChecker::new("0.0.0");
351        let mut config = Config::default();
352        config.updates.update_check_frequency = UpdateCheckFrequency::Never;
353        assert!(!checker.should_check(&config));
354    }
355
356    #[test]
357    fn test_should_check_no_previous() {
358        let checker = UpdateChecker::new("0.0.0");
359        let mut config = Config::default();
360        config.updates.update_check_frequency = UpdateCheckFrequency::Weekly;
361        config.updates.last_update_check = None;
362        assert!(checker.should_check(&config));
363    }
364
365    #[test]
366    fn test_should_check_time_elapsed() {
367        let checker = UpdateChecker::new("0.0.0");
368        let mut config = Config::default();
369        config.updates.update_check_frequency = UpdateCheckFrequency::Daily;
370
371        // Set last check to 2 days ago
372        let two_days_ago = Utc::now() - chrono::Duration::days(2);
373        config.updates.last_update_check = Some(two_days_ago.to_rfc3339());
374        assert!(checker.should_check(&config));
375
376        // Set last check to 1 hour ago
377        let one_hour_ago = Utc::now() - chrono::Duration::hours(1);
378        config.updates.last_update_check = Some(one_hour_ago.to_rfc3339());
379        assert!(!checker.should_check(&config));
380    }
381
382    #[test]
383    fn test_current_timestamp_format() {
384        let ts = current_timestamp();
385        // Should be parseable as RFC 3339
386        assert!(DateTime::parse_from_rfc3339(&ts).is_ok());
387    }
388}