use std::{
io::IsTerminal,
path::PathBuf,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use colored::Colorize;
use semver::Version;
use serde::{Deserialize, Serialize};
use tokio::task::JoinHandle;
const RELEASES_API_URL: &str =
"https://api.github.com/repos/s2-streamstore/s2/releases?per_page=50";
const CLI_TAG_PREFIX: &str = "s2-cli-v";
const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const FETCH_TIMEOUT: Duration = Duration::from_secs(3);
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Serialize, Deserialize)]
struct CheckState {
checked_at: u64,
latest_version: String,
}
fn state_path() -> Option<PathBuf> {
let mut path = dirs::cache_dir()?;
path.push("s2");
path.push("cli-update-check.toml");
Some(path)
}
fn load_state(path: &PathBuf) -> Option<CheckState> {
toml::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
fn save_state(path: &PathBuf, state: &CheckState) {
let Ok(contents) = toml::to_string(state) else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, contents);
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}
pub fn spawn_check() -> Option<JoinHandle<Option<Version>>> {
if std::env::var_os("S2_NO_UPDATE_CHECK").is_some() || !std::io::stderr().is_terminal() {
return None;
}
Some(tokio::spawn(check()))
}
async fn check() -> Option<Version> {
let path = state_path()?;
let now = unix_now();
let state = load_state(&path);
if let Some(state) = &state
&& now.saturating_sub(state.checked_at) < CHECK_INTERVAL.as_secs()
{
return None;
}
let fetched = fetch_latest().await;
let latest_version = fetched
.as_ref()
.map(ToString::to_string)
.or(state.map(|s| s.latest_version))
.unwrap_or_default();
save_state(
&path,
&CheckState {
checked_at: now,
latest_version,
},
);
let latest = fetched?;
let current = Version::parse(CURRENT_VERSION).ok()?;
(latest > current).then_some(latest)
}
async fn fetch_latest() -> Option<Version> {
#[derive(Deserialize)]
struct Release {
tag_name: String,
prerelease: bool,
draft: bool,
}
let client = reqwest::Client::builder()
.user_agent(concat!("s2-cli/", env!("CARGO_PKG_VERSION")))
.timeout(FETCH_TIMEOUT)
.build()
.ok()?;
let body = client
.get(RELEASES_API_URL)
.send()
.await
.ok()?
.error_for_status()
.ok()?
.text()
.await
.ok()?;
let releases: Vec<Release> = serde_json::from_str(&body).ok()?;
latest_cli_version(
releases
.iter()
.map(|r| (r.tag_name.as_str(), !r.prerelease && !r.draft)),
)
}
fn latest_cli_version<'a>(tags: impl IntoIterator<Item = (&'a str, bool)>) -> Option<Version> {
tags.into_iter()
.filter(|(_, released)| *released)
.filter_map(|(tag, _)| tag.strip_prefix(CLI_TAG_PREFIX))
.filter_map(|v| Version::parse(v).ok())
.max()
}
pub async fn notify(check: Option<JoinHandle<Option<Version>>>) {
let Some(handle) = check else {
return;
};
let Ok(Ok(Some(latest))) = tokio::time::timeout(FETCH_TIMEOUT, handle).await else {
return;
};
eprintln!(
"\n{} {} {}",
"A new release of s2-cli is available:".yellow(),
CURRENT_VERSION.cyan(),
format!("→ {latest}").cyan(),
);
eprintln!(
"{}",
"To upgrade: https://s2.dev/docs/quickstart#get-started-with-the-cli".yellow()
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn picks_latest_released_cli_tag() {
let tags = [
("s2-cli-v0.38.0", true),
("s2-lite-v0.38.0", true),
("s2-cli-v0.39.0", false),
("s2-cli-v0.38.2", true),
("s2-sdk-v0.31.8", true),
("s2-cli-v0.38.10", true),
];
assert_eq!(
latest_cli_version(tags),
Some(Version::new(0, 38, 10)),
"highest semver among released s2-cli tags"
);
assert_eq!(latest_cli_version([("s2-sdk-v0.31.8", true)]), None);
}
#[tokio::test]
#[ignore = "hits the GitHub API"]
async fn fetches_latest_cli_release() {
let latest = fetch_latest().await;
assert!(latest.is_some(), "expected a released s2-cli version");
}
}