use std::io::IsTerminal;
use std::path::PathBuf;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
const RELEASES_LATEST_URL: &str =
"https://api.github.com/repos/nolgiacorp/nolgia-cli/releases/latest";
const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const FETCH_TIMEOUT: Duration = Duration::from_secs(3);
const EXIT_GRACE: Duration = Duration::from_millis(400);
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Serialize, Deserialize)]
struct CheckCache {
checked_at: DateTime<Utc>,
latest: String,
}
#[derive(Deserialize)]
struct InstallMetadata {
method: String,
}
#[derive(Deserialize)]
struct LatestRelease {
tag_name: String,
}
pub struct UpdateCheck {
hint: Option<String>,
refresh: Option<tokio::task::JoinHandle<()>>,
}
pub fn start(json_output: bool) -> UpdateCheck {
if json_output || disabled() {
return UpdateCheck {
hint: None,
refresh: None,
};
}
let cache = read_cache();
let hint = cache
.as_ref()
.filter(|c| is_newer(&c.latest, CURRENT_VERSION))
.map(|c| hint_line(&c.latest, &upgrade_command()));
let refresh = match &cache {
Some(c)
if Utc::now()
.signed_duration_since(c.checked_at)
.to_std()
.map(|d| d < CHECK_INTERVAL)
.unwrap_or(true) =>
{
None
}
_ => Some(tokio::spawn(refresh_cache())),
};
UpdateCheck { hint, refresh }
}
impl UpdateCheck {
pub async fn finish(self) {
if let Some(handle) = self.refresh {
let _ = tokio::time::timeout(EXIT_GRACE, handle).await;
}
if let Some(hint) = self.hint {
eprintln!("{hint}");
}
}
}
fn disabled() -> bool {
let set = |name: &str| std::env::var(name).is_ok_and(|v| !v.is_empty());
set("NOLGIA_NO_UPDATE_CHECK")
|| set("CI")
|| set("NOLGIA_SURFACE")
|| !std::io::stderr().is_terminal()
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn nolgia_dir(env_override: &str, default_suffix: &str) -> Option<PathBuf> {
let base = match std::env::var_os(env_override) {
Some(dir) if !dir.is_empty() => PathBuf::from(dir),
_ => home_dir()?.join(default_suffix),
};
Some(base.join("nolgia"))
}
fn cache_path() -> Option<PathBuf> {
Some(nolgia_dir("XDG_STATE_HOME", ".local/state")?.join("update-check.json"))
}
fn metadata_path() -> Option<PathBuf> {
Some(nolgia_dir("XDG_CONFIG_HOME", ".config")?.join("install-metadata.json"))
}
fn read_cache() -> Option<CheckCache> {
let raw = std::fs::read_to_string(cache_path()?).ok()?;
serde_json::from_str(&raw).ok()
}
async fn refresh_cache() {
let Ok(client) = reqwest::Client::builder().timeout(FETCH_TIMEOUT).build() else {
return;
};
let Ok(response) = client
.get(RELEASES_LATEST_URL)
.header("User-Agent", format!("nolgia-cli/{CURRENT_VERSION}"))
.send()
.await
else {
return;
};
let Ok(release) = response.json::<LatestRelease>().await else {
return;
};
let latest = release.tag_name.trim_start_matches('v').to_string();
let cache = CheckCache {
checked_at: Utc::now(),
latest,
};
let Some(path) = cache_path() else { return };
let Some(parent) = path.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
return;
}
let Ok(body) = serde_json::to_vec(&cache) else {
return;
};
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, body).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
fn is_newer(candidate: &str, current: &str) -> bool {
match (parse_version(candidate), parse_version(current)) {
(Some(a), Some(b)) => a > b,
_ => false,
}
}
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
let mut parts = v.trim().trim_start_matches('v').splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next().unwrap_or("0").parse().ok()?;
Some((major, minor, patch))
}
fn install_method() -> Option<String> {
let raw = std::fs::read_to_string(metadata_path()?).ok()?;
let metadata: InstallMetadata = serde_json::from_str(&raw).ok()?;
Some(metadata.method)
}
fn upgrade_command() -> String {
upgrade_command_for(
install_method().as_deref(),
&std::env::current_exe()
.unwrap_or_default()
.to_string_lossy(),
)
}
fn upgrade_command_for(method: Option<&str>, exe_path: &str) -> String {
match method {
Some("npm") => "npm update -g @nolgia/cli".to_string(),
Some("install.sh") => {
"curl -fsSL https://raw.githubusercontent.com/nolgiacorp/nolgia-cli/main/install.sh | bash".to_string()
}
_ if exe_path.contains(".cargo/bin") => "cargo install nolgia-cli".to_string(),
_ if exe_path.contains("Cellar") || exe_path.contains("homebrew") => {
"brew upgrade nolgia".to_string()
}
_ => "curl -fsSL https://raw.githubusercontent.com/nolgiacorp/nolgia-cli/main/install.sh | bash".to_string(),
}
}
fn hint_line(latest: &str, upgrade: &str) -> String {
format!("nolgia {latest} is available (you have {CURRENT_VERSION}); upgrade with: {upgrade}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn newer_versions_compare_numerically() {
assert!(is_newer("0.2.10", "0.2.9"));
assert!(is_newer("v0.3.0", "0.2.1"));
assert!(!is_newer("0.2.1", "0.2.1"));
assert!(!is_newer("0.1.9", "0.2.0"));
assert!(!is_newer("garbage", "0.2.1"));
}
#[test]
fn upgrade_command_prefers_recorded_method() {
assert_eq!(
upgrade_command_for(Some("npm"), "/anywhere/nolgia"),
"npm update -g @nolgia/cli"
);
assert!(upgrade_command_for(Some("install.sh"), "/anywhere/nolgia").contains("install.sh"));
}
#[test]
fn upgrade_command_falls_back_to_exe_path() {
assert_eq!(
upgrade_command_for(None, "/Users/dev/.cargo/bin/nolgia"),
"cargo install nolgia-cli"
);
assert_eq!(
upgrade_command_for(None, "/opt/homebrew/Cellar/nolgia/0.2.1/bin/nolgia"),
"brew upgrade nolgia"
);
assert!(upgrade_command_for(None, "/usr/local/bin/nolgia").contains("install.sh"));
}
}