tuicr 0.20.0

Review AI-generated diffs like a GitHub pull request, right from your terminal.
Documentation
use std::time::Duration;

use ureq::Agent;

const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
const CHECK_TIMEOUT: Duration = Duration::from_secs(3);

#[derive(Debug, Clone)]
pub struct UpdateInfo {
    pub current_version: String,
    pub latest_version: String,
    pub update_available: bool,
    pub is_ahead: bool,
}

pub enum UpdateCheckResult {
    UpdateAvailable(UpdateInfo),
    UpToDate(UpdateInfo),
    AheadOfRelease(UpdateInfo),
    Failed(String),
}

pub fn check_for_updates() -> UpdateCheckResult {
    let config = Agent::config_builder()
        .timeout_global(Some(CHECK_TIMEOUT))
        .build();
    let agent: Agent = config.into();
    let crates_io_url = crates_io_url();
    let response = match agent.get(&crates_io_url).call() {
        Ok(response) => response,
        Err(error) => return UpdateCheckResult::Failed(format!("Network error: {error}")),
    };
    let body: serde_json::Value = match response.into_body().read_json() {
        Ok(body) => body,
        Err(error) => {
            return UpdateCheckResult::Failed(format!("Failed to parse response: {error}"));
        }
    };

    classify_versions(env!("CARGO_PKG_VERSION"), latest_version(&body))
}

fn crates_io_url() -> String {
    format!("{CRATES_IO_API_BASE}/{}", env!("CARGO_PKG_NAME"))
}

fn latest_version(body: &serde_json::Value) -> Option<&str> {
    body.get("crate")?.get("max_version")?.as_str()
}

fn classify_versions(current: &str, latest: Option<&str>) -> UpdateCheckResult {
    let Some(latest) = latest else {
        return UpdateCheckResult::Failed("Could not find version info".to_string());
    };
    let update_available = is_newer_version(current, latest);
    let is_ahead = is_newer_version(latest, current);
    let info = UpdateInfo {
        current_version: current.to_string(),
        latest_version: latest.to_string(),
        update_available,
        is_ahead,
    };

    if update_available {
        UpdateCheckResult::UpdateAvailable(info)
    } else if is_ahead {
        UpdateCheckResult::AheadOfRelease(info)
    } else {
        UpdateCheckResult::UpToDate(info)
    }
}

pub(super) fn is_newer_version(current: &str, latest: &str) -> bool {
    semver::Version::parse(latest)
        .ok()
        .zip(semver::Version::parse(current).ok())
        .is_some_and(|(latest_version, current_version)| latest_version > current_version)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classifies_available_current_and_ahead_versions() {
        assert!(matches!(
            classify_versions("1.0.0", Some("1.1.0")),
            UpdateCheckResult::UpdateAvailable(_)
        ));
        assert!(matches!(
            classify_versions("1.0.0", Some("1.0.0")),
            UpdateCheckResult::UpToDate(_)
        ));
        assert!(matches!(
            classify_versions("1.1.0", Some("1.0.0")),
            UpdateCheckResult::AheadOfRelease(_)
        ));
        assert!(matches!(
            classify_versions("1.0.0", None),
            UpdateCheckResult::Failed(_)
        ));
    }

    #[test]
    fn compares_supported_and_invalid_versions() {
        assert!(is_newer_version("0.5.0", "0.6.0"));
        assert!(is_newer_version("0.5.0", "1.0.0"));
        assert!(is_newer_version("0.5.0", "0.5.1"));
        assert!(is_newer_version("1.0.0-beta.1", "1.0.0"));
        assert!(is_newer_version("1.0.0-beta.1", "1.0.0-beta.2"));
        assert!(!is_newer_version("0.5", "0.5.1"));
        assert!(!is_newer_version("0.5.0", "0.5.0"));
        assert!(!is_newer_version("0.6.0", "0.5.0"));
        assert!(!is_newer_version("dev", "1.0.0"));
        assert!(!is_newer_version("1.0.0", "dev"));
        assert!(!is_newer_version("1", "2"));
    }

    #[test]
    fn builds_crates_io_url_from_package_name() {
        assert_eq!(
            crates_io_url(),
            format!("https://crates.io/api/v1/crates/{}", env!("CARGO_PKG_NAME"))
        );
    }

    #[test]
    fn reads_latest_version_from_crates_io_shape() {
        let body = serde_json::json!({"crate": {"max_version": "1.2.3"}});
        assert_eq!(latest_version(&body), Some("1.2.3"));
        assert_eq!(latest_version(&serde_json::json!({})), None);
    }
}