Skip to main content

intelli_shell/service/
version.rs

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