intelli_shell/service/
version.rs

1use std::{env, time::Duration};
2
3use chrono::Utc;
4use color_eyre::eyre::Context;
5use reqwest::header;
6use semver::Version;
7use serde::Deserialize;
8use tracing::{Instrument, instrument};
9
10use super::IntelliShellService;
11use crate::{
12    errors::{Result, UserFacingError},
13    storage::SqliteStorage,
14};
15
16/// The timeout for the request to check for a new version
17const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
18
19/// Represents the state of the background version check
20#[derive(Debug)]
21pub(super) enum VersionCheckState {
22    /// The check has not been started yet
23    NotStarted,
24    /// The check is currently in progress
25    InProgress,
26    /// The check has finished, and the result is cached
27    Finished(Option<Version>),
28}
29
30impl IntelliShellService {
31    /// Polls for a new version in a non-blocking manner.
32    ///
33    /// This method returns immediately. On the first call, it spawns a background task to fetch the latest version.
34    /// Subsequent calls will return `None` while the check is in progress. Once the check is finished, this method
35    /// will consistently return the cached result.
36    #[instrument(skip_all)]
37    pub fn poll_new_version(&self) -> Option<Version> {
38        // Lock the state to check the current status of the version check
39        let mut state = self.version_check_state.lock().expect("poisoned lock");
40
41        match &*state {
42            // If the check has already finished, return the cached result
43            VersionCheckState::Finished(version) => version.clone(),
44
45            // If the check is in progress, do nothing and return None
46            VersionCheckState::InProgress => None,
47
48            // If the check hasn't started
49            VersionCheckState::NotStarted => {
50                // When check_updates is disabled, skip the version check
51                if !self.check_updates {
52                    tracing::debug!("Skipping version check as it's disabled in the configuration");
53                    *state = VersionCheckState::Finished(None);
54                    return None;
55                }
56
57                // If not disabled, spawn a background task to perform the version check and return `None` immediately
58                *state = VersionCheckState::InProgress;
59                tracing::trace!("Spawning background task for version check");
60                drop(state);
61
62                let storage = self.storage.clone();
63                let state_clone = self.version_check_state.clone();
64                tokio::spawn(
65                    async move {
66                        let result = fetch_latest_version(&storage, false).await;
67
68                        // Once the check is done, lock the state and update it with the result
69                        let mut state = state_clone.lock().expect("poisoned lock");
70                        match result {
71                            Ok(version) => {
72                                if let Some(ref v) = version {
73                                    tracing::info!("New version available: v{v}");
74                                } else {
75                                    tracing::debug!("No new version available");
76                                }
77                                *state = VersionCheckState::Finished(version);
78                            }
79                            Err(err) => {
80                                tracing::error!("Failed to check for new version: {err:#?}");
81                                *state = VersionCheckState::Finished(None);
82                            }
83                        }
84                    }
85                    .instrument(tracing::info_span!("bg")),
86                );
87
88                None
89            }
90        }
91    }
92
93    /// Checks for a new version, performing a network request if necessary.
94    ///
95    /// This method performs the version check immediately, blocking the caller.
96    ///
97    /// For a non-blocking alternative that spawns a background task, see [`poll_new_version`](Self::poll_new_version).
98    #[instrument(skip_all)]
99    pub async fn check_new_version(&self, force_fetch: bool) -> Result<Option<Version>> {
100        fetch_latest_version(&self.storage, force_fetch).await
101    }
102}
103
104/// Fetches the latest version from the remote source, respecting a time-based cache.
105///
106/// It first consults the local database to see if a check was performed within the last hours.
107/// If not, it proceeds to fetch the latest release from the GitHub API, updates the database with the new version and
108/// timestamp, and returns the result.
109///
110/// It will return `None` if the latest version is not newer than the actual one.
111async fn fetch_latest_version(storage: &SqliteStorage, force_fetch: bool) -> Result<Option<Version>> {
112    // Get the current version
113    let now = Utc::now();
114    let current = Version::parse(env!("CARGO_PKG_VERSION")).wrap_err("Failed to parse current version")?;
115
116    // When not forcing a fetch, check the database cache for recent version info
117    if !force_fetch {
118        let (latest, checked_at) = storage.get_version_info().await?;
119
120        // If the latest version was checked recently, return whether it's newer than the current one
121        if (now - checked_at) < chrono::Duration::hours(16) {
122            tracing::debug!("Skipping version retrieval as it was checked recently, latest: v{latest}");
123            return Ok(Some(latest).filter(|v| v > &current));
124        }
125    }
126
127    // A simple struct to deserialize the relevant fields from the GitHub API response
128    #[derive(Deserialize, Debug)]
129    struct Release {
130        tag_name: String,
131    }
132
133    // Fetch latest release from GitHub
134    let res = reqwest::Client::new()
135        .get("https://api.github.com/repos/lasantosr/intelli-shell/releases/latest")
136        .header(header::USER_AGENT, "intelli-shell")
137        .timeout(REQUEST_TIMEOUT)
138        .send()
139        .await
140        .map_err(|err| {
141            tracing::error!("{err:?}");
142            UserFacingError::LatestVersionRequestFailed(err.to_string())
143        })?;
144
145    if !res.status().is_success() {
146        let status = res.status();
147        let status_str = status.as_str();
148        let body = res.text().await.unwrap_or_default();
149        let message = serde_json::from_str::<serde_json::Value>(&body)
150            .ok()
151            .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(|s| s.to_string()))
152            .unwrap_or_else(|| format!("received {status_str} response"));
153        if let Some(reason) = status.canonical_reason() {
154            tracing::error!("Got response [{status_str}] {reason}:\n{body}");
155            return Err(UserFacingError::LatestVersionRequestFailed(message).into());
156        } else {
157            tracing::error!("Got response [{status_str}]:\n{body}");
158            return Err(UserFacingError::LatestVersionRequestFailed(message).into());
159        }
160    }
161
162    let release: Release = res.json().await.wrap_err("Failed to parse latest release response")?;
163
164    // Parse it
165    let tag_version = release.tag_name.trim_start_matches('v');
166    let latest = Version::parse(tag_version)
167        .wrap_err_with(|| format!("Failed to parse latest version from tag: {tag_version}"))?;
168
169    tracing::debug!("Latest version fetched: v{latest}");
170
171    // Store the new information in the database
172    storage.update_version_info(latest.clone(), now).await?;
173
174    // Return whether the latest version is newer than the current one
175    Ok(Some(latest).filter(|v| v > &current))
176}