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 response = ureq::get(url)
315        .set("User-Agent", "fresh-editor-update-checker")
316        .set("Accept", "application/vnd.github.v3+json")
317        .timeout(Duration::from_secs(5))
318        .call()
319        .map_err(|e| {
320            tracing::debug!("HTTP request failed: {}", e);
321            format!("HTTP request failed: {}", e)
322        })?;
323
324    let body = response
325        .into_string()
326        .map_err(|e| format!("Failed to read response body: {}", e))?;
327
328    let version = parse_version_from_json(&body)?;
329    tracing::debug!("Latest version: {}", version);
330    Ok(version)
331}
332
333/// Parse version from GitHub API JSON response
334fn parse_version_from_json(json: &str) -> Result<String, String> {
335    let tag_name_key = "\"tag_name\"";
336    let start = json
337        .find(tag_name_key)
338        .ok_or_else(|| "tag_name not found in response".to_string())?;
339
340    let after_key = &json[start + tag_name_key.len()..];
341
342    let value_start = after_key
343        .find('"')
344        .ok_or_else(|| "Invalid JSON: missing quote after tag_name".to_string())?;
345
346    let value_content = &after_key[value_start + 1..];
347    let value_end = value_content
348        .find('"')
349        .ok_or_else(|| "Invalid JSON: unclosed quote".to_string())?;
350
351    let tag = &value_content[..value_end];
352
353    // Strip 'v' prefix if present
354    Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
355}
356
357/// Detect the installation method based on the current executable path
358pub fn detect_install_method() -> InstallMethod {
359    match env::current_exe() {
360        Ok(path) => detect_install_method_from_path(&path),
361        Err(_) => InstallMethod::Unknown,
362    }
363}
364
365/// Detect installation method from a given executable path
366pub fn detect_install_method_from_path(exe_path: &Path) -> InstallMethod {
367    let path_str = exe_path.to_string_lossy();
368
369    // Check for Homebrew paths (macOS and Linux)
370    if path_str.contains("/opt/homebrew/")
371        || path_str.contains("/usr/local/Cellar/")
372        || path_str.contains("/home/linuxbrew/")
373        || path_str.contains("/.linuxbrew/")
374    {
375        return InstallMethod::Homebrew;
376    }
377
378    // Check for Cargo installation
379    if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
380        return InstallMethod::Cargo;
381    }
382
383    // Check for npm global installation
384    if path_str.contains("/node_modules/")
385        || path_str.contains("\\node_modules\\")
386        || path_str.contains("/npm/")
387        || path_str.contains("/lib/node_modules/")
388    {
389        return InstallMethod::Npm;
390    }
391
392    // Check for AUR installation (Arch Linux)
393    if path_str.starts_with("/usr/bin/") && is_arch_linux() {
394        return InstallMethod::Aur;
395    }
396
397    // Check for package manager installation (standard system paths)
398    if path_str.starts_with("/usr/bin/")
399        || path_str.starts_with("/usr/local/bin/")
400        || path_str.starts_with("/bin/")
401    {
402        return InstallMethod::PackageManager;
403    }
404
405    InstallMethod::Unknown
406}
407
408/// Check if we're running on Arch Linux
409fn is_arch_linux() -> bool {
410    std::fs::read_to_string("/etc/os-release")
411        .map(|content| content.contains("Arch Linux") || content.contains("ID=arch"))
412        .unwrap_or(false)
413}
414
415/// Compare two semantic versions
416/// Returns true if `latest` is newer than `current`
417pub fn is_newer_version(current: &str, latest: &str) -> bool {
418    let parse_version = |v: &str| -> Option<(u32, u32, u32)> {
419        let parts: Vec<&str> = v.split('.').collect();
420        if parts.len() >= 3 {
421            Some((
422                parts[0].parse().ok()?,
423                parts[1].parse().ok()?,
424                parts[2].split('-').next()?.parse().ok()?,
425            ))
426        } else if parts.len() == 2 {
427            Some((parts[0].parse().ok()?, parts[1].parse().ok()?, 0))
428        } else {
429            None
430        }
431    };
432
433    match (parse_version(current), parse_version(latest)) {
434        (Some((c_major, c_minor, c_patch)), Some((l_major, l_minor, l_patch))) => {
435            (l_major, l_minor, l_patch) > (c_major, c_minor, c_patch)
436        }
437        _ => false,
438    }
439}
440
441/// Check for a new release (blocking)
442pub fn check_for_update(releases_url: &str) -> Result<ReleaseCheckResult, String> {
443    let latest_version = fetch_latest_version(releases_url)?;
444    let install_method = detect_install_method();
445    let update_available = is_newer_version(CURRENT_VERSION, &latest_version);
446
447    tracing::debug!(
448        current = CURRENT_VERSION,
449        latest = %latest_version,
450        update_available,
451        install_method = ?install_method,
452        "Release check complete"
453    );
454
455    Ok(ReleaseCheckResult {
456        latest_version,
457        update_available,
458        install_method,
459    })
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use std::path::PathBuf;
466
467    #[test]
468    fn test_is_newer_version_major() {
469        assert!(is_newer_version("0.1.26", "1.0.0"));
470        assert!(is_newer_version("1.0.0", "2.0.0"));
471    }
472
473    #[test]
474    fn test_is_newer_version_minor() {
475        assert!(is_newer_version("0.1.26", "0.2.0"));
476        assert!(is_newer_version("0.1.26", "0.2.26"));
477    }
478
479    #[test]
480    fn test_is_newer_version_patch() {
481        assert!(is_newer_version("0.1.26", "0.1.27"));
482        assert!(is_newer_version("0.1.26", "0.1.100"));
483    }
484
485    #[test]
486    fn test_is_newer_version_same() {
487        assert!(!is_newer_version("0.1.26", "0.1.26"));
488    }
489
490    #[test]
491    fn test_is_newer_version_older() {
492        assert!(!is_newer_version("0.1.26", "0.1.25"));
493        assert!(!is_newer_version("0.2.0", "0.1.26"));
494        assert!(!is_newer_version("1.0.0", "0.1.26"));
495    }
496
497    #[test]
498    fn test_is_newer_version_with_v_prefix() {
499        assert!(is_newer_version("0.1.26", "0.1.27"));
500    }
501
502    #[test]
503    fn test_is_newer_version_with_prerelease() {
504        assert!(is_newer_version("0.1.26-alpha", "0.1.27"));
505        assert!(is_newer_version("0.1.26", "0.1.27-beta"));
506    }
507
508    #[test]
509    fn test_detect_install_method_homebrew_macos() {
510        let path = PathBuf::from("/opt/homebrew/Cellar/fresh/0.1.26/bin/fresh");
511        assert_eq!(
512            detect_install_method_from_path(&path),
513            InstallMethod::Homebrew
514        );
515    }
516
517    #[test]
518    fn test_detect_install_method_homebrew_intel_mac() {
519        let path = PathBuf::from("/usr/local/Cellar/fresh/0.1.26/bin/fresh");
520        assert_eq!(
521            detect_install_method_from_path(&path),
522            InstallMethod::Homebrew
523        );
524    }
525
526    #[test]
527    fn test_detect_install_method_homebrew_linux() {
528        let path = PathBuf::from("/home/linuxbrew/.linuxbrew/bin/fresh");
529        assert_eq!(
530            detect_install_method_from_path(&path),
531            InstallMethod::Homebrew
532        );
533    }
534
535    #[test]
536    fn test_detect_install_method_cargo() {
537        let path = PathBuf::from("/home/user/.cargo/bin/fresh");
538        assert_eq!(detect_install_method_from_path(&path), InstallMethod::Cargo);
539    }
540
541    #[test]
542    fn test_detect_install_method_cargo_windows() {
543        let path = PathBuf::from("C:\\Users\\user\\.cargo\\bin\\fresh.exe");
544        assert_eq!(detect_install_method_from_path(&path), InstallMethod::Cargo);
545    }
546
547    #[test]
548    fn test_detect_install_method_npm() {
549        let path = PathBuf::from("/usr/local/lib/node_modules/fresh-editor/bin/fresh");
550        assert_eq!(detect_install_method_from_path(&path), InstallMethod::Npm);
551    }
552
553    #[test]
554    fn test_detect_install_method_package_manager() {
555        let path = PathBuf::from("/usr/local/bin/fresh");
556        assert_eq!(
557            detect_install_method_from_path(&path),
558            InstallMethod::PackageManager
559        );
560    }
561
562    #[test]
563    fn test_detect_install_method_unknown() {
564        let path = PathBuf::from("/home/user/downloads/fresh");
565        assert_eq!(
566            detect_install_method_from_path(&path),
567            InstallMethod::Unknown
568        );
569    }
570
571    #[test]
572    fn test_parse_version_from_json() {
573        let json = r#"{"tag_name": "v0.1.27", "name": "Release 0.1.27"}"#;
574        assert_eq!(parse_version_from_json(json).unwrap(), "0.1.27");
575    }
576
577    #[test]
578    fn test_parse_version_from_json_no_v_prefix() {
579        let json = r#"{"tag_name": "0.1.27", "name": "Release 0.1.27"}"#;
580        assert_eq!(parse_version_from_json(json).unwrap(), "0.1.27");
581    }
582
583    #[test]
584    fn test_parse_version_from_json_full_response() {
585        let json = r#"{
586            "url": "https://api.github.com/repos/sinelaw/fresh/releases/12345",
587            "tag_name": "v0.2.0",
588            "target_commitish": "main",
589            "name": "v0.2.0",
590            "draft": false,
591            "prerelease": false
592        }"#;
593        assert_eq!(parse_version_from_json(json).unwrap(), "0.2.0");
594    }
595
596    #[test]
597    fn test_current_version_is_valid() {
598        let parts: Vec<&str> = CURRENT_VERSION.split('.').collect();
599        assert!(parts.len() >= 2, "Version should have at least major.minor");
600        assert!(
601            parts[0].parse::<u32>().is_ok(),
602            "Major version should be a number"
603        );
604        assert!(
605            parts[1].parse::<u32>().is_ok(),
606            "Minor version should be a number"
607        );
608    }
609
610    #[test]
611    fn test_version_parsing_with_mock_data() {
612        let json = r#"{"tag_name": "v99.0.0"}"#;
613        let version = parse_version_from_json(json).unwrap();
614        assert!(is_newer_version(CURRENT_VERSION, &version));
615    }
616
617    use std::sync::mpsc as std_mpsc;
618
619    /// Test helper: start a local HTTP server that returns a mock release JSON
620    /// Returns (stop_sender, url) - send to stop_sender to shut down the server
621    fn start_mock_release_server(version: &str) -> (std_mpsc::Sender<()>, String) {
622        let server = tiny_http::Server::http("127.0.0.1:0").expect("Failed to start test server");
623        let port = server.server_addr().to_ip().unwrap().port();
624        let url = format!("http://127.0.0.1:{}/releases/latest", port);
625
626        let (stop_tx, stop_rx) = std_mpsc::channel::<()>();
627
628        // Spawn a thread to handle requests
629        let version = version.to_string();
630        thread::spawn(move || {
631            loop {
632                // Check for stop signal
633                if stop_rx.try_recv().is_ok() {
634                    break;
635                }
636
637                // Non-blocking receive with timeout
638                match server.recv_timeout(Duration::from_millis(100)) {
639                    Ok(Some(request)) => {
640                        let response_body = format!(r#"{{"tag_name": "v{}"}}"#, version);
641                        let response = tiny_http::Response::from_string(response_body).with_header(
642                            tiny_http::Header::from_bytes(
643                                &b"Content-Type"[..],
644                                &b"application/json"[..],
645                            )
646                            .unwrap(),
647                        );
648                        let _ = request.respond(response);
649                    }
650                    Ok(None) => {
651                        // Timeout, continue loop
652                    }
653                    Err(_) => {
654                        // Server error, exit
655                        break;
656                    }
657                }
658            }
659        });
660
661        (stop_tx, url)
662    }
663
664    #[test]
665    fn test_periodic_update_checker_with_local_server() {
666        // Test that the periodic checker works with a real HTTP server
667        let (stop_tx, url) = start_mock_release_server("99.0.0");
668        let time_source = super::super::time_source::TestTimeSource::shared();
669        let temp_dir = tempfile::tempdir().unwrap();
670
671        let mut checker = start_periodic_update_check_with_interval(
672            &url,
673            Duration::from_millis(50),
674            time_source,
675            temp_dir.path().to_path_buf(),
676        );
677
678        // Wait for initial result
679        let start = Instant::now();
680        while start.elapsed() < Duration::from_secs(2) {
681            if checker.poll_result().is_some() {
682                break;
683            }
684            thread::sleep(Duration::from_millis(10));
685        }
686
687        // Verify cached result
688        assert!(
689            checker.is_update_available(),
690            "Should detect update available"
691        );
692        assert_eq!(checker.latest_version(), Some("99.0.0"));
693        assert!(checker.get_cached_result().is_some());
694
695        drop(checker);
696        let _ = stop_tx.send(());
697    }
698
699    #[test]
700    fn test_periodic_update_checker_shutdown_clean() {
701        // Test that the checker shuts down cleanly without hanging
702        let (stop_tx, url) = start_mock_release_server("99.0.0");
703        let time_source = super::super::time_source::TestTimeSource::shared();
704        let temp_dir = tempfile::tempdir().unwrap();
705
706        let checker = start_periodic_update_check_with_interval(
707            &url,
708            Duration::from_millis(50),
709            time_source,
710            temp_dir.path().to_path_buf(),
711        );
712
713        // Let it run briefly
714        thread::sleep(Duration::from_millis(100));
715
716        // Drop should signal stop and not hang
717        let start = Instant::now();
718        drop(checker);
719        let elapsed = start.elapsed();
720
721        // Shutdown should be quick (within a second)
722        assert!(
723            elapsed < Duration::from_secs(2),
724            "Shutdown took too long: {:?}",
725            elapsed
726        );
727
728        let _ = stop_tx.send(());
729    }
730
731    #[test]
732    fn test_periodic_update_checker_multiple_cycles() {
733        // Test that the checker produces multiple results when time advances by days
734        let (stop_tx, url) = start_mock_release_server("99.0.0");
735        let time_source = super::super::time_source::TestTimeSource::shared();
736        let temp_dir = tempfile::tempdir().unwrap();
737
738        let mut checker = start_periodic_update_check_with_interval(
739            &url,
740            Duration::from_secs(86400),
741            time_source.clone(),
742            temp_dir.path().to_path_buf(),
743        );
744
745        let mut result_count = 0;
746        let start = Instant::now();
747        let timeout = Duration::from_secs(2);
748
749        // Get initial result
750        while start.elapsed() < timeout && result_count < 1 {
751            if checker.poll_result().is_some() {
752                result_count += 1;
753            }
754            thread::sleep(Duration::from_millis(10));
755        }
756
757        // Advance time by 1 day to trigger next check
758        time_source.advance(Duration::from_secs(86400));
759
760        // Wait for second result
761        let start2 = Instant::now();
762        while start2.elapsed() < timeout && result_count < 2 {
763            if checker.poll_result().is_some() {
764                result_count += 1;
765            }
766            thread::sleep(Duration::from_millis(10));
767        }
768
769        assert!(
770            result_count >= 2,
771            "Expected at least 2 results, got {}",
772            result_count
773        );
774
775        drop(checker);
776        let _ = stop_tx.send(());
777    }
778
779    #[test]
780    fn test_periodic_update_checker_no_update_when_current() {
781        // Test behavior when server returns current version (no update)
782        let (stop_tx, url) = start_mock_release_server(CURRENT_VERSION);
783        let time_source = super::super::time_source::TestTimeSource::shared();
784        let temp_dir = tempfile::tempdir().unwrap();
785
786        let mut checker = start_periodic_update_check_with_interval(
787            &url,
788            Duration::from_secs(3600),
789            time_source,
790            temp_dir.path().to_path_buf(),
791        );
792
793        // Wait for initial result
794        let start = Instant::now();
795        while start.elapsed() < Duration::from_secs(2) {
796            if checker.poll_result().is_some() {
797                break;
798            }
799            thread::sleep(Duration::from_millis(10));
800        }
801
802        // Verify no update available
803        assert!(!checker.is_update_available());
804        assert!(checker.latest_version().is_none()); // Returns None when no update
805        assert!(checker.get_cached_result().is_some()); // But result is still cached
806
807        drop(checker);
808        let _ = stop_tx.send(());
809    }
810
811    #[test]
812    fn test_periodic_update_checker_api_before_result() {
813        // Test that API methods work correctly before any result is received
814        let (stop_tx, url) = start_mock_release_server("99.0.0");
815        let time_source = super::super::time_source::TestTimeSource::shared();
816        let temp_dir = tempfile::tempdir().unwrap();
817
818        // Use a very long interval so we only test the initial state
819        let checker = start_periodic_update_check_with_interval(
820            &url,
821            Duration::from_secs(3600),
822            time_source,
823            temp_dir.path().to_path_buf(),
824        );
825
826        // Immediately check (before result arrives)
827        assert!(!checker.is_update_available());
828        assert!(checker.latest_version().is_none());
829        assert!(checker.get_cached_result().is_none());
830
831        drop(checker);
832        let _ = stop_tx.send(());
833    }
834}