1use std::path::Path;
10use std::process::{Command, Stdio};
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 git_command_detached() -> Command {
234 let mut cmd = Command::new("git");
235
236 cmd.stdin(Stdio::null());
238 cmd.stdout(Stdio::piped());
239 cmd.stderr(Stdio::piped());
240
241 cmd.env("GIT_TERMINAL_PROMPT", "0");
243 cmd.env("GIT_ASKPASS", "");
244 cmd.env("SSH_ASKPASS", "");
245 cmd.env(
246 "GIT_SSH_COMMAND",
247 "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
248 );
249
250 cmd
251}
252
253fn check_git_source(
259 current_version: &str,
260 path: &Path,
261 method_name: &str,
262) -> Result<Option<UpdateInfo>> {
263 let path_str = path.to_string_lossy();
264
265 let _fetch_result = git_command_detached()
276 .args([
277 "-C",
278 &path_str,
279 "-c",
280 "http.lowSpeedLimit=1000",
281 "-c",
282 "http.lowSpeedTime=5",
283 "fetch",
284 "--quiet",
285 ])
286 .output();
287
288 let local_output = git_command_detached()
296 .args(["-C", &path_str, "rev-parse", "HEAD"])
297 .output()
298 .context("failed to get local HEAD")?;
299
300 if !local_output.status.success() {
301 anyhow::bail!("git rev-parse HEAD failed");
302 }
303
304 let local_head = String::from_utf8_lossy(&local_output.stdout)
305 .trim()
306 .to_string();
307 let local_short = &local_head[..8.min(local_head.len())];
308
309 let installed_commit = option_env!("MI6_GIT_COMMIT").filter(|s| !s.is_empty());
312
313 if let Some(installed) = installed_commit {
314 if !local_head.starts_with(installed) {
316 let is_ancestor = git_command_detached()
319 .args([
320 "-C",
321 &path_str,
322 "merge-base",
323 "--is-ancestor",
324 installed,
325 &local_head,
326 ])
327 .status()
328 .map(|s| s.success())
329 .unwrap_or(false);
330
331 if is_ancestor {
332 return Ok(Some(UpdateInfo {
333 current_version: current_version.to_string(),
334 latest_version: format!("git:{}", local_short),
335 install_method: method_name.to_string(),
336 current_git_hash: Some(installed.to_string()),
337 }));
338 }
339 }
340 }
341
342 let remote_branch = find_default_remote_branch(path)?;
344 let remote_output = git_command_detached()
345 .args(["-C", &path_str, "rev-parse", &remote_branch])
346 .output()
347 .context("failed to get remote HEAD")?;
348
349 if !remote_output.status.success() {
350 anyhow::bail!("git rev-parse {} failed", remote_branch);
351 }
352
353 let remote_head = String::from_utf8_lossy(&remote_output.stdout)
354 .trim()
355 .to_string();
356
357 if local_head != remote_head {
359 let merge_base_output = git_command_detached()
361 .args(["-C", &path_str, "merge-base", &local_head, &remote_head])
362 .output()
363 .context("failed to find merge base")?;
364
365 if merge_base_output.status.success() {
366 let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
367 .trim()
368 .to_string();
369
370 if merge_base == local_head {
372 let remote_short = &remote_head[..8.min(remote_head.len())];
373 return Ok(Some(UpdateInfo {
374 current_version: current_version.to_string(),
375 latest_version: format!("git:{}", remote_short),
376 install_method: method_name.to_string(),
377 current_git_hash: Some(local_short.to_string()),
378 }));
379 }
380 }
381 }
382
383 Ok(None)
384}
385
386fn find_default_remote_branch(path: &Path) -> Result<String> {
388 let path_str = path.to_string_lossy();
389
390 let main_check = git_command_detached()
392 .args(["-C", &path_str, "rev-parse", "--verify", "origin/main"])
393 .output();
394
395 if let Ok(output) = main_check
396 && output.status.success()
397 {
398 return Ok("origin/main".to_string());
399 }
400
401 let master_check = git_command_detached()
403 .args(["-C", &path_str, "rev-parse", "--verify", "origin/master"])
404 .output();
405
406 if let Ok(output) = master_check
407 && output.status.success()
408 {
409 return Ok("origin/master".to_string());
410 }
411
412 Ok("origin/main".to_string())
414}
415
416fn is_newer_version(latest: &str, current: &str) -> bool {
421 let parse_version = |v: &str| -> (Vec<u32>, bool) {
423 let mut parts = Vec::new();
424 let mut has_prerelease = false;
425
426 for segment in v.split('.') {
427 if let Some(dash_pos) = segment.find('-') {
429 let num_part = &segment[..dash_pos];
430 if let Ok(n) = num_part.parse() {
431 parts.push(n);
432 }
433 has_prerelease = true;
434 } else if let Ok(n) = segment.parse() {
435 parts.push(n);
436 }
437 }
438 (parts, has_prerelease)
439 };
440
441 let (latest_parts, latest_is_prerelease) = parse_version(latest);
442 let (current_parts, current_is_prerelease) = parse_version(current);
443
444 for (l, c) in latest_parts.iter().zip(current_parts.iter()) {
446 match l.cmp(c) {
447 std::cmp::Ordering::Greater => return true,
448 std::cmp::Ordering::Less => return false,
449 std::cmp::Ordering::Equal => {}
450 }
451 }
452
453 if latest_parts.len() != current_parts.len() {
455 return latest_parts.len() > current_parts.len();
456 }
457
458 match (latest_is_prerelease, current_is_prerelease) {
462 (false, true) => true, (true, false) => false, _ => false, }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn test_is_newer_version() {
474 assert!(is_newer_version("1.0.1", "1.0.0"));
476 assert!(is_newer_version("1.1.0", "1.0.0"));
477 assert!(is_newer_version("2.0.0", "1.0.0"));
478
479 assert!(!is_newer_version("1.0.0", "1.0.0"));
481
482 assert!(!is_newer_version("1.0.0", "1.0.1"));
484 assert!(!is_newer_version("1.0.0", "2.0.0"));
485
486 assert!(is_newer_version("1.0.1", "1.0"));
488 assert!(!is_newer_version("1.0", "1.0.1"));
489
490 assert!(is_newer_version("1.0.1", "1.0.0-alpha"));
492 assert!(!is_newer_version("1.0.0-alpha", "1.0.1"));
493
494 assert!(is_newer_version("1.0.0", "1.0.0-alpha"));
497 assert!(is_newer_version("1.0.0", "1.0.0-beta"));
498 assert!(is_newer_version("1.0.0", "1.0.0-rc1"));
499
500 assert!(!is_newer_version("1.0.0-alpha", "1.0.0"));
502
503 assert!(!is_newer_version("1.0.0-alpha", "1.0.0-alpha"));
505 }
506
507 #[test]
508 fn test_update_info_notification_messages() {
509 let info = UpdateInfo {
511 current_version: "1.0.0".to_string(),
512 latest_version: "1.1.0".to_string(),
513 install_method: "Standalone".to_string(),
514 current_git_hash: None,
515 };
516 let messages = info.notification_messages();
517 assert_eq!(messages.len(), 2);
518 assert_eq!(messages[0], "Update available: v1.0.0 -> v1.1.0");
519 assert_eq!(messages[1], "Upgrade using `mi6 upgrade`");
520
521 let info_source = UpdateInfo {
523 current_version: "1.0.0".to_string(),
524 latest_version: "git:b697acb1".to_string(),
525 install_method: "Cargo (source)".to_string(),
526 current_git_hash: Some("a1b2c3d4".to_string()),
527 };
528 let messages_source = info_source.notification_messages();
529 assert_eq!(messages_source.len(), 2);
530 assert_eq!(messages_source[0], "Update available: a1b2c3d4 -> b697acb1");
531 assert_eq!(messages_source[1], "Upgrade using `mi6 upgrade`");
532 }
533}