Skip to main content

fresh/services/
release_checker.rs

1//! Release checker module for checking if a new version is available.
2//!
3//! This module provides functionality to:
4//! - Check for new releases by fetching a GitHub releases API endpoint
5//! - Detect the installation method (Homebrew, npm, cargo, etc.) based on executable path
6//! - Provide appropriate update commands based on installation method
7//! - Periodic update checking with automatic re-spawn daily
8
9use super::time_source::SharedTimeSource;
10use std::env;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::mpsc::{self, Receiver, TryRecvError};
14use std::sync::Arc;
15use std::thread::{self, JoinHandle};
16use std::time::{Duration, Instant};
17
18/// The current version of the editor
19pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
20
21/// Default GitHub releases API URL for the fresh editor
22pub const DEFAULT_RELEASES_URL: &str = "https://api.github.com/repos/sinelaw/fresh/releases/latest";
23
24/// Installation method detection result
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum InstallMethod {
27    /// Installed via Homebrew
28    Homebrew,
29    /// Installed via cargo
30    Cargo,
31    /// Installed via npm
32    Npm,
33    /// Installed via a Linux package manager (apt, dnf, etc.)
34    PackageManager,
35    /// Installed via AUR (Arch User Repository)
36    Aur,
37    /// Unknown installation method or manually installed
38    Unknown,
39}
40
41impl InstallMethod {
42    /// Get the update command for this installation method
43    pub fn update_command(&self) -> Option<&'static str> {
44        Some(match self {
45            Self::Homebrew => " brew upgrade fresh-editor",
46            Self::Cargo => "cargo install fresh-editor",
47            Self::Npm => "npm update -g @fresh-editor/fresh-editor",
48            Self::Aur => "yay -Syu fresh-editor  # or use your AUR helper",
49            Self::PackageManager => "Update using your system package manager",
50            Self::Unknown => return None,
51        })
52    }
53}
54
55/// Result of checking for a new release
56#[derive(Debug, Clone)]
57pub struct ReleaseCheckResult {
58    /// The latest version available
59    pub latest_version: String,
60    /// Whether an update is available
61    pub update_available: bool,
62    /// The detected installation method
63    pub install_method: InstallMethod,
64}
65
66/// Handle to a background update check (one-shot)
67///
68/// Use `try_get_result` to check if the result is ready without blocking.
69pub struct UpdateCheckHandle {
70    receiver: Receiver<Result<ReleaseCheckResult, String>>,
71    #[allow(dead_code)]
72    thread: JoinHandle<()>,
73}
74
75impl UpdateCheckHandle {
76    /// Try to get the result without blocking.
77    /// Returns Some(result) if the check completed, None if still running.
78    /// If still running, the background thread is abandoned (will be killed on process exit).
79    pub fn try_get_result(self) -> Option<Result<ReleaseCheckResult, String>> {
80        match self.receiver.try_recv() {
81            Ok(result) => {
82                tracing::debug!("Update check completed");
83                Some(result)
84            }
85            Err(TryRecvError::Empty) => {
86                // Still running - abandon the thread
87                tracing::debug!("Update check still running, abandoning");
88                drop(self.thread);
89                None
90            }
91            Err(TryRecvError::Disconnected) => {
92                // Thread panicked or exited without sending
93                tracing::debug!("Update check thread disconnected");
94                None
95            }
96        }
97    }
98}
99
100/// Default check interval for periodic update checking (24 hours)
101pub const DEFAULT_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
102
103/// Handle to a periodic update checker that runs in the background.
104///
105/// The checker runs daily and provides results via `poll_result()`.
106/// When a check finds an update, the result is stored until retrieved.
107pub struct PeriodicUpdateChecker {
108    /// Receiver for update check results
109    receiver: Receiver<Result<ReleaseCheckResult, String>>,
110    /// Signal to stop the background thread
111    stop_signal: Arc<AtomicBool>,
112    /// Background thread handle
113    #[allow(dead_code)]
114    thread: JoinHandle<()>,
115    /// Last successful result (cached)
116    last_result: Option<ReleaseCheckResult>,
117    /// Time of last check (for tracking)
118    last_check_time: Option<Instant>,
119}
120
121impl PeriodicUpdateChecker {
122    /// Poll for a new update check result without blocking.
123    ///
124    /// Returns `Some(result)` if a new check completed, `None` if no new result.
125    /// Successful results are cached and can be retrieved via `get_cached_result()`.
126    pub fn poll_result(&mut self) -> Option<Result<ReleaseCheckResult, String>> {
127        match self.receiver.try_recv() {
128            Ok(result) => {
129                self.last_check_time = Some(Instant::now());
130                if let Ok(ref release_result) = result {
131                    tracing::debug!(
132                        "Periodic update check completed: update_available={}",
133                        release_result.update_available
134                    );
135                    self.last_result = Some(release_result.clone());
136                }
137                Some(result)
138            }
139            Err(TryRecvError::Empty) => None,
140            Err(TryRecvError::Disconnected) => {
141                tracing::debug!("Periodic update checker thread disconnected");
142                None
143            }
144        }
145    }
146
147    /// Get the cached result from the last successful check.
148    pub fn get_cached_result(&self) -> Option<&ReleaseCheckResult> {
149        self.last_result.as_ref()
150    }
151
152    /// Check if an update is available (from cached result).
153    pub fn is_update_available(&self) -> bool {
154        self.last_result
155            .as_ref()
156            .map(|r| r.update_available)
157            .unwrap_or(false)
158    }
159
160    /// Get the latest version string if an update is available.
161    pub fn latest_version(&self) -> Option<&str> {
162        self.last_result.as_ref().and_then(|r| {
163            if r.update_available {
164                Some(r.latest_version.as_str())
165            } else {
166                None
167            }
168        })
169    }
170}
171
172impl Drop for PeriodicUpdateChecker {
173    fn drop(&mut self) {
174        // Signal the background thread to stop
175        self.stop_signal.store(true, Ordering::SeqCst);
176    }
177}
178
179/// Start a periodic update checker that runs daily.
180///
181/// The checker immediately runs the first check, then repeats daily.
182/// Results are available via `poll_result()` on the returned handle.
183pub fn start_periodic_update_check(
184    releases_url: &str,
185    time_source: SharedTimeSource,
186    data_dir: PathBuf,
187) -> PeriodicUpdateChecker {
188    start_periodic_update_check_with_interval(
189        releases_url,
190        DEFAULT_UPDATE_CHECK_INTERVAL,
191        time_source,
192        data_dir,
193    )
194}
195
196/// Start a periodic update checker with a custom check interval.
197///
198/// This is primarily for testing - allows specifying a short interval to verify
199/// the periodic behavior without waiting for a day.
200///
201/// # Arguments
202/// * `releases_url` - The GitHub releases API URL to check
203/// * `check_interval` - Duration between checks
204/// * `time_source` - Time source for debouncing and sleep
205/// * `data_dir` - Data directory for storing the telemetry stamp file
206pub fn start_periodic_update_check_with_interval(
207    releases_url: &str,
208    check_interval: Duration,
209    time_source: SharedTimeSource,
210    data_dir: PathBuf,
211) -> PeriodicUpdateChecker {
212    tracing::debug!(
213        "Starting periodic update checker with interval {:?}",
214        check_interval
215    );
216    let url = releases_url.to_string();
217    let (tx, rx) = mpsc::channel();
218    let stop_signal = Arc::new(AtomicBool::new(false));
219    let stop_signal_clone = stop_signal.clone();
220
221    // Use a smaller sleep increment for shorter intervals
222    let sleep_increment = if check_interval < Duration::from_secs(10) {
223        Duration::from_millis(10)
224    } else {
225        Duration::from_secs(1)
226    };
227
228    let handle = thread::spawn(move || {
229        // Run initial check immediately, but only if not already done today (debounce)
230        if let Some(unique_id) =
231            super::telemetry::should_run_daily_check(time_source.as_ref(), &data_dir)
232        {
233            super::telemetry::track_open(&unique_id);
234            let result = check_for_update(&url);
235            if tx.send(result).is_err() {
236                return; // Receiver dropped, exit
237            }
238        }
239
240        // Then check periodically (debouncing will naturally pass after 24 hours)
241        loop {
242            // Sleep in small increments to allow quick shutdown
243            let sleep_end = time_source.now() + check_interval;
244            while time_source.now() < sleep_end {
245                if stop_signal_clone.load(Ordering::SeqCst) {
246                    tracing::debug!("Periodic update checker stopping");
247                    return;
248                }
249                time_source.sleep(sleep_increment);
250            }
251
252            // Check if we should stop before making a new request
253            if stop_signal_clone.load(Ordering::SeqCst) {
254                tracing::debug!("Periodic update checker stopping");
255                return;
256            }
257
258            tracing::debug!("Periodic update check starting");
259            // Debounce check - only proceed if a new day
260            if let Some(unique_id) =
261                super::telemetry::should_run_daily_check(time_source.as_ref(), &data_dir)
262            {
263                super::telemetry::track_open(&unique_id);
264                let result = check_for_update(&url);
265                if tx.send(result).is_err() {
266                    return; // Receiver dropped, exit
267                }
268            }
269        }
270    });
271
272    PeriodicUpdateChecker {
273        receiver: rx,
274        stop_signal,
275        thread: handle,
276        last_result: None,
277        last_check_time: None,
278    }
279}
280
281/// Start a background update check
282///
283/// Returns a handle that can be used to query the result later.
284/// The check runs in a background thread and won't block.
285/// Respects daily debouncing - if already checked today, no result will be sent.
286pub fn start_update_check(
287    releases_url: &str,
288    time_source: SharedTimeSource,
289    data_dir: PathBuf,
290) -> UpdateCheckHandle {
291    tracing::debug!("Starting background update check");
292    let url = releases_url.to_string();
293    let (tx, rx) = mpsc::channel();
294
295    let handle = thread::spawn(move || {
296        if let Some(unique_id) =
297            super::telemetry::should_run_daily_check(time_source.as_ref(), &data_dir)
298        {
299            super::telemetry::track_open(&unique_id);
300            let result = check_for_update(&url);
301            let _ = tx.send(result);
302        }
303    });
304
305    UpdateCheckHandle {
306        receiver: rx,
307        thread: handle,
308    }
309}
310
311/// Fetches release information from the provided URL.
312pub fn fetch_latest_version(url: &str) -> Result<String, String> {
313    tracing::debug!("Fetching latest version from {}", url);
314    let agent = ureq::Agent::config_builder()
315        .timeout_global(Some(Duration::from_secs(5)))
316        .build()
317        .new_agent();
318    let response = agent
319        .get(url)
320        .header("User-Agent", "fresh-editor-update-checker")
321        .header("Accept", "application/vnd.github.v3+json")
322        .call()
323        .map_err(|e| {
324            tracing::debug!("HTTP request failed: {}", e);
325            format!("HTTP request failed: {}", e)
326        })?;
327
328    let body = response
329        .into_body()
330        .read_to_string()
331        .map_err(|e| format!("Failed to read response body: {}", e))?;
332
333    let version = parse_version_from_json(&body)?;
334    tracing::debug!("Latest version: {}", version);
335    Ok(version)
336}
337
338/// Parse version from GitHub API JSON response
339fn parse_version_from_json(json: &str) -> Result<String, String> {
340    let tag_name_key = "\"tag_name\"";
341    let start = json
342        .find(tag_name_key)
343        .ok_or_else(|| "tag_name not found in response".to_string())?;
344
345    let after_key = &json[start + tag_name_key.len()..];
346
347    let value_start = after_key
348        .find('"')
349        .ok_or_else(|| "Invalid JSON: missing quote after tag_name".to_string())?;
350
351    let value_content = &after_key[value_start + 1..];
352    let value_end = value_content
353        .find('"')
354        .ok_or_else(|| "Invalid JSON: unclosed quote".to_string())?;
355
356    let tag = &value_content[..value_end];
357
358    // Strip 'v' prefix if present
359    Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
360}
361
362/// Detect the installation method based on the current executable path
363pub fn detect_install_method() -> InstallMethod {
364    match env::current_exe() {
365        Ok(path) => detect_install_method_from_path(&path),
366        Err(_) => InstallMethod::Unknown,
367    }
368}
369
370/// Detect installation method from a given executable path
371pub fn detect_install_method_from_path(exe_path: &Path) -> InstallMethod {
372    let path_str = exe_path.to_string_lossy();
373
374    // Check for Homebrew paths (macOS and Linux)
375    if path_str.contains("/opt/homebrew/")
376        || path_str.contains("/usr/local/Cellar/")
377        || path_str.contains("/home/linuxbrew/")
378        || path_str.contains("/.linuxbrew/")
379    {
380        return InstallMethod::Homebrew;
381    }
382
383    // Check for Cargo installation
384    if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
385        return InstallMethod::Cargo;
386    }
387
388    // Check for npm global installation
389    if path_str.contains("/node_modules/")
390        || path_str.contains("\\node_modules\\")
391        || path_str.contains("/npm/")
392        || path_str.contains("/lib/node_modules/")
393    {
394        return InstallMethod::Npm;
395    }
396
397    // Check for AUR installation (Arch Linux)
398    if path_str.starts_with("/usr/bin/") && is_arch_linux() {
399        return InstallMethod::Aur;
400    }
401
402    // Check for package manager installation (standard system paths)
403    if path_str.starts_with("/usr/bin/")
404        || path_str.starts_with("/usr/local/bin/")
405        || path_str.starts_with("/bin/")
406    {
407        return InstallMethod::PackageManager;
408    }
409
410    InstallMethod::Unknown
411}
412
413/// Check if we're running on Arch Linux
414fn is_arch_linux() -> bool {
415    std::fs::read_to_string("/etc/os-release")
416        .map(|content| content.contains("Arch Linux") || content.contains("ID=arch"))
417        .unwrap_or(false)
418}
419
420/// Compare two semantic versions
421/// Returns true if `latest` is newer than `current`
422pub fn is_newer_version(current: &str, latest: &str) -> bool {
423    let parse_version = |v: &str| -> Option<(u32, u32, u32)> {
424        let parts: Vec<&str> = v.split('.').collect();
425        if parts.len() >= 3 {
426            Some((
427                parts[0].parse().ok()?,
428                parts[1].parse().ok()?,
429                parts[2].split('-').next()?.parse().ok()?,
430            ))
431        } else if parts.len() == 2 {
432            Some((parts[0].parse().ok()?, parts[1].parse().ok()?, 0))
433        } else {
434            None
435        }
436    };
437
438    match (parse_version(current), parse_version(latest)) {
439        (Some((c_major, c_minor, c_patch)), Some((l_major, l_minor, l_patch))) => {
440            (l_major, l_minor, l_patch) > (c_major, c_minor, c_patch)
441        }
442        _ => false,
443    }
444}
445
446/// Check for a new release (blocking)
447pub fn check_for_update(releases_url: &str) -> Result<ReleaseCheckResult, String> {
448    let latest_version = fetch_latest_version(releases_url)?;
449    let install_method = detect_install_method();
450    let update_available = is_newer_version(CURRENT_VERSION, &latest_version);
451
452    tracing::debug!(
453        current = CURRENT_VERSION,
454        latest = %latest_version,
455        update_available,
456        install_method = ?install_method,
457        "Release check complete"
458    );
459
460    Ok(ReleaseCheckResult {
461        latest_version,
462        update_available,
463        install_method,
464    })
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use std::path::PathBuf;
471
472    #[test]
473    fn test_is_newer_version_major() {
474        assert!(is_newer_version("0.1.26", "1.0.0"));
475        assert!(is_newer_version("1.0.0", "2.0.0"));
476    }
477
478    #[test]
479    fn test_is_newer_version_minor() {
480        assert!(is_newer_version("0.1.26", "0.2.0"));
481        assert!(is_newer_version("0.1.26", "0.2.26"));
482    }
483
484    #[test]
485    fn test_is_newer_version_patch() {
486        assert!(is_newer_version("0.1.26", "0.1.27"));
487        assert!(is_newer_version("0.1.26", "0.1.100"));
488    }
489
490    #[test]
491    fn test_is_newer_version_same() {
492        assert!(!is_newer_version("0.1.26", "0.1.26"));
493    }
494
495    #[test]
496    fn test_is_newer_version_older() {
497        assert!(!is_newer_version("0.1.26", "0.1.25"));
498        assert!(!is_newer_version("0.2.0", "0.1.26"));
499        assert!(!is_newer_version("1.0.0", "0.1.26"));
500    }
501
502    #[test]
503    fn test_is_newer_version_with_v_prefix() {
504        assert!(is_newer_version("0.1.26", "0.1.27"));
505    }
506
507    #[test]
508    fn test_is_newer_version_with_prerelease() {
509        assert!(is_newer_version("0.1.26-alpha", "0.1.27"));
510        assert!(is_newer_version("0.1.26", "0.1.27-beta"));
511    }
512
513    #[test]
514    fn test_detect_install_method_homebrew_macos() {
515        let path = PathBuf::from("/opt/homebrew/Cellar/fresh/0.1.26/bin/fresh");
516        assert_eq!(
517            detect_install_method_from_path(&path),
518            InstallMethod::Homebrew
519        );
520    }
521
522    #[test]
523    fn test_detect_install_method_homebrew_intel_mac() {
524        let path = PathBuf::from("/usr/local/Cellar/fresh/0.1.26/bin/fresh");
525        assert_eq!(
526            detect_install_method_from_path(&path),
527            InstallMethod::Homebrew
528        );
529    }
530
531    #[test]
532    fn test_detect_install_method_homebrew_linux() {
533        let path = PathBuf::from("/home/linuxbrew/.linuxbrew/bin/fresh");
534        assert_eq!(
535            detect_install_method_from_path(&path),
536            InstallMethod::Homebrew
537        );
538    }
539
540    #[test]
541    fn test_detect_install_method_cargo() {
542        let path = PathBuf::from("/home/user/.cargo/bin/fresh");
543        assert_eq!(detect_install_method_from_path(&path), InstallMethod::Cargo);
544    }
545
546    #[test]
547    fn test_detect_install_method_cargo_windows() {
548        let path = PathBuf::from("C:\\Users\\user\\.cargo\\bin\\fresh.exe");
549        assert_eq!(detect_install_method_from_path(&path), InstallMethod::Cargo);
550    }
551
552    #[test]
553    fn test_detect_install_method_npm() {
554        let path = PathBuf::from("/usr/local/lib/node_modules/fresh-editor/bin/fresh");
555        assert_eq!(detect_install_method_from_path(&path), InstallMethod::Npm);
556    }
557
558    #[test]
559    fn test_detect_install_method_package_manager() {
560        let path = PathBuf::from("/usr/local/bin/fresh");
561        assert_eq!(
562            detect_install_method_from_path(&path),
563            InstallMethod::PackageManager
564        );
565    }
566
567    #[test]
568    fn test_detect_install_method_unknown() {
569        let path = PathBuf::from("/home/user/downloads/fresh");
570        assert_eq!(
571            detect_install_method_from_path(&path),
572            InstallMethod::Unknown
573        );
574    }
575
576    #[test]
577    fn test_parse_version_from_json() {
578        let json = r#"{"tag_name": "v0.1.27", "name": "Release 0.1.27"}"#;
579        assert_eq!(parse_version_from_json(json).unwrap(), "0.1.27");
580    }
581
582    #[test]
583    fn test_parse_version_from_json_no_v_prefix() {
584        let json = r#"{"tag_name": "0.1.27", "name": "Release 0.1.27"}"#;
585        assert_eq!(parse_version_from_json(json).unwrap(), "0.1.27");
586    }
587
588    #[test]
589    fn test_parse_version_from_json_full_response() {
590        let json = r#"{
591            "url": "https://api.github.com/repos/sinelaw/fresh/releases/12345",
592            "tag_name": "v0.2.0",
593            "target_commitish": "main",
594            "name": "v0.2.0",
595            "draft": false,
596            "prerelease": false
597        }"#;
598        assert_eq!(parse_version_from_json(json).unwrap(), "0.2.0");
599    }
600
601    #[test]
602    fn test_current_version_is_valid() {
603        let parts: Vec<&str> = CURRENT_VERSION.split('.').collect();
604        assert!(parts.len() >= 2, "Version should have at least major.minor");
605        assert!(
606            parts[0].parse::<u32>().is_ok(),
607            "Major version should be a number"
608        );
609        assert!(
610            parts[1].parse::<u32>().is_ok(),
611            "Minor version should be a number"
612        );
613    }
614
615    #[test]
616    fn test_version_parsing_with_mock_data() {
617        let json = r#"{"tag_name": "v99.0.0"}"#;
618        let version = parse_version_from_json(json).unwrap();
619        assert!(is_newer_version(CURRENT_VERSION, &version));
620    }
621
622    use std::sync::mpsc as std_mpsc;
623
624    /// Test helper: start a local HTTP server that returns a mock release JSON
625    /// Returns (stop_sender, url) - send to stop_sender to shut down the server
626    fn start_mock_release_server(version: &str) -> (std_mpsc::Sender<()>, String) {
627        let server = tiny_http::Server::http("127.0.0.1:0").expect("Failed to start test server");
628        let port = server.server_addr().to_ip().unwrap().port();
629        let url = format!("http://127.0.0.1:{}/releases/latest", port);
630
631        let (stop_tx, stop_rx) = std_mpsc::channel::<()>();
632
633        // Spawn a thread to handle requests
634        let version = version.to_string();
635        thread::spawn(move || {
636            loop {
637                // Check for stop signal
638                if stop_rx.try_recv().is_ok() {
639                    break;
640                }
641
642                // Non-blocking receive with timeout
643                match server.recv_timeout(Duration::from_millis(100)) {
644                    Ok(Some(request)) => {
645                        let response_body = format!(r#"{{"tag_name": "v{}"}}"#, version);
646                        let response = tiny_http::Response::from_string(response_body).with_header(
647                            tiny_http::Header::from_bytes(
648                                &b"Content-Type"[..],
649                                &b"application/json"[..],
650                            )
651                            .unwrap(),
652                        );
653                        let _ = request.respond(response);
654                    }
655                    Ok(None) => {
656                        // Timeout, continue loop
657                    }
658                    Err(_) => {
659                        // Server error, exit
660                        break;
661                    }
662                }
663            }
664        });
665
666        (stop_tx, url)
667    }
668
669    #[test]
670    fn test_periodic_update_checker_with_local_server() {
671        // Test that the periodic checker works with a real HTTP server
672        let (stop_tx, url) = start_mock_release_server("99.0.0");
673        let time_source = super::super::time_source::TestTimeSource::shared();
674        let temp_dir = tempfile::tempdir().unwrap();
675
676        let mut checker = start_periodic_update_check_with_interval(
677            &url,
678            Duration::from_millis(50),
679            time_source,
680            temp_dir.path().to_path_buf(),
681        );
682
683        // Wait for initial result
684        let start = Instant::now();
685        while start.elapsed() < Duration::from_secs(2) {
686            if checker.poll_result().is_some() {
687                break;
688            }
689            thread::sleep(Duration::from_millis(10));
690        }
691
692        // Verify cached result
693        assert!(
694            checker.is_update_available(),
695            "Should detect update available"
696        );
697        assert_eq!(checker.latest_version(), Some("99.0.0"));
698        assert!(checker.get_cached_result().is_some());
699
700        drop(checker);
701        let _ = stop_tx.send(());
702    }
703
704    #[test]
705    fn test_periodic_update_checker_shutdown_clean() {
706        // Test that the checker shuts down cleanly without hanging
707        let (stop_tx, url) = start_mock_release_server("99.0.0");
708        let time_source = super::super::time_source::TestTimeSource::shared();
709        let temp_dir = tempfile::tempdir().unwrap();
710
711        let checker = start_periodic_update_check_with_interval(
712            &url,
713            Duration::from_millis(50),
714            time_source,
715            temp_dir.path().to_path_buf(),
716        );
717
718        // Let it run briefly
719        thread::sleep(Duration::from_millis(100));
720
721        // Drop should signal stop and not hang
722        let start = Instant::now();
723        drop(checker);
724        let elapsed = start.elapsed();
725
726        // Shutdown should be quick (within a second)
727        assert!(
728            elapsed < Duration::from_secs(2),
729            "Shutdown took too long: {:?}",
730            elapsed
731        );
732
733        let _ = stop_tx.send(());
734    }
735
736    #[test]
737    fn test_periodic_update_checker_multiple_cycles() {
738        // Test that the checker produces multiple results when time advances by days
739        let (stop_tx, url) = start_mock_release_server("99.0.0");
740        let time_source = super::super::time_source::TestTimeSource::shared();
741        let temp_dir = tempfile::tempdir().unwrap();
742
743        let mut checker = start_periodic_update_check_with_interval(
744            &url,
745            Duration::from_secs(86400),
746            time_source.clone(),
747            temp_dir.path().to_path_buf(),
748        );
749
750        let mut result_count = 0;
751        let start = Instant::now();
752        let timeout = Duration::from_secs(2);
753
754        // Get initial result
755        while start.elapsed() < timeout && result_count < 1 {
756            if checker.poll_result().is_some() {
757                result_count += 1;
758            }
759            thread::sleep(Duration::from_millis(10));
760        }
761
762        // Advance time by 1 day to trigger next check
763        time_source.advance(Duration::from_secs(86400));
764
765        // Wait for second result
766        let start2 = Instant::now();
767        while start2.elapsed() < timeout && result_count < 2 {
768            if checker.poll_result().is_some() {
769                result_count += 1;
770            }
771            thread::sleep(Duration::from_millis(10));
772        }
773
774        assert!(
775            result_count >= 2,
776            "Expected at least 2 results, got {}",
777            result_count
778        );
779
780        drop(checker);
781        let _ = stop_tx.send(());
782    }
783
784    #[test]
785    fn test_periodic_update_checker_no_update_when_current() {
786        // Test behavior when server returns current version (no update)
787        let (stop_tx, url) = start_mock_release_server(CURRENT_VERSION);
788        let time_source = super::super::time_source::TestTimeSource::shared();
789        let temp_dir = tempfile::tempdir().unwrap();
790
791        let mut checker = start_periodic_update_check_with_interval(
792            &url,
793            Duration::from_secs(3600),
794            time_source,
795            temp_dir.path().to_path_buf(),
796        );
797
798        // Wait for initial result
799        let start = Instant::now();
800        while start.elapsed() < Duration::from_secs(2) {
801            if checker.poll_result().is_some() {
802                break;
803            }
804            thread::sleep(Duration::from_millis(10));
805        }
806
807        // Verify no update available
808        assert!(!checker.is_update_available());
809        assert!(checker.latest_version().is_none()); // Returns None when no update
810        assert!(checker.get_cached_result().is_some()); // But result is still cached
811
812        drop(checker);
813        let _ = stop_tx.send(());
814    }
815
816    #[test]
817    fn test_periodic_update_checker_api_before_result() {
818        // Test that API methods work correctly before any result is received
819        let (stop_tx, url) = start_mock_release_server("99.0.0");
820        let time_source = super::super::time_source::TestTimeSource::shared();
821        let temp_dir = tempfile::tempdir().unwrap();
822
823        // Use a very long interval so we only test the initial state
824        let checker = start_periodic_update_check_with_interval(
825            &url,
826            Duration::from_secs(3600),
827            time_source,
828            temp_dir.path().to_path_buf(),
829        );
830
831        // Immediately check (before result arrives)
832        assert!(!checker.is_update_available());
833        assert!(checker.latest_version().is_none());
834        assert!(checker.get_cached_result().is_none());
835
836        drop(checker);
837        let _ = stop_tx.send(());
838    }
839}