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, Stdio};
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/// Create a git command that is fully detached from the terminal.
226///
227/// This prevents background git operations from:
228/// - Prompting for SSH passphrases (which would steal terminal input)
229/// - Prompting for credentials via askpass programs
230/// - Causing visual glitches by writing to the terminal
231///
232/// SSH is configured with BatchMode=yes to fail fast instead of prompting.
233fn git_command_detached() -> Command {
234    let mut cmd = Command::new("git");
235
236    // Detach from terminal - critical for preventing input stealing
237    cmd.stdin(Stdio::null());
238    cmd.stdout(Stdio::piped());
239    cmd.stderr(Stdio::piped());
240
241    // Disable all credential/password prompts
242    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
253/// Check git source for updates by comparing installed binary with local source.
254///
255/// This checks two things:
256/// 1. If the installed binary is older than the local source (primary check)
257/// 2. If the local source is behind the remote (secondary check)
258fn 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    // Fetch from remote to check for updates.
266    // Uses git_command_detached() to prevent SSH passphrase prompts from
267    // stealing terminal input and causing visual glitches.
268    //
269    // SSH BatchMode=yes causes SSH to fail immediately if authentication
270    // requires user interaction, rather than prompting (and blocking/glitching).
271    //
272    // Timeout config options abort if transfer is too slow:
273    // - lowSpeedLimit: minimum bytes/sec (1000 = 1KB/s)
274    // - lowSpeedTime: seconds to wait at low speed before aborting (5s)
275    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    // Ignore fetch errors - we'll just compare with whatever remote state we have.
289    // Common expected failures:
290    // - SSH key requires passphrase (BatchMode=yes causes immediate failure)
291    // - Network unreachable
292    // - Remote doesn't exist
293
294    // Get local HEAD commit
295    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    // Check if installed binary is behind local source
310    // The installed binary's git commit is embedded at compile time
311    let installed_commit = option_env!("MI6_GIT_COMMIT").filter(|s| !s.is_empty());
312
313    if let Some(installed) = installed_commit {
314        // If installed commit doesn't match local HEAD, check if local is ahead
315        if !local_head.starts_with(installed) {
316            // Check if installed commit is an ancestor of local HEAD
317            // (i.e., local HEAD is newer than the installed binary)
318            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    // Also check if remote is ahead of local (user may need to pull first)
343    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 and remote differ, check if remote is ahead
358    if local_head != remote_head {
359        // Check if remote is ahead of local
360        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            // Remote is ahead if merge base equals local HEAD
371            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
386/// Find the default remote branch (origin/main or origin/master).
387fn find_default_remote_branch(path: &Path) -> Result<String> {
388    let path_str = path.to_string_lossy();
389
390    // Try origin/main first
391    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    // Fall back to origin/master
402    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    // Default to origin/main if neither exists (will fail later with a clear error)
413    Ok("origin/main".to_string())
414}
415
416/// Compare two semantic versions to check if `latest` is newer than `current`.
417///
418/// Handles pre-release versions: stable releases are considered newer than
419/// pre-release versions of the same base version (e.g., 1.0.0 > 1.0.0-alpha).
420fn is_newer_version(latest: &str, current: &str) -> bool {
421    // Split version into base parts and whether it has a pre-release suffix
422    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            // Check if this segment has a pre-release suffix
428            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    // Compare numeric parts first
445    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 one version has more parts, check those
454    if latest_parts.len() != current_parts.len() {
455        return latest_parts.len() > current_parts.len();
456    }
457
458    // Same base version - check pre-release status
459    // A stable release (no prerelease) is newer than a prerelease
460    // e.g., 1.0.0 > 1.0.0-alpha
461    match (latest_is_prerelease, current_is_prerelease) {
462        (false, true) => true,  // latest is stable, current is prerelease
463        (true, false) => false, // latest is prerelease, current is stable
464        _ => false,             // both same type, not newer
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_is_newer_version() {
474        // Basic version comparisons
475        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        // Same version
480        assert!(!is_newer_version("1.0.0", "1.0.0"));
481
482        // Older version
483        assert!(!is_newer_version("1.0.0", "1.0.1"));
484        assert!(!is_newer_version("1.0.0", "2.0.0"));
485
486        // Version with more parts
487        assert!(is_newer_version("1.0.1", "1.0"));
488        assert!(!is_newer_version("1.0", "1.0.1"));
489
490        // Pre-release versions - newer major/minor/patch wins
491        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        // Pre-release to stable of SAME version - stable is newer
495        // This is the key edge case from the review
496        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        // Stable to pre-release of same version - not newer
501        assert!(!is_newer_version("1.0.0-alpha", "1.0.0"));
502
503        // Same pre-release is not newer
504        assert!(!is_newer_version("1.0.0-alpha", "1.0.0-alpha"));
505    }
506
507    #[test]
508    fn test_update_info_notification_messages() {
509        // Test non-source install (version numbers)
510        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        // Test source install (commit hashes)
522        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}