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