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(
231 current_version: &str,
232 path: &Path,
233 method_name: &str,
234) -> Result<Option<UpdateInfo>> {
235 let path_str = path.to_string_lossy();
236
237 let fetch_output = Command::new("git")
242 .args([
243 "-C",
244 &path_str,
245 "-c",
246 "http.lowSpeedLimit=1000",
247 "-c",
248 "http.lowSpeedTime=5",
249 "fetch",
250 "--quiet",
251 ])
252 .output();
253
254 let _ = fetch_output;
257
258 let local_output = Command::new("git")
260 .args(["-C", &path_str, "rev-parse", "HEAD"])
261 .output()
262 .context("failed to get local HEAD")?;
263
264 if !local_output.status.success() {
265 anyhow::bail!("git rev-parse HEAD failed");
266 }
267
268 let local_head = String::from_utf8_lossy(&local_output.stdout)
269 .trim()
270 .to_string();
271 let local_short = &local_head[..8.min(local_head.len())];
272
273 let installed_commit = option_env!("MI6_GIT_COMMIT").filter(|s| !s.is_empty());
276
277 if let Some(installed) = installed_commit {
278 if !local_head.starts_with(installed) {
280 let is_ancestor = Command::new("git")
283 .args([
284 "-C",
285 &path_str,
286 "merge-base",
287 "--is-ancestor",
288 installed,
289 &local_head,
290 ])
291 .status()
292 .map(|s| s.success())
293 .unwrap_or(false);
294
295 if is_ancestor {
296 return Ok(Some(UpdateInfo {
297 current_version: current_version.to_string(),
298 latest_version: format!("git:{}", local_short),
299 install_method: method_name.to_string(),
300 current_git_hash: Some(installed.to_string()),
301 }));
302 }
303 }
304 }
305
306 let remote_branch = find_default_remote_branch(path)?;
308 let remote_output = Command::new("git")
309 .args(["-C", &path_str, "rev-parse", &remote_branch])
310 .output()
311 .context("failed to get remote HEAD")?;
312
313 if !remote_output.status.success() {
314 anyhow::bail!("git rev-parse {} failed", remote_branch);
315 }
316
317 let remote_head = String::from_utf8_lossy(&remote_output.stdout)
318 .trim()
319 .to_string();
320
321 if local_head != remote_head {
323 let merge_base_output = Command::new("git")
325 .args(["-C", &path_str, "merge-base", &local_head, &remote_head])
326 .output()
327 .context("failed to find merge base")?;
328
329 if merge_base_output.status.success() {
330 let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
331 .trim()
332 .to_string();
333
334 if merge_base == local_head {
336 let remote_short = &remote_head[..8.min(remote_head.len())];
337 return Ok(Some(UpdateInfo {
338 current_version: current_version.to_string(),
339 latest_version: format!("git:{}", remote_short),
340 install_method: method_name.to_string(),
341 current_git_hash: Some(local_short.to_string()),
342 }));
343 }
344 }
345 }
346
347 Ok(None)
348}
349
350fn find_default_remote_branch(path: &Path) -> Result<String> {
352 let path_str = path.to_string_lossy();
353
354 let main_check = Command::new("git")
356 .args(["-C", &path_str, "rev-parse", "--verify", "origin/main"])
357 .output();
358
359 if let Ok(output) = main_check
360 && output.status.success()
361 {
362 return Ok("origin/main".to_string());
363 }
364
365 let master_check = Command::new("git")
367 .args(["-C", &path_str, "rev-parse", "--verify", "origin/master"])
368 .output();
369
370 if let Ok(output) = master_check
371 && output.status.success()
372 {
373 return Ok("origin/master".to_string());
374 }
375
376 Ok("origin/main".to_string())
378}
379
380fn is_newer_version(latest: &str, current: &str) -> bool {
385 let parse_version = |v: &str| -> (Vec<u32>, bool) {
387 let mut parts = Vec::new();
388 let mut has_prerelease = false;
389
390 for segment in v.split('.') {
391 if let Some(dash_pos) = segment.find('-') {
393 let num_part = &segment[..dash_pos];
394 if let Ok(n) = num_part.parse() {
395 parts.push(n);
396 }
397 has_prerelease = true;
398 } else if let Ok(n) = segment.parse() {
399 parts.push(n);
400 }
401 }
402 (parts, has_prerelease)
403 };
404
405 let (latest_parts, latest_is_prerelease) = parse_version(latest);
406 let (current_parts, current_is_prerelease) = parse_version(current);
407
408 for (l, c) in latest_parts.iter().zip(current_parts.iter()) {
410 match l.cmp(c) {
411 std::cmp::Ordering::Greater => return true,
412 std::cmp::Ordering::Less => return false,
413 std::cmp::Ordering::Equal => {}
414 }
415 }
416
417 if latest_parts.len() != current_parts.len() {
419 return latest_parts.len() > current_parts.len();
420 }
421
422 match (latest_is_prerelease, current_is_prerelease) {
426 (false, true) => true, (true, false) => false, _ => false, }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn test_is_newer_version() {
438 assert!(is_newer_version("1.0.1", "1.0.0"));
440 assert!(is_newer_version("1.1.0", "1.0.0"));
441 assert!(is_newer_version("2.0.0", "1.0.0"));
442
443 assert!(!is_newer_version("1.0.0", "1.0.0"));
445
446 assert!(!is_newer_version("1.0.0", "1.0.1"));
448 assert!(!is_newer_version("1.0.0", "2.0.0"));
449
450 assert!(is_newer_version("1.0.1", "1.0"));
452 assert!(!is_newer_version("1.0", "1.0.1"));
453
454 assert!(is_newer_version("1.0.1", "1.0.0-alpha"));
456 assert!(!is_newer_version("1.0.0-alpha", "1.0.1"));
457
458 assert!(is_newer_version("1.0.0", "1.0.0-alpha"));
461 assert!(is_newer_version("1.0.0", "1.0.0-beta"));
462 assert!(is_newer_version("1.0.0", "1.0.0-rc1"));
463
464 assert!(!is_newer_version("1.0.0-alpha", "1.0.0"));
466
467 assert!(!is_newer_version("1.0.0-alpha", "1.0.0-alpha"));
469 }
470
471 #[test]
472 fn test_update_info_notification_messages() {
473 let info = UpdateInfo {
475 current_version: "1.0.0".to_string(),
476 latest_version: "1.1.0".to_string(),
477 install_method: "Standalone".to_string(),
478 current_git_hash: None,
479 };
480 let messages = info.notification_messages();
481 assert_eq!(messages.len(), 2);
482 assert_eq!(messages[0], "Update available: v1.0.0 -> v1.1.0");
483 assert_eq!(messages[1], "Upgrade using `mi6 upgrade`");
484
485 let info_source = UpdateInfo {
487 current_version: "1.0.0".to_string(),
488 latest_version: "git:b697acb1".to_string(),
489 install_method: "Cargo (source)".to_string(),
490 current_git_hash: Some("a1b2c3d4".to_string()),
491 };
492 let messages_source = info_source.notification_messages();
493 assert_eq!(messages_source.len(), 2);
494 assert_eq!(messages_source[0], "Update available: a1b2c3d4 -> b697acb1");
495 assert_eq!(messages_source[1], "Upgrade using `mi6 upgrade`");
496 }
497}