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//! - Daily update checking (debounced via stamp file)
8
9use super::time_source::SharedTimeSource;
10use std::env;
11use std::path::{Path, PathBuf};
12use std::sync::mpsc::{self, Receiver, TryRecvError};
13use std::thread::{self, JoinHandle};
14use std::time::Duration;
15
16/// The current version of the editor
17pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
18
19/// Default GitHub releases API URL for the fresh editor
20pub const DEFAULT_RELEASES_URL: &str = "https://api.github.com/repos/sinelaw/fresh/releases/latest";
21
22/// Installation method detection result
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum InstallMethod {
25    /// Installed via Homebrew
26    Homebrew,
27    /// Installed via cargo
28    Cargo,
29    /// Installed via npm
30    Npm,
31    /// Installed via a Linux package manager (apt, dnf, etc.)
32    PackageManager,
33    /// Installed via AUR (Arch User Repository)
34    Aur,
35    /// Unknown installation method or manually installed
36    Unknown,
37}
38
39impl InstallMethod {
40    /// Get the update command for this installation method
41    pub fn update_command(&self) -> Option<&'static str> {
42        Some(match self {
43            Self::Homebrew => "brew upgrade fresh-editor",
44            Self::Cargo => "cargo install --locked fresh-editor",
45            Self::Npm => "npm update -g @fresh-editor/fresh-editor",
46            Self::Aur => "yay -Syu fresh-editor  # or use your AUR helper",
47            Self::PackageManager => "Update using your system package manager",
48            Self::Unknown => return None,
49        })
50    }
51}
52
53/// Result of checking for a new release
54#[derive(Debug, Clone)]
55pub struct ReleaseCheckResult {
56    /// The latest version available
57    pub latest_version: String,
58    /// Whether an update is available
59    pub update_available: bool,
60    /// The detected installation method
61    pub install_method: InstallMethod,
62}
63
64/// Handle to a background update check (one-shot)
65///
66/// Use `try_get_result` to check if the result is ready without blocking.
67pub struct UpdateCheckHandle {
68    receiver: Receiver<Result<ReleaseCheckResult, String>>,
69    #[allow(dead_code)]
70    thread: JoinHandle<()>,
71}
72
73impl UpdateCheckHandle {
74    /// Try to get the result without blocking.
75    /// Returns Some(result) if the check completed, None if still running.
76    /// If still running, the background thread is abandoned (will be killed on process exit).
77    pub fn try_get_result(self) -> Option<Result<ReleaseCheckResult, String>> {
78        match self.receiver.try_recv() {
79            Ok(result) => {
80                tracing::debug!("Update check completed");
81                Some(result)
82            }
83            Err(TryRecvError::Empty) => {
84                // Still running - abandon the thread
85                tracing::debug!("Update check still running, abandoning");
86                drop(self.thread);
87                None
88            }
89            Err(TryRecvError::Disconnected) => {
90                // Thread panicked or exited without sending
91                tracing::debug!("Update check thread disconnected");
92                None
93            }
94        }
95    }
96}
97
98/// Handle to an update checker running in the background.
99///
100/// Runs a single check at startup (if not already done today).
101/// Results are available via `poll_result()`.
102pub struct UpdateChecker {
103    /// Receiver for update check results
104    receiver: Receiver<Result<ReleaseCheckResult, String>>,
105    /// Background thread handle
106    #[allow(dead_code)]
107    thread: JoinHandle<()>,
108    /// Last successful result (cached)
109    last_result: Option<ReleaseCheckResult>,
110}
111
112/// Backwards compatibility alias
113pub type PeriodicUpdateChecker = UpdateChecker;
114
115impl UpdateChecker {
116    /// Poll for a new update check result without blocking.
117    ///
118    /// Returns `Some(result)` if a new check completed, `None` if no new result.
119    /// Successful results are cached and can be retrieved via `get_cached_result()`.
120    pub fn poll_result(&mut self) -> Option<Result<ReleaseCheckResult, String>> {
121        match self.receiver.try_recv() {
122            Ok(result) => {
123                if let Ok(ref release_result) = result {
124                    tracing::debug!(
125                        "Update check completed: update_available={}",
126                        release_result.update_available
127                    );
128                    self.last_result = Some(release_result.clone());
129                }
130                Some(result)
131            }
132            Err(TryRecvError::Empty) => None,
133            Err(TryRecvError::Disconnected) => None,
134        }
135    }
136
137    /// Get the cached result from the last successful check.
138    pub fn get_cached_result(&self) -> Option<&ReleaseCheckResult> {
139        self.last_result.as_ref()
140    }
141
142    /// Check if an update is available (from cached result).
143    pub fn is_update_available(&self) -> bool {
144        self.last_result
145            .as_ref()
146            .map(|r| r.update_available)
147            .unwrap_or(false)
148    }
149
150    /// Get the latest version string if an update is available.
151    pub fn latest_version(&self) -> Option<&str> {
152        self.last_result.as_ref().and_then(|r| {
153            if r.update_available {
154                Some(r.latest_version.as_str())
155            } else {
156                None
157            }
158        })
159    }
160}
161
162/// Start an update checker that runs once at startup.
163///
164/// The check respects daily debouncing via the stamp file - if already
165/// checked today, no network request is made.
166/// Results are available via `poll_result()` on the returned handle.
167pub fn start_periodic_update_check(
168    releases_url: &str,
169    time_source: SharedTimeSource,
170    data_dir: PathBuf,
171) -> UpdateChecker {
172    tracing::debug!("Starting update checker");
173    let url = releases_url.to_string();
174    let (tx, rx) = mpsc::channel();
175
176    let handle = thread::spawn(move || {
177        if let Some(unique_id) =
178            super::telemetry::should_run_daily_check(time_source.as_ref(), &data_dir)
179        {
180            super::telemetry::track_open(&unique_id);
181            let result = check_for_update(&url);
182            // Receiver may be dropped if checker is dropped before result arrives.
183            #[allow(clippy::let_underscore_must_use)]
184            let _ = tx.send(result);
185        }
186    });
187
188    UpdateChecker {
189        receiver: rx,
190        thread: handle,
191        last_result: None,
192    }
193}
194
195/// Start an update checker (for testing with custom parameters).
196#[doc(hidden)]
197pub fn start_periodic_update_check_with_interval(
198    releases_url: &str,
199    _check_interval: Duration,
200    time_source: SharedTimeSource,
201    data_dir: PathBuf,
202) -> UpdateChecker {
203    // check_interval is ignored - debouncing is handled by stamp file
204    start_periodic_update_check(releases_url, time_source, data_dir)
205}
206
207/// Start a background update check
208///
209/// Returns a handle that can be used to query the result later.
210/// The check runs in a background thread and won't block.
211/// Respects daily debouncing - if already checked today, no result will be sent.
212pub fn start_update_check(
213    releases_url: &str,
214    time_source: SharedTimeSource,
215    data_dir: PathBuf,
216) -> UpdateCheckHandle {
217    tracing::debug!("Starting background update check");
218    let url = releases_url.to_string();
219    let (tx, rx) = mpsc::channel();
220
221    let handle = thread::spawn(move || {
222        if let Some(unique_id) =
223            super::telemetry::should_run_daily_check(time_source.as_ref(), &data_dir)
224        {
225            super::telemetry::track_open(&unique_id);
226            let result = check_for_update(&url);
227            // Receiver may be dropped if handle is dropped before result arrives.
228            #[allow(clippy::let_underscore_must_use)]
229            let _ = tx.send(result);
230        }
231    });
232
233    UpdateCheckHandle {
234        receiver: rx,
235        thread: handle,
236    }
237}
238
239/// Fetches release information from the provided URL.
240///
241/// The HTTP/TLS transport lives in `services::http`; without the `http`
242/// feature that call returns an error and we surface it here unchanged.
243pub fn fetch_latest_version(url: &str) -> Result<String, String> {
244    tracing::debug!("Fetching latest version from {}", url);
245    let body = super::http::get_release_json(url)?;
246    let version = parse_version_from_json(&body)?;
247    tracing::debug!("Latest version: {}", version);
248    Ok(version)
249}
250
251/// Parse version from GitHub API JSON response
252fn parse_version_from_json(json: &str) -> Result<String, String> {
253    let tag_name_key = "\"tag_name\"";
254    let start = json
255        .find(tag_name_key)
256        .ok_or_else(|| "tag_name not found in response".to_string())?;
257
258    let after_key = &json[start + tag_name_key.len()..];
259
260    let value_start = after_key
261        .find('"')
262        .ok_or_else(|| "Invalid JSON: missing quote after tag_name".to_string())?;
263
264    let value_content = &after_key[value_start + 1..];
265    let value_end = value_content
266        .find('"')
267        .ok_or_else(|| "Invalid JSON: unclosed quote".to_string())?;
268
269    let tag = &value_content[..value_end];
270
271    // Strip 'v' prefix if present
272    Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
273}
274
275/// Detect the installation method based on the current executable path
276pub fn detect_install_method() -> InstallMethod {
277    match env::current_exe() {
278        Ok(path) => detect_install_method_from_path(&path),
279        Err(_) => InstallMethod::Unknown,
280    }
281}
282
283/// Detect installation method from a given executable path
284pub fn detect_install_method_from_path(exe_path: &Path) -> InstallMethod {
285    let path_str = exe_path.to_string_lossy();
286
287    // Check for Homebrew paths (macOS and Linux)
288    if path_str.contains("/opt/homebrew/")
289        || path_str.contains("/usr/local/Cellar/")
290        || path_str.contains("/home/linuxbrew/")
291        || path_str.contains("/.linuxbrew/")
292    {
293        return InstallMethod::Homebrew;
294    }
295
296    // Check for Cargo installation
297    if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
298        return InstallMethod::Cargo;
299    }
300
301    // Check for npm global installation
302    if path_str.contains("/node_modules/")
303        || path_str.contains("\\node_modules\\")
304        || path_str.contains("/npm/")
305        || path_str.contains("/lib/node_modules/")
306    {
307        return InstallMethod::Npm;
308    }
309
310    // Check for AUR installation (Arch Linux)
311    if path_str.starts_with("/usr/bin/") && is_arch_linux() {
312        return InstallMethod::Aur;
313    }
314
315    // Check for package manager installation (standard system paths)
316    if path_str.starts_with("/usr/bin/")
317        || path_str.starts_with("/usr/local/bin/")
318        || path_str.starts_with("/bin/")
319    {
320        return InstallMethod::PackageManager;
321    }
322
323    InstallMethod::Unknown
324}
325
326/// Check if we're running on Arch Linux
327fn is_arch_linux() -> bool {
328    std::fs::read_to_string("/etc/os-release")
329        .map(|content| content.contains("Arch Linux") || content.contains("ID=arch"))
330        .unwrap_or(false)
331}
332
333/// Compare two semantic versions
334/// Returns true if `latest` is newer than `current`
335pub fn is_newer_version(current: &str, latest: &str) -> bool {
336    let parse_version = |v: &str| -> Option<(u32, u32, u32)> {
337        let parts: Vec<&str> = v.split('.').collect();
338        if parts.len() >= 3 {
339            Some((
340                parts[0].parse().ok()?,
341                parts[1].parse().ok()?,
342                parts[2].split('-').next()?.parse().ok()?,
343            ))
344        } else if parts.len() == 2 {
345            Some((parts[0].parse().ok()?, parts[1].parse().ok()?, 0))
346        } else {
347            None
348        }
349    };
350
351    match (parse_version(current), parse_version(latest)) {
352        (Some((c_major, c_minor, c_patch)), Some((l_major, l_minor, l_patch))) => {
353            (l_major, l_minor, l_patch) > (c_major, c_minor, c_patch)
354        }
355        _ => false,
356    }
357}
358
359/// Check for a new release (blocking)
360pub fn check_for_update(releases_url: &str) -> Result<ReleaseCheckResult, String> {
361    let latest_version = fetch_latest_version(releases_url)?;
362    let install_method = detect_install_method();
363    let update_available = is_newer_version(CURRENT_VERSION, &latest_version);
364
365    tracing::debug!(
366        current = CURRENT_VERSION,
367        latest = %latest_version,
368        update_available,
369        install_method = ?install_method,
370        "Release check complete"
371    );
372
373    Ok(ReleaseCheckResult {
374        latest_version,
375        update_available,
376        install_method,
377    })
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use std::path::PathBuf;
384
385    #[test]
386    fn test_is_newer_version() {
387        // (current, latest, expected_newer)
388        let cases = [
389            ("0.1.26", "1.0.0", true),        // major bump
390            ("0.1.26", "0.2.0", true),        // minor bump
391            ("0.1.26", "0.1.27", true),       // patch bump
392            ("0.1.26", "0.1.26", false),      // same
393            ("0.1.26", "0.1.25", false),      // older patch
394            ("0.2.0", "0.1.26", false),       // older minor
395            ("1.0.0", "0.1.26", false),       // older major
396            ("0.1.26-alpha", "0.1.27", true), // prerelease current
397            ("0.1.26", "0.1.27-beta", true),  // prerelease latest
398        ];
399        for (current, latest, expected) in cases {
400            assert_eq!(
401                is_newer_version(current, latest),
402                expected,
403                "is_newer_version({:?}, {:?})",
404                current,
405                latest
406            );
407        }
408    }
409
410    #[test]
411    fn test_detect_install_method() {
412        let cases = [
413            (
414                "/opt/homebrew/Cellar/fresh/0.1.26/bin/fresh",
415                InstallMethod::Homebrew,
416            ),
417            (
418                "/usr/local/Cellar/fresh/0.1.26/bin/fresh",
419                InstallMethod::Homebrew,
420            ),
421            (
422                "/home/linuxbrew/.linuxbrew/bin/fresh",
423                InstallMethod::Homebrew,
424            ),
425            ("/home/user/.cargo/bin/fresh", InstallMethod::Cargo),
426            (
427                "C:\\Users\\user\\.cargo\\bin\\fresh.exe",
428                InstallMethod::Cargo,
429            ),
430            (
431                "/usr/local/lib/node_modules/fresh-editor/bin/fresh",
432                InstallMethod::Npm,
433            ),
434            ("/usr/local/bin/fresh", InstallMethod::PackageManager),
435            ("/home/user/downloads/fresh", InstallMethod::Unknown),
436        ];
437        for (path, expected) in cases {
438            assert_eq!(
439                detect_install_method_from_path(&PathBuf::from(path)),
440                expected,
441                "detect_install_method({:?})",
442                path
443            );
444        }
445    }
446
447    #[test]
448    fn test_parse_version_from_json() {
449        // Various JSON formats should all parse correctly
450        let cases = [
451            (r#"{"tag_name": "v0.1.27"}"#, "0.1.27"),
452            (r#"{"tag_name": "0.1.27"}"#, "0.1.27"),
453            (
454                r#"{"tag_name": "v0.2.0", "name": "v0.2.0", "draft": false}"#,
455                "0.2.0",
456            ),
457        ];
458        for (json, expected) in cases {
459            assert_eq!(parse_version_from_json(json).unwrap(), expected);
460        }
461
462        // Verify mock version is detected as newer than current
463        let version = parse_version_from_json(r#"{"tag_name": "v99.0.0"}"#).unwrap();
464        assert!(is_newer_version(CURRENT_VERSION, &version));
465    }
466
467    #[test]
468    fn test_current_version_is_valid() {
469        let parts: Vec<&str> = CURRENT_VERSION.split('.').collect();
470        assert!(parts.len() >= 2, "Version should have at least major.minor");
471        assert!(parts[0].parse::<u32>().is_ok());
472        assert!(parts[1].parse::<u32>().is_ok());
473    }
474
475    use std::sync::mpsc as std_mpsc;
476
477    /// Test helper: start a local HTTP server that returns a mock release JSON
478    /// Returns (stop_sender, url) - send to stop_sender to shut down the server
479    fn start_mock_release_server(version: &str) -> (std_mpsc::Sender<()>, String) {
480        let server = tiny_http::Server::http("127.0.0.1:0").expect("Failed to start test server");
481        let port = server.server_addr().to_ip().unwrap().port();
482        let url = format!("http://127.0.0.1:{}/releases/latest", port);
483
484        let (stop_tx, stop_rx) = std_mpsc::channel::<()>();
485
486        // Spawn a thread to handle requests
487        let version = version.to_string();
488        thread::spawn(move || {
489            loop {
490                // Check for stop signal
491                if stop_rx.try_recv().is_ok() {
492                    break;
493                }
494
495                // Non-blocking receive with timeout
496                match server.recv_timeout(Duration::from_millis(100)) {
497                    Ok(Some(request)) => {
498                        let response_body = format!(r#"{{"tag_name": "v{}"}}"#, version);
499                        let response = tiny_http::Response::from_string(response_body).with_header(
500                            tiny_http::Header::from_bytes(
501                                &b"Content-Type"[..],
502                                &b"application/json"[..],
503                            )
504                            .unwrap(),
505                        );
506                        drop(request.respond(response));
507                    }
508                    Ok(None) => {
509                        // Timeout, continue loop
510                    }
511                    Err(_) => {
512                        // Server error, exit
513                        break;
514                    }
515                }
516            }
517        });
518
519        (stop_tx, url)
520    }
521
522    #[test]
523    fn test_update_checker_detects_new_version() {
524        let (stop_tx, url) = start_mock_release_server("99.0.0");
525        let time_source = super::super::time_source::TestTimeSource::shared();
526        let temp_dir = tempfile::tempdir().unwrap();
527
528        let mut checker =
529            start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
530
531        // Wait for result
532        let start = std::time::Instant::now();
533        while start.elapsed() < Duration::from_secs(2) {
534            if checker.poll_result().is_some() {
535                break;
536            }
537            thread::sleep(Duration::from_millis(10));
538        }
539
540        assert!(checker.is_update_available());
541        assert_eq!(checker.latest_version(), Some("99.0.0"));
542
543        stop_tx.send(()).ok();
544    }
545
546    #[test]
547    fn test_update_checker_no_update_when_current() {
548        let (stop_tx, url) = start_mock_release_server(CURRENT_VERSION);
549        let time_source = super::super::time_source::TestTimeSource::shared();
550        let temp_dir = tempfile::tempdir().unwrap();
551
552        let mut checker =
553            start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
554
555        // Wait for result
556        let start = std::time::Instant::now();
557        while start.elapsed() < Duration::from_secs(2) {
558            if checker.poll_result().is_some() {
559                break;
560            }
561            thread::sleep(Duration::from_millis(10));
562        }
563
564        assert!(!checker.is_update_available());
565        assert!(checker.latest_version().is_none());
566        assert!(checker.get_cached_result().is_some());
567
568        stop_tx.send(()).ok();
569    }
570
571    #[test]
572    fn test_update_checker_api_before_result() {
573        let (stop_tx, url) = start_mock_release_server("99.0.0");
574        let time_source = super::super::time_source::TestTimeSource::shared();
575        let temp_dir = tempfile::tempdir().unwrap();
576
577        let checker = start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
578
579        // Immediately check (before result arrives)
580        assert!(!checker.is_update_available());
581        assert!(checker.latest_version().is_none());
582        assert!(checker.get_cached_result().is_none());
583
584        stop_tx.send(()).ok();
585    }
586}