mi6_cli/commands/
update_check.rs

1//! Background update checking for mi6.
2//!
3//! Checks for available updates based on the installation method:
4//! - GitHub releases: Uses `self_update` crate to check latest release
5//! - crates.io: Queries the crates.io API
6//! - Homebrew: Runs `brew outdated mi6`
7//! - Git source: Checks if the remote has new commits
8
9use std::path::Path;
10use std::process::Command;
11use std::time::Duration;
12
13use anyhow::{Context, Result};
14
15use super::upgrade::InstallMethod;
16
17/// Information about an available update.
18#[derive(Debug, Clone)]
19pub struct UpdateInfo {
20    /// Current installed version.
21    pub current_version: String,
22    /// Latest available version.
23    pub latest_version: String,
24    /// How mi6 was installed.
25    pub install_method: String,
26    /// Current git commit hash (8 chars) for source installs.
27    pub current_git_hash: Option<String>,
28}
29
30impl UpdateInfo {
31    /// Format user-friendly update notification messages.
32    ///
33    /// Returns two messages:
34    /// 1. "Update available: <current> -> <new>"
35    /// 2. "Upgrade using `mi6 upgrade`"
36    ///
37    /// For source installs, uses 8-character commit hashes.
38    /// For other installs, uses version numbers with "v" prefix.
39    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                // Source install - show commit hashes (8 chars)
43                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                // Other install - show version numbers with v prefix
50                (
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
66/// Check for available updates based on installation method.
67///
68/// Returns `Ok(Some(UpdateInfo))` if an update is available,
69/// `Ok(None)` if already at the latest version,
70/// or an error if the check failed.
71///
72/// This function is designed to be called from a background thread
73/// and should not block the main thread for long.
74pub 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
81/// Check for updates with explicit version and install method.
82/// Useful for testing or when these are already known.
83pub 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
97/// Check GitHub releases for updates using the self_update crate.
98fn 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/// crates.io API response for crate info.
126#[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
137/// Check crates.io for updates.
138fn check_crates_io(current_version: &str, method_name: &str) -> Result<Option<UpdateInfo>> {
139    // crates.io API requires a User-Agent header
140    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
170/// Check Homebrew for updates.
171fn 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        // If brew info fails, silently skip the check rather than adding fallback complexity
179        return Ok(None);
180    }
181
182    let stdout = String::from_utf8_lossy(&output.stdout);
183
184    // Parse the JSON response to get the latest version
185    #[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
225/// Check git source for updates by comparing installed binary with local source.
226///
227/// This checks two things:
228/// 1. If the installed binary is older than the local source (primary check)
229/// 2. If the local source is behind the remote (secondary check)
230fn 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    // First, fetch from remote with a timeout to avoid blocking.
238    // Use git config options to timeout if transfer is too slow:
239    // - lowSpeedLimit: minimum bytes/sec (1000 = 1KB/s)
240    // - lowSpeedTime: seconds to wait at low speed before aborting (5s)
241    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    // Ignore fetch errors - we'll just compare with whatever remote state we have
255    // Remote might not be reachable, which is fine
256    let _ = fetch_output;
257
258    // Get local HEAD commit
259    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    // Check if installed binary is behind local source
274    // The installed binary's git commit is embedded at compile time
275    let installed_commit = option_env!("MI6_GIT_COMMIT").filter(|s| !s.is_empty());
276
277    if let Some(installed) = installed_commit {
278        // If installed commit doesn't match local HEAD, check if local is ahead
279        if !local_head.starts_with(installed) {
280            // Check if installed commit is an ancestor of local HEAD
281            // (i.e., local HEAD is newer than the installed binary)
282            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    // Also check if remote is ahead of local (user may need to pull first)
307    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 and remote differ, check if remote is ahead
322    if local_head != remote_head {
323        // Check if remote is ahead of local
324        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            // Remote is ahead if merge base equals local HEAD
335            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
350/// Find the default remote branch (origin/main or origin/master).
351fn find_default_remote_branch(path: &Path) -> Result<String> {
352    let path_str = path.to_string_lossy();
353
354    // Try origin/main first
355    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    // Fall back to origin/master
366    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    // Default to origin/main if neither exists (will fail later with a clear error)
377    Ok("origin/main".to_string())
378}
379
380/// Compare two semantic versions to check if `latest` is newer than `current`.
381///
382/// Handles pre-release versions: stable releases are considered newer than
383/// pre-release versions of the same base version (e.g., 1.0.0 > 1.0.0-alpha).
384fn is_newer_version(latest: &str, current: &str) -> bool {
385    // Split version into base parts and whether it has a pre-release suffix
386    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            // Check if this segment has a pre-release suffix
392            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    // Compare numeric parts first
409    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 one version has more parts, check those
418    if latest_parts.len() != current_parts.len() {
419        return latest_parts.len() > current_parts.len();
420    }
421
422    // Same base version - check pre-release status
423    // A stable release (no prerelease) is newer than a prerelease
424    // e.g., 1.0.0 > 1.0.0-alpha
425    match (latest_is_prerelease, current_is_prerelease) {
426        (false, true) => true,  // latest is stable, current is prerelease
427        (true, false) => false, // latest is prerelease, current is stable
428        _ => false,             // both same type, not newer
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn test_is_newer_version() {
438        // Basic version comparisons
439        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        // Same version
444        assert!(!is_newer_version("1.0.0", "1.0.0"));
445
446        // Older version
447        assert!(!is_newer_version("1.0.0", "1.0.1"));
448        assert!(!is_newer_version("1.0.0", "2.0.0"));
449
450        // Version with more parts
451        assert!(is_newer_version("1.0.1", "1.0"));
452        assert!(!is_newer_version("1.0", "1.0.1"));
453
454        // Pre-release versions - newer major/minor/patch wins
455        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        // Pre-release to stable of SAME version - stable is newer
459        // This is the key edge case from the review
460        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        // Stable to pre-release of same version - not newer
465        assert!(!is_newer_version("1.0.0-alpha", "1.0.0"));
466
467        // Same pre-release is not newer
468        assert!(!is_newer_version("1.0.0-alpha", "1.0.0-alpha"));
469    }
470
471    #[test]
472    fn test_update_info_notification_messages() {
473        // Test non-source install (version numbers)
474        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        // Test source install (commit hashes)
486        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}