ralph-tui 2.9.3

Terminal UI for Ralph Orchestrator using ratatui
Documentation
use crate::state::UpdateStatus;
use reqwest::header::{ACCEPT, HeaderMap, HeaderValue, USER_AGENT};
use serde::Deserialize;
use std::cmp::Ordering;
use std::time::Duration;

const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(2);

#[derive(Debug, Deserialize)]
struct LatestRelease {
    tag_name: String,
    prerelease: bool,
}

pub async fn fetch_update_status() -> UpdateStatus {
    match fetch_latest_release_tag().await {
        Ok(Some(latest)) => match compare_versions(env!("CARGO_PKG_VERSION"), &latest) {
            Some(Ordering::Less) => UpdateStatus::Available { latest },
            Some(Ordering::Equal | Ordering::Greater) => UpdateStatus::UpToDate,
            None => UpdateStatus::Unknown,
        },
        Ok(None) => UpdateStatus::Unknown,
        Err(_) => UpdateStatus::Unknown,
    }
}

async fn fetch_latest_release_tag() -> Result<Option<String>, reqwest::Error> {
    let Some(url) = latest_release_api_url() else {
        return Ok(None);
    };

    let mut headers = HeaderMap::new();
    headers.insert(
        USER_AGENT,
        HeaderValue::from_static(concat!("ralph-tui/", env!("CARGO_PKG_VERSION"))),
    );
    headers.insert(
        ACCEPT,
        HeaderValue::from_static("application/vnd.github+json"),
    );

    let client = reqwest::Client::builder()
        .default_headers(headers)
        .timeout(UPDATE_CHECK_TIMEOUT)
        .build()?;

    let release = client
        .get(url)
        .send()
        .await?
        .error_for_status()?
        .json::<LatestRelease>()
        .await?;

    if release.prerelease {
        return Ok(None);
    }

    Ok(normalize_version(&release.tag_name))
}

fn latest_release_api_url() -> Option<String> {
    let repository = env!("CARGO_PKG_REPOSITORY").trim_end_matches('/');
    let repo = repository
        .strip_prefix("https://github.com/")
        .or_else(|| repository.strip_prefix("http://github.com/"))
        .map(|value| value.trim_end_matches(".git"))
        .filter(|value| !value.is_empty())
        .unwrap_or("mikeyobrien/ralph-orchestrator");

    Some(format!(
        "https://api.github.com/repos/{repo}/releases/latest"
    ))
}

fn normalize_version(input: &str) -> Option<String> {
    let trimmed = input.trim().trim_start_matches('v');
    let core = trimmed.split(['-', '+']).next()?.trim();
    if core.is_empty() || !core.split('.').all(|part| !part.is_empty()) {
        return None;
    }
    Some(core.to_string())
}

fn compare_versions(current: &str, latest: &str) -> Option<Ordering> {
    let current = parse_version(current)?;
    let latest = parse_version(latest)?;
    Some(current.cmp(&latest))
}

fn parse_version(version: &str) -> Option<Vec<u64>> {
    normalize_version(version)?
        .split('.')
        .map(|part| part.parse().ok())
        .collect()
}

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

    #[test]
    fn normalizes_release_tags() {
        assert_eq!(normalize_version("v2.8.0"), Some("2.8.0".to_string()));
        assert_eq!(normalize_version("2.8.0-beta.1"), Some("2.8.0".to_string()));
        assert_eq!(
            normalize_version("2.8.0+build.5"),
            Some("2.8.0".to_string())
        );
    }

    #[test]
    fn rejects_invalid_versions() {
        assert_eq!(normalize_version(""), None);
        assert_eq!(normalize_version("v"), None);
        assert_eq!(normalize_version("2..8"), None);
    }

    #[test]
    fn compares_versions_numerically() {
        assert_eq!(compare_versions("2.7.0", "2.8.0"), Some(Ordering::Less));
        assert_eq!(compare_versions("2.8.0", "2.8.0"), Some(Ordering::Equal));
        assert_eq!(compare_versions("2.10.0", "2.9.9"), Some(Ordering::Greater));
    }

    #[test]
    fn builds_github_release_api_url() {
        assert_eq!(
            latest_release_api_url(),
            Some(
                "https://api.github.com/repos/mikeyobrien/ralph-orchestrator/releases/latest"
                    .to_string()
            )
        );
    }
}