1use anyhow::{Context, Result};
9use serde::Deserialize;
10
11const DEFAULT_REPO: &str = "navi-ai-org/navi";
12const INSTALL_SH: &str =
13 "https://raw.githubusercontent.com/navi-ai-org/navi/main/scripts/install.sh";
14const INSTALL_PS1: &str =
15 "https://raw.githubusercontent.com/navi-ai-org/navi/main/scripts/install.ps1";
16
17#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19pub struct UpdateInfo {
20 pub current_version: String,
22 pub latest_tag: String,
24 pub latest_version: String,
26 pub release_url: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub body: Option<String>,
31 #[serde(default)]
33 pub prerelease: bool,
34}
35
36impl UpdateInfo {
37 pub fn is_newer(&self) -> bool {
38 version_is_newer(&self.latest_version, &self.current_version)
39 }
40}
41
42#[derive(Debug, Deserialize)]
43struct GhRelease {
44 tag_name: String,
45 html_url: String,
46 #[serde(default)]
47 body: Option<String>,
48 #[serde(default)]
49 prerelease: bool,
50 #[serde(default)]
51 draft: bool,
52}
53
54pub fn current_version() -> &'static str {
57 env!("CARGO_PKG_VERSION")
58}
59
60pub fn normalize_version(v: &str) -> String {
62 v.trim().trim_start_matches('v').trim().to_string()
63}
64
65pub fn version_is_newer(candidate: &str, current: &str) -> bool {
67 let c = parse_semver(candidate);
68 let cur = parse_semver(current);
69 c > cur
70}
71
72fn parse_semver(v: &str) -> (u64, u64, u64) {
73 let v = normalize_version(v);
74 let mut parts = v.split(|c| c == '.' || c == '-' || c == '+');
75 let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
76 let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
77 let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
78 (major, minor, patch)
79}
80
81pub async fn check_for_update(
86 current: &str,
87 repo: Option<&str>,
88 include_prerelease: bool,
89) -> Result<Option<UpdateInfo>> {
90 let repo = repo.unwrap_or(DEFAULT_REPO);
91 let current_version = normalize_version(current);
92 let url = if include_prerelease {
93 format!("https://api.github.com/repos/{repo}/releases?per_page=5")
94 } else {
95 format!("https://api.github.com/repos/{repo}/releases/latest")
96 };
97
98 let client = reqwest::Client::builder()
99 .timeout(std::time::Duration::from_secs(15))
100 .user_agent(format!("navi/{current_version}"))
101 .build()
102 .context("build HTTP client for update check")?;
103
104 let release = if include_prerelease {
105 let list: Vec<GhRelease> = client
106 .get(&url)
107 .send()
108 .await
109 .context("fetch releases list")?
110 .error_for_status()
111 .context("releases list HTTP error")?
112 .json()
113 .await
114 .context("parse releases list")?;
115 list.into_iter()
116 .find(|r| !r.draft && (include_prerelease || !r.prerelease))
117 .context("no suitable release found")?
118 } else {
119 client
120 .get(&url)
121 .send()
122 .await
123 .context("fetch latest release")?
124 .error_for_status()
125 .context("latest release HTTP error")?
126 .json::<GhRelease>()
127 .await
128 .context("parse latest release")?
129 };
130
131 if release.draft {
132 return Ok(None);
133 }
134 if release.prerelease && !include_prerelease {
135 return Ok(None);
136 }
137
138 let latest_version = normalize_version(&release.tag_name);
139 if !version_is_newer(&latest_version, ¤t_version) {
140 return Ok(None);
141 }
142
143 Ok(Some(UpdateInfo {
144 current_version,
145 latest_tag: release.tag_name,
146 latest_version,
147 release_url: release.html_url,
148 body: release.body.filter(|b| !b.trim().is_empty()),
149 prerelease: release.prerelease,
150 }))
151}
152
153pub async fn apply_update(info: &UpdateInfo) -> Result<()> {
158 let version = info.latest_version.clone();
159 tokio::task::spawn_blocking(move || apply_update_blocking(&version))
160 .await
161 .context("update task join")??;
162 Ok(())
163}
164
165fn apply_update_blocking(version: &str) -> Result<()> {
166 let version = normalize_version(version);
167 match std::env::consts::OS {
168 "windows" => {
169 let status = std::process::Command::new("powershell")
171 .args([
172 "-NoProfile",
173 "-ExecutionPolicy",
174 "Bypass",
175 "-Command",
176 &format!(
177 "irm {INSTALL_PS1} | iex; if (Get-Command Install-Navi -ErrorAction SilentlyContinue) {{ Install-Navi -Version {version} }} else {{ & ([scriptblock]::Create((irm {INSTALL_PS1}))) -Version {version} }}"
178 ),
179 ])
180 .status()
181 .context("spawn powershell installer")?;
182 if !status.success() {
183 let tmp = std::env::temp_dir().join("navi-install.ps1");
185 let script = std::process::Command::new("powershell")
186 .args([
187 "-NoProfile",
188 "-Command",
189 &format!(
190 "Invoke-WebRequest -Uri '{INSTALL_PS1}' -OutFile '{}'",
191 tmp.display()
192 ),
193 ])
194 .status()
195 .context("download install.ps1")?;
196 if !script.success() {
197 anyhow::bail!("failed to download install.ps1");
198 }
199 let status = std::process::Command::new("powershell")
200 .args([
201 "-NoProfile",
202 "-ExecutionPolicy",
203 "Bypass",
204 "-File",
205 tmp.to_str().unwrap_or("navi-install.ps1"),
206 "-Version",
207 &version,
208 ])
209 .status()
210 .context("run install.ps1")?;
211 if !status.success() {
212 anyhow::bail!("install.ps1 exited with {status}");
213 }
214 }
215 Ok(())
216 }
217 _ => {
218 let status = std::process::Command::new("sh")
220 .args([
221 "-c",
222 &format!("curl -fsSL {INSTALL_SH} | sh -s -- --version {version}"),
223 ])
224 .status()
225 .context("spawn install.sh")?;
226 if !status.success() {
227 anyhow::bail!("install.sh exited with {status}");
228 }
229 Ok(())
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn normalize_strips_v() {
240 assert_eq!(normalize_version("v0.2.3"), "0.2.3");
241 assert_eq!(normalize_version("0.2.3"), "0.2.3");
242 }
243
244 #[test]
245 fn semver_compare() {
246 assert!(version_is_newer("0.2.3", "0.2.2"));
247 assert!(version_is_newer("1.0.0", "0.9.9"));
248 assert!(!version_is_newer("0.2.2", "0.2.3"));
249 assert!(!version_is_newer("0.2.3", "0.2.3"));
250 }
251}