1use std::path::Path;
10use std::process::Command;
11use std::time::Duration;
12
13use anyhow::{Context, Result};
14
15use super::upgrade::InstallMethod;
16
17#[derive(Debug, Clone)]
19pub struct UpdateInfo {
20 pub current_version: String,
22 pub latest_version: String,
24 pub install_method: String,
26 pub current_git_hash: Option<String>,
28}
29
30impl UpdateInfo {
31 pub fn notification_messages(&self) -> Vec<String> {
40 let (current_display, latest_display) =
41 if let Some(ref current_hash) = self.current_git_hash {
42 let latest_hash = self
44 .latest_version
45 .strip_prefix("git:")
46 .unwrap_or(&self.latest_version);
47 (current_hash.clone(), latest_hash.to_string())
48 } else {
49 (
51 format!("v{}", self.current_version),
52 format!("v{}", self.latest_version),
53 )
54 };
55
56 vec![
57 format!(
58 "Update available: {} -> {}",
59 current_display, latest_display
60 ),
61 "Upgrade using `mi6 upgrade`".to_string(),
62 ]
63 }
64}
65
66pub fn check_for_update() -> Result<Option<UpdateInfo>> {
75 let current_version = env!("CARGO_PKG_VERSION");
76 let install_method = InstallMethod::detect()?;
77
78 check_for_update_with_method(current_version, &install_method)
79}
80
81pub fn check_for_update_with_method(
84 current_version: &str,
85 install_method: &InstallMethod,
86) -> Result<Option<UpdateInfo>> {
87 let method_name = install_method.name().to_string();
88
89 match install_method {
90 InstallMethod::Standalone => check_github_releases(current_version, &method_name),
91 InstallMethod::CargoRegistry => check_crates_io(current_version, &method_name),
92 InstallMethod::Homebrew => check_homebrew(current_version, &method_name),
93 InstallMethod::CargoPath(path) => check_git_source(current_version, path, &method_name),
94 }
95}
96
97fn check_github_releases(current_version: &str, method_name: &str) -> Result<Option<UpdateInfo>> {
99 let update = self_update::backends::github::Update::configure()
100 .repo_owner("paradigmxyz")
101 .repo_name("mi6")
102 .bin_name("mi6")
103 .current_version(current_version)
104 .build()
105 .context("failed to configure GitHub update checker")?;
106
107 let latest = update
108 .get_latest_release()
109 .context("failed to check GitHub releases")?;
110
111 let latest_version = latest.version.trim_start_matches('v').to_string();
112
113 if is_newer_version(&latest_version, current_version) {
114 Ok(Some(UpdateInfo {
115 current_version: current_version.to_string(),
116 latest_version,
117 install_method: method_name.to_string(),
118 current_git_hash: None,
119 }))
120 } else {
121 Ok(None)
122 }
123}
124
125#[derive(serde::Deserialize)]
127struct CratesIoResponse {
128 #[serde(rename = "crate")]
129 crate_info: CrateInfo,
130}
131
132#[derive(serde::Deserialize)]
133struct CrateInfo {
134 max_stable_version: String,
135}
136
137fn check_crates_io(current_version: &str, method_name: &str) -> Result<Option<UpdateInfo>> {
139 let response = ureq::get("https://crates.io/api/v1/crates/mi6")
141 .set(
142 "User-Agent",
143 &format!(
144 "mi6/{} (https://github.com/paradigmxyz/mi6)",
145 current_version
146 ),
147 )
148 .timeout(Duration::from_secs(10))
149 .call()
150 .context("failed to query crates.io API")?;
151
152 let body: CratesIoResponse = response
153 .into_json()
154 .context("failed to parse crates.io response")?;
155
156 let latest_version = body.crate_info.max_stable_version;
157
158 if is_newer_version(&latest_version, current_version) {
159 Ok(Some(UpdateInfo {
160 current_version: current_version.to_string(),
161 latest_version,
162 install_method: method_name.to_string(),
163 current_git_hash: None,
164 }))
165 } else {
166 Ok(None)
167 }
168}
169
170fn check_homebrew(current_version: &str, method_name: &str) -> Result<Option<UpdateInfo>> {
172 let output = Command::new("brew")
173 .args(["info", "--json=v2", "mi6"])
174 .output()
175 .context("failed to run brew info")?;
176
177 if !output.status.success() {
178 return Ok(None);
180 }
181
182 let stdout = String::from_utf8_lossy(&output.stdout);
183
184 #[derive(serde::Deserialize)]
186 struct BrewInfoResponse {
187 formulae: Vec<BrewFormula>,
188 }
189
190 #[derive(serde::Deserialize)]
191 struct BrewFormula {
192 versions: BrewVersions,
193 }
194
195 #[derive(serde::Deserialize)]
196 struct BrewVersions {
197 stable: String,
198 }
199
200 let info: BrewInfoResponse =
201 serde_json::from_str(&stdout).context("failed to parse brew info JSON")?;
202
203 let latest_version = info
204 .formulae
205 .first()
206 .map(|f| f.versions.stable.clone())
207 .unwrap_or_default();
208
209 if latest_version.is_empty() {
210 return Ok(None);
211 }
212
213 if is_newer_version(&latest_version, current_version) {
214 Ok(Some(UpdateInfo {
215 current_version: current_version.to_string(),
216 latest_version,
217 install_method: method_name.to_string(),
218 current_git_hash: None,
219 }))
220 } else {
221 Ok(None)
222 }
223}
224
225fn check_git_source(
227 current_version: &str,
228 path: &Path,
229 method_name: &str,
230) -> Result<Option<UpdateInfo>> {
231 let path_str = path.to_string_lossy();
232
233 let fetch_output = Command::new("git")
235 .args(["-C", &path_str, "fetch", "--quiet"])
236 .output();
237
238 let _ = fetch_output;
241
242 let local_output = Command::new("git")
244 .args(["-C", &path_str, "rev-parse", "HEAD"])
245 .output()
246 .context("failed to get local HEAD")?;
247
248 if !local_output.status.success() {
249 anyhow::bail!("git rev-parse HEAD failed");
250 }
251
252 let local_head = String::from_utf8_lossy(&local_output.stdout)
253 .trim()
254 .to_string();
255
256 let remote_branch = find_default_remote_branch(path)?;
258 let remote_output = Command::new("git")
259 .args(["-C", &path_str, "rev-parse", &remote_branch])
260 .output()
261 .context("failed to get remote HEAD")?;
262
263 if !remote_output.status.success() {
264 anyhow::bail!("git rev-parse {} failed", remote_branch);
265 }
266
267 let remote_head = String::from_utf8_lossy(&remote_output.stdout)
268 .trim()
269 .to_string();
270
271 if local_head != remote_head {
273 let merge_base_output = Command::new("git")
275 .args(["-C", &path_str, "merge-base", &local_head, &remote_head])
276 .output()
277 .context("failed to find merge base")?;
278
279 if merge_base_output.status.success() {
280 let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
281 .trim()
282 .to_string();
283
284 if merge_base == local_head {
286 let local_short = &local_head[..8.min(local_head.len())];
288 let remote_short = &remote_head[..8.min(remote_head.len())];
289 return Ok(Some(UpdateInfo {
290 current_version: current_version.to_string(),
291 latest_version: format!("git:{}", remote_short),
292 install_method: method_name.to_string(),
293 current_git_hash: Some(local_short.to_string()),
294 }));
295 }
296 }
297 }
298
299 Ok(None)
300}
301
302fn find_default_remote_branch(path: &Path) -> Result<String> {
304 let path_str = path.to_string_lossy();
305
306 let main_check = Command::new("git")
308 .args(["-C", &path_str, "rev-parse", "--verify", "origin/main"])
309 .output();
310
311 if let Ok(output) = main_check
312 && output.status.success()
313 {
314 return Ok("origin/main".to_string());
315 }
316
317 let master_check = Command::new("git")
319 .args(["-C", &path_str, "rev-parse", "--verify", "origin/master"])
320 .output();
321
322 if let Ok(output) = master_check
323 && output.status.success()
324 {
325 return Ok("origin/master".to_string());
326 }
327
328 Ok("origin/main".to_string())
330}
331
332fn is_newer_version(latest: &str, current: &str) -> bool {
337 let parse_version = |v: &str| -> (Vec<u32>, bool) {
339 let mut parts = Vec::new();
340 let mut has_prerelease = false;
341
342 for segment in v.split('.') {
343 if let Some(dash_pos) = segment.find('-') {
345 let num_part = &segment[..dash_pos];
346 if let Ok(n) = num_part.parse() {
347 parts.push(n);
348 }
349 has_prerelease = true;
350 } else if let Ok(n) = segment.parse() {
351 parts.push(n);
352 }
353 }
354 (parts, has_prerelease)
355 };
356
357 let (latest_parts, latest_is_prerelease) = parse_version(latest);
358 let (current_parts, current_is_prerelease) = parse_version(current);
359
360 for (l, c) in latest_parts.iter().zip(current_parts.iter()) {
362 match l.cmp(c) {
363 std::cmp::Ordering::Greater => return true,
364 std::cmp::Ordering::Less => return false,
365 std::cmp::Ordering::Equal => {}
366 }
367 }
368
369 if latest_parts.len() != current_parts.len() {
371 return latest_parts.len() > current_parts.len();
372 }
373
374 match (latest_is_prerelease, current_is_prerelease) {
378 (false, true) => true, (true, false) => false, _ => false, }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_is_newer_version() {
390 assert!(is_newer_version("1.0.1", "1.0.0"));
392 assert!(is_newer_version("1.1.0", "1.0.0"));
393 assert!(is_newer_version("2.0.0", "1.0.0"));
394
395 assert!(!is_newer_version("1.0.0", "1.0.0"));
397
398 assert!(!is_newer_version("1.0.0", "1.0.1"));
400 assert!(!is_newer_version("1.0.0", "2.0.0"));
401
402 assert!(is_newer_version("1.0.1", "1.0"));
404 assert!(!is_newer_version("1.0", "1.0.1"));
405
406 assert!(is_newer_version("1.0.1", "1.0.0-alpha"));
408 assert!(!is_newer_version("1.0.0-alpha", "1.0.1"));
409
410 assert!(is_newer_version("1.0.0", "1.0.0-alpha"));
413 assert!(is_newer_version("1.0.0", "1.0.0-beta"));
414 assert!(is_newer_version("1.0.0", "1.0.0-rc1"));
415
416 assert!(!is_newer_version("1.0.0-alpha", "1.0.0"));
418
419 assert!(!is_newer_version("1.0.0-alpha", "1.0.0-alpha"));
421 }
422
423 #[test]
424 fn test_update_info_notification_messages() {
425 let info = UpdateInfo {
427 current_version: "1.0.0".to_string(),
428 latest_version: "1.1.0".to_string(),
429 install_method: "Standalone".to_string(),
430 current_git_hash: None,
431 };
432 let messages = info.notification_messages();
433 assert_eq!(messages.len(), 2);
434 assert_eq!(messages[0], "Update available: v1.0.0 -> v1.1.0");
435 assert_eq!(messages[1], "Upgrade using `mi6 upgrade`");
436
437 let info_source = UpdateInfo {
439 current_version: "1.0.0".to_string(),
440 latest_version: "git:b697acb1".to_string(),
441 install_method: "Cargo (source)".to_string(),
442 current_git_hash: Some("a1b2c3d4".to_string()),
443 };
444 let messages_source = info_source.notification_messages();
445 assert_eq!(messages_source.len(), 2);
446 assert_eq!(messages_source[0], "Update available: a1b2c3d4 -> b697acb1");
447 assert_eq!(messages_source[1], "Upgrade using `mi6 upgrade`");
448 }
449}