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(5);
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    /// Checks if there's a new version available. This method returns immediately.
29    ///
30    /// On the first call, it spawns a background task to check for a new version.
31    /// Subsequent calls will return `None` until the check is complete.
32    /// Once finished, it will always return the cached result.
33    #[instrument(skip_all)]
34    pub fn check_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 = perform_version_check(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
91/// Performs the actual version check against the remote source
92async fn perform_version_check(storage: SqliteStorage) -> Result<Option<Version>> {
93    // Get the current version and the last checked version
94    let now = Utc::now();
95    let current = Version::parse(env!("CARGO_PKG_VERSION")).wrap_err("Failed to parse current version")?;
96    let (latest, checked_at) = storage.get_version_info().await?;
97
98    // If the latest version was checked recently, return whether it's newer than the current one
99    if (now - checked_at) < chrono::Duration::hours(16) {
100        tracing::debug!("Skipping version retrieval as it was checked recently, latest: v{latest}");
101        return Ok(Some(latest).filter(|v| v > &current));
102    }
103
104    // A simple struct to deserialize the relevant fields from the GitHub API response
105    #[derive(Deserialize, Debug)]
106    struct Release {
107        tag_name: String,
108    }
109
110    // Fetch latest release from GitHub
111    let release: Release = reqwest::Client::new()
112        .get("https://api.github.com/repos/lasantosr/intelli-shell/releases/latest")
113        .header(header::USER_AGENT, "intelli-shell")
114        .timeout(REQUEST_TIMEOUT)
115        .send()
116        .await
117        .wrap_err("Failed to fetch latest release from GitHub")?
118        .json()
119        .await
120        .wrap_err("Failed to parse latest release response")?;
121
122    // Parse it
123    let tag_version = release.tag_name.trim_start_matches('v');
124    let latest = Version::parse(tag_version)
125        .wrap_err_with(|| format!("Failed to parse latest version from tag: {tag_version}"))?;
126
127    tracing::debug!("Latest version fetched: v{latest}");
128
129    // Store the new information in the database
130    storage.update_version_info(latest.clone(), now).await?;
131
132    // Return whether the latest version is newer than the current one
133    Ok(Some(latest).filter(|v| v > &current))
134}