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