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.
240pub fn fetch_latest_version(url: &str) -> Result<String, String> {
241    tracing::debug!("Fetching latest version from {}", url);
242    let agent = ureq::Agent::config_builder()
243        .timeout_global(Some(Duration::from_secs(15)))
244        .build()
245        .new_agent();
246    let response = agent
247        .get(url)
248        .header("User-Agent", "fresh-editor-update-checker")
249        .header("Accept", "application/vnd.github.v3+json")
250        .call()
251        .map_err(|e| {
252            tracing::debug!("HTTP request failed: {}", e);
253            format!("HTTP request failed: {}", e)
254        })?;
255
256    let body = response
257        .into_body()
258        .read_to_string()
259        .map_err(|e| format!("Failed to read response body: {}", e))?;
260
261    let version = parse_version_from_json(&body)?;
262    tracing::debug!("Latest version: {}", version);
263    Ok(version)
264}
265
266/// Parse version from GitHub API JSON response
267fn parse_version_from_json(json: &str) -> Result<String, String> {
268    let tag_name_key = "\"tag_name\"";
269    let start = json
270        .find(tag_name_key)
271        .ok_or_else(|| "tag_name not found in response".to_string())?;
272
273    let after_key = &json[start + tag_name_key.len()..];
274
275    let value_start = after_key
276        .find('"')
277        .ok_or_else(|| "Invalid JSON: missing quote after tag_name".to_string())?;
278
279    let value_content = &after_key[value_start + 1..];
280    let value_end = value_content
281        .find('"')
282        .ok_or_else(|| "Invalid JSON: unclosed quote".to_string())?;
283
284    let tag = &value_content[..value_end];
285
286    // Strip 'v' prefix if present
287    Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
288}
289
290/// Detect the installation method based on the current executable path
291pub fn detect_install_method() -> InstallMethod {
292    match env::current_exe() {
293        Ok(path) => detect_install_method_from_path(&path),
294        Err(_) => InstallMethod::Unknown,
295    }
296}
297
298/// Detect installation method from a given executable path
299pub fn detect_install_method_from_path(exe_path: &Path) -> InstallMethod {
300    let path_str = exe_path.to_string_lossy();
301
302    // Check for Homebrew paths (macOS and Linux)
303    if path_str.contains("/opt/homebrew/")
304        || path_str.contains("/usr/local/Cellar/")
305        || path_str.contains("/home/linuxbrew/")
306        || path_str.contains("/.linuxbrew/")
307    {
308        return InstallMethod::Homebrew;
309    }
310
311    // Check for Cargo installation
312    if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
313        return InstallMethod::Cargo;
314    }
315
316    // Check for npm global installation
317    if path_str.contains("/node_modules/")
318        || path_str.contains("\\node_modules\\")
319        || path_str.contains("/npm/")
320        || path_str.contains("/lib/node_modules/")
321    {
322        return InstallMethod::Npm;
323    }
324
325    // Check for AUR installation (Arch Linux)
326    if path_str.starts_with("/usr/bin/") && is_arch_linux() {
327        return InstallMethod::Aur;
328    }
329
330    // Check for package manager installation (standard system paths)
331    if path_str.starts_with("/usr/bin/")
332        || path_str.starts_with("/usr/local/bin/")
333        || path_str.starts_with("/bin/")
334    {
335        return InstallMethod::PackageManager;
336    }
337
338    InstallMethod::Unknown
339}
340
341/// Check if we're running on Arch Linux
342fn is_arch_linux() -> bool {
343    std::fs::read_to_string("/etc/os-release")
344        .map(|content| content.contains("Arch Linux") || content.contains("ID=arch"))
345        .unwrap_or(false)
346}
347
348/// Compare two semantic versions
349/// Returns true if `latest` is newer than `current`
350pub fn is_newer_version(current: &str, latest: &str) -> bool {
351    let parse_version = |v: &str| -> Option<(u32, u32, u32)> {
352        let parts: Vec<&str> = v.split('.').collect();
353        if parts.len() >= 3 {
354            Some((
355                parts[0].parse().ok()?,
356                parts[1].parse().ok()?,
357                parts[2].split('-').next()?.parse().ok()?,
358            ))
359        } else if parts.len() == 2 {
360            Some((parts[0].parse().ok()?, parts[1].parse().ok()?, 0))
361        } else {
362            None
363        }
364    };
365
366    match (parse_version(current), parse_version(latest)) {
367        (Some((c_major, c_minor, c_patch)), Some((l_major, l_minor, l_patch))) => {
368            (l_major, l_minor, l_patch) > (c_major, c_minor, c_patch)
369        }
370        _ => false,
371    }
372}
373
374/// Check for a new release (blocking)
375pub fn check_for_update(releases_url: &str) -> Result<ReleaseCheckResult, String> {
376    let latest_version = fetch_latest_version(releases_url)?;
377    let install_method = detect_install_method();
378    let update_available = is_newer_version(CURRENT_VERSION, &latest_version);
379
380    tracing::debug!(
381        current = CURRENT_VERSION,
382        latest = %latest_version,
383        update_available,
384        install_method = ?install_method,
385        "Release check complete"
386    );
387
388    Ok(ReleaseCheckResult {
389        latest_version,
390        update_available,
391        install_method,
392    })
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use std::path::PathBuf;
399
400    #[test]
401    fn test_is_newer_version() {
402        // (current, latest, expected_newer)
403        let cases = [
404            ("0.1.26", "1.0.0", true),        // major bump
405            ("0.1.26", "0.2.0", true),        // minor bump
406            ("0.1.26", "0.1.27", true),       // patch bump
407            ("0.1.26", "0.1.26", false),      // same
408            ("0.1.26", "0.1.25", false),      // older patch
409            ("0.2.0", "0.1.26", false),       // older minor
410            ("1.0.0", "0.1.26", false),       // older major
411            ("0.1.26-alpha", "0.1.27", true), // prerelease current
412            ("0.1.26", "0.1.27-beta", true),  // prerelease latest
413        ];
414        for (current, latest, expected) in cases {
415            assert_eq!(
416                is_newer_version(current, latest),
417                expected,
418                "is_newer_version({:?}, {:?})",
419                current,
420                latest
421            );
422        }
423    }
424
425    #[test]
426    fn test_detect_install_method() {
427        let cases = [
428            (
429                "/opt/homebrew/Cellar/fresh/0.1.26/bin/fresh",
430                InstallMethod::Homebrew,
431            ),
432            (
433                "/usr/local/Cellar/fresh/0.1.26/bin/fresh",
434                InstallMethod::Homebrew,
435            ),
436            (
437                "/home/linuxbrew/.linuxbrew/bin/fresh",
438                InstallMethod::Homebrew,
439            ),
440            ("/home/user/.cargo/bin/fresh", InstallMethod::Cargo),
441            (
442                "C:\\Users\\user\\.cargo\\bin\\fresh.exe",
443                InstallMethod::Cargo,
444            ),
445            (
446                "/usr/local/lib/node_modules/fresh-editor/bin/fresh",
447                InstallMethod::Npm,
448            ),
449            ("/usr/local/bin/fresh", InstallMethod::PackageManager),
450            ("/home/user/downloads/fresh", InstallMethod::Unknown),
451        ];
452        for (path, expected) in cases {
453            assert_eq!(
454                detect_install_method_from_path(&PathBuf::from(path)),
455                expected,
456                "detect_install_method({:?})",
457                path
458            );
459        }
460    }
461
462    #[test]
463    fn test_parse_version_from_json() {
464        // Various JSON formats should all parse correctly
465        let cases = [
466            (r#"{"tag_name": "v0.1.27"}"#, "0.1.27"),
467            (r#"{"tag_name": "0.1.27"}"#, "0.1.27"),
468            (
469                r#"{"tag_name": "v0.2.0", "name": "v0.2.0", "draft": false}"#,
470                "0.2.0",
471            ),
472        ];
473        for (json, expected) in cases {
474            assert_eq!(parse_version_from_json(json).unwrap(), expected);
475        }
476
477        // Verify mock version is detected as newer than current
478        let version = parse_version_from_json(r#"{"tag_name": "v99.0.0"}"#).unwrap();
479        assert!(is_newer_version(CURRENT_VERSION, &version));
480    }
481
482    #[test]
483    fn test_current_version_is_valid() {
484        let parts: Vec<&str> = CURRENT_VERSION.split('.').collect();
485        assert!(parts.len() >= 2, "Version should have at least major.minor");
486        assert!(parts[0].parse::<u32>().is_ok());
487        assert!(parts[1].parse::<u32>().is_ok());
488    }
489
490    use std::sync::mpsc as std_mpsc;
491
492    /// Test helper: start a local HTTP server that returns a mock release JSON
493    /// Returns (stop_sender, url) - send to stop_sender to shut down the server
494    fn start_mock_release_server(version: &str) -> (std_mpsc::Sender<()>, String) {
495        let server = tiny_http::Server::http("127.0.0.1:0").expect("Failed to start test server");
496        let port = server.server_addr().to_ip().unwrap().port();
497        let url = format!("http://127.0.0.1:{}/releases/latest", port);
498
499        let (stop_tx, stop_rx) = std_mpsc::channel::<()>();
500
501        // Spawn a thread to handle requests
502        let version = version.to_string();
503        thread::spawn(move || {
504            loop {
505                // Check for stop signal
506                if stop_rx.try_recv().is_ok() {
507                    break;
508                }
509
510                // Non-blocking receive with timeout
511                match server.recv_timeout(Duration::from_millis(100)) {
512                    Ok(Some(request)) => {
513                        let response_body = format!(r#"{{"tag_name": "v{}"}}"#, version);
514                        let response = tiny_http::Response::from_string(response_body).with_header(
515                            tiny_http::Header::from_bytes(
516                                &b"Content-Type"[..],
517                                &b"application/json"[..],
518                            )
519                            .unwrap(),
520                        );
521                        drop(request.respond(response));
522                    }
523                    Ok(None) => {
524                        // Timeout, continue loop
525                    }
526                    Err(_) => {
527                        // Server error, exit
528                        break;
529                    }
530                }
531            }
532        });
533
534        (stop_tx, url)
535    }
536
537    #[test]
538    fn test_update_checker_detects_new_version() {
539        let (stop_tx, url) = start_mock_release_server("99.0.0");
540        let time_source = super::super::time_source::TestTimeSource::shared();
541        let temp_dir = tempfile::tempdir().unwrap();
542
543        let mut checker =
544            start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
545
546        // Wait for result
547        let start = std::time::Instant::now();
548        while start.elapsed() < Duration::from_secs(2) {
549            if checker.poll_result().is_some() {
550                break;
551            }
552            thread::sleep(Duration::from_millis(10));
553        }
554
555        assert!(checker.is_update_available());
556        assert_eq!(checker.latest_version(), Some("99.0.0"));
557
558        stop_tx.send(()).ok();
559    }
560
561    #[test]
562    fn test_update_checker_no_update_when_current() {
563        let (stop_tx, url) = start_mock_release_server(CURRENT_VERSION);
564        let time_source = super::super::time_source::TestTimeSource::shared();
565        let temp_dir = tempfile::tempdir().unwrap();
566
567        let mut checker =
568            start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
569
570        // Wait for result
571        let start = std::time::Instant::now();
572        while start.elapsed() < Duration::from_secs(2) {
573            if checker.poll_result().is_some() {
574                break;
575            }
576            thread::sleep(Duration::from_millis(10));
577        }
578
579        assert!(!checker.is_update_available());
580        assert!(checker.latest_version().is_none());
581        assert!(checker.get_cached_result().is_some());
582
583        stop_tx.send(()).ok();
584    }
585
586    #[test]
587    fn test_update_checker_api_before_result() {
588        let (stop_tx, url) = start_mock_release_server("99.0.0");
589        let time_source = super::super::time_source::TestTimeSource::shared();
590        let temp_dir = tempfile::tempdir().unwrap();
591
592        let checker = start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
593
594        // Immediately check (before result arrives)
595        assert!(!checker.is_update_available());
596        assert!(checker.latest_version().is_none());
597        assert!(checker.get_cached_result().is_none());
598
599        stop_tx.send(()).ok();
600    }
601}