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 local HEAD with remote.
226fn 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    // First, fetch from remote (with a timeout to avoid blocking)
234    let fetch_output = Command::new("git")
235        .args(["-C", &path_str, "fetch", "--quiet"])
236        .output();
237
238    // Ignore fetch errors - we'll just compare with whatever remote state we have
239    // Remote might not be reachable, which is fine
240    let _ = fetch_output;
241
242    // Get local HEAD commit
243    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    // Get remote HEAD (origin/main or origin/master)
257    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 and remote differ, check if remote is ahead
272    if local_head != remote_head {
273        // Check if remote is ahead of local
274        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            // Remote is ahead if merge base equals local HEAD
285            if merge_base == local_head {
286                // Get short hashes for display (8 chars)
287                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
302/// Find the default remote branch (origin/main or origin/master).
303fn find_default_remote_branch(path: &Path) -> Result<String> {
304    let path_str = path.to_string_lossy();
305
306    // Try origin/main first
307    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    // Fall back to origin/master
318    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    // Default to origin/main if neither exists (will fail later with a clear error)
329    Ok("origin/main".to_string())
330}
331
332/// Compare two semantic versions to check if `latest` is newer than `current`.
333///
334/// Handles pre-release versions: stable releases are considered newer than
335/// pre-release versions of the same base version (e.g., 1.0.0 > 1.0.0-alpha).
336fn is_newer_version(latest: &str, current: &str) -> bool {
337    // Split version into base parts and whether it has a pre-release suffix
338    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            // Check if this segment has a pre-release suffix
344            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    // Compare numeric parts first
361    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 one version has more parts, check those
370    if latest_parts.len() != current_parts.len() {
371        return latest_parts.len() > current_parts.len();
372    }
373
374    // Same base version - check pre-release status
375    // A stable release (no prerelease) is newer than a prerelease
376    // e.g., 1.0.0 > 1.0.0-alpha
377    match (latest_is_prerelease, current_is_prerelease) {
378        (false, true) => true,  // latest is stable, current is prerelease
379        (true, false) => false, // latest is prerelease, current is stable
380        _ => false,             // both same type, not newer
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_is_newer_version() {
390        // Basic version comparisons
391        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        // Same version
396        assert!(!is_newer_version("1.0.0", "1.0.0"));
397
398        // Older version
399        assert!(!is_newer_version("1.0.0", "1.0.1"));
400        assert!(!is_newer_version("1.0.0", "2.0.0"));
401
402        // Version with more parts
403        assert!(is_newer_version("1.0.1", "1.0"));
404        assert!(!is_newer_version("1.0", "1.0.1"));
405
406        // Pre-release versions - newer major/minor/patch wins
407        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        // Pre-release to stable of SAME version - stable is newer
411        // This is the key edge case from the review
412        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        // Stable to pre-release of same version - not newer
417        assert!(!is_newer_version("1.0.0-alpha", "1.0.0"));
418
419        // Same pre-release is not newer
420        assert!(!is_newer_version("1.0.0-alpha", "1.0.0-alpha"));
421    }
422
423    #[test]
424    fn test_update_info_notification_messages() {
425        // Test non-source install (version numbers)
426        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        // Test source install (commit hashes)
438        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}