use anyhow::{Context, Result};
use serde::Deserialize;
const DEFAULT_REPO: &str = "navi-ai-org/navi";
const INSTALL_SH: &str =
"https://raw.githubusercontent.com/navi-ai-org/navi/main/scripts/install.sh";
const INSTALL_PS1: &str =
"https://raw.githubusercontent.com/navi-ai-org/navi/main/scripts/install.ps1";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UpdateInfo {
pub current_version: String,
pub latest_tag: String,
pub latest_version: String,
pub release_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default)]
pub prerelease: bool,
}
impl UpdateInfo {
pub fn is_newer(&self) -> bool {
version_is_newer(&self.latest_version, &self.current_version)
}
}
#[derive(Debug, Deserialize)]
struct GhRelease {
tag_name: String,
html_url: String,
#[serde(default)]
body: Option<String>,
#[serde(default)]
prerelease: bool,
#[serde(default)]
draft: bool,
}
pub fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn normalize_version(v: &str) -> String {
v.trim().trim_start_matches('v').trim().to_string()
}
pub fn version_is_newer(candidate: &str, current: &str) -> bool {
let c = parse_semver(candidate);
let cur = parse_semver(current);
c > cur
}
fn parse_semver(v: &str) -> (u64, u64, u64) {
let v = normalize_version(v);
let mut parts = v.split(|c| c == '.' || c == '-' || c == '+');
let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
(major, minor, patch)
}
pub async fn check_for_update(
current: &str,
repo: Option<&str>,
include_prerelease: bool,
) -> Result<Option<UpdateInfo>> {
let repo = repo.unwrap_or(DEFAULT_REPO);
let current_version = normalize_version(current);
let url = if include_prerelease {
format!("https://api.github.com/repos/{repo}/releases?per_page=5")
} else {
format!("https://api.github.com/repos/{repo}/releases/latest")
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.user_agent(format!("navi/{current_version}"))
.build()
.context("build HTTP client for update check")?;
let release = if include_prerelease {
let list: Vec<GhRelease> = client
.get(&url)
.send()
.await
.context("fetch releases list")?
.error_for_status()
.context("releases list HTTP error")?
.json()
.await
.context("parse releases list")?;
list.into_iter()
.find(|r| !r.draft && (include_prerelease || !r.prerelease))
.context("no suitable release found")?
} else {
client
.get(&url)
.send()
.await
.context("fetch latest release")?
.error_for_status()
.context("latest release HTTP error")?
.json::<GhRelease>()
.await
.context("parse latest release")?
};
if release.draft {
return Ok(None);
}
if release.prerelease && !include_prerelease {
return Ok(None);
}
let latest_version = normalize_version(&release.tag_name);
if !version_is_newer(&latest_version, ¤t_version) {
return Ok(None);
}
Ok(Some(UpdateInfo {
current_version,
latest_tag: release.tag_name,
latest_version,
release_url: release.html_url,
body: release.body.filter(|b| !b.trim().is_empty()),
prerelease: release.prerelease,
}))
}
pub async fn apply_update(info: &UpdateInfo) -> Result<()> {
let version = info.latest_version.clone();
tokio::task::spawn_blocking(move || apply_update_blocking(&version))
.await
.context("update task join")??;
Ok(())
}
fn apply_update_blocking(version: &str) -> Result<()> {
let version = normalize_version(version);
match std::env::consts::OS {
"windows" => {
let status = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
&format!(
"irm {INSTALL_PS1} | iex; if (Get-Command Install-Navi -ErrorAction SilentlyContinue) {{ Install-Navi -Version {version} }} else {{ & ([scriptblock]::Create((irm {INSTALL_PS1}))) -Version {version} }}"
),
])
.status()
.context("spawn powershell installer")?;
if !status.success() {
let tmp = std::env::temp_dir().join("navi-install.ps1");
let script = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-Command",
&format!(
"Invoke-WebRequest -Uri '{INSTALL_PS1}' -OutFile '{}'",
tmp.display()
),
])
.status()
.context("download install.ps1")?;
if !script.success() {
anyhow::bail!("failed to download install.ps1");
}
let status = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
tmp.to_str().unwrap_or("navi-install.ps1"),
"-Version",
&version,
])
.status()
.context("run install.ps1")?;
if !status.success() {
anyhow::bail!("install.ps1 exited with {status}");
}
}
Ok(())
}
_ => {
let status = std::process::Command::new("sh")
.args([
"-c",
&format!("curl -fsSL {INSTALL_SH} | sh -s -- --version {version}"),
])
.status()
.context("spawn install.sh")?;
if !status.success() {
anyhow::bail!("install.sh exited with {status}");
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_strips_v() {
assert_eq!(normalize_version("v0.2.3"), "0.2.3");
assert_eq!(normalize_version("0.2.3"), "0.2.3");
}
#[test]
fn semver_compare() {
assert!(version_is_newer("0.2.3", "0.2.2"));
assert!(version_is_newer("1.0.0", "0.9.9"));
assert!(!version_is_newer("0.2.2", "0.2.3"));
assert!(!version_is_newer("0.2.3", "0.2.3"));
}
}