use anyhow::{Context, Result};
use serde::Deserialize;
use std::time::Duration;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Deserialize)]
pub struct CrateVersion {
pub num: String,
pub created_at: String,
#[serde(default)]
pub downloads: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct CratesIoResponse {
pub crate_info: CrateInfo,
}
#[derive(Debug, Deserialize)]
pub struct CrateInfo {
pub latest_version: Option<CrateVersion>,
#[serde(default)]
pub versions: Vec<CrateVersion>,
}
#[derive(Debug, Clone)]
pub struct VersionInfo {
pub current: String,
pub latest: String,
pub release_date: String,
pub is_outdated: bool,
pub versions_behind: u32,
pub release_url: String,
}
impl VersionInfo {
pub fn new(current: String, latest: String, release_date: String) -> Self {
let is_outdated = compare_versions(&latest, ¤t) > 0;
let versions_behind = calculate_versions_behind(¤t, &latest);
let release_url = format!(
"https://crates.io/crates/oxi-cli/{}",
latest.replace('.', "-").replace('+', "-")
);
Self {
current,
latest,
release_date,
is_outdated,
versions_behind,
release_url,
}
}
pub fn current_display(&self) -> String {
format!("v{}", self.current)
}
pub fn latest_display(&self) -> String {
format!("v{}", self.latest)
}
pub fn notification_message(&self) -> String {
if self.is_outdated {
format!(
"oxi {} available (current: {})",
self.latest_display(),
self.current_display()
)
} else {
format!("oxi {} (up to date)", self.current_display())
}
}
pub fn summary(&self) -> String {
if self.is_outdated {
if self.versions_behind > 0 {
format!(
"New version available: {} ({} behind)",
self.latest_display(),
self.versions_behind
)
} else {
format!("New version available: {}", self.latest_display())
}
} else {
"You have the latest version.".to_string()
}
}
}
fn parse_version(version: &str) -> Option<(u32, u32, u32)> {
let version = version.trim().trim_start_matches('v');
let version = version.split('-').next().unwrap_or(version);
let parts: Vec<&str> = version.split('.').collect();
if parts.len() == 3 {
let major = parts[0].parse().ok()?;
let minor = parts[1].parse().ok()?;
let patch = parts[2]
.split('+')
.next()
.unwrap_or(parts[2])
.parse()
.ok()?;
Some((major, minor, patch))
} else {
None
}
}
fn compare_versions(left: &str, right: &str) -> i32 {
let left_parsed = match parse_version(left) {
Some(v) => v,
None => return 0,
};
let right_parsed = match parse_version(right) {
Some(v) => v,
None => return 0,
};
if left_parsed.0 != right_parsed.0 {
return left_parsed.0 as i32 - right_parsed.0 as i32;
}
if left_parsed.1 != right_parsed.1 {
return left_parsed.1 as i32 - right_parsed.1 as i32;
}
left_parsed.2 as i32 - right_parsed.2 as i32
}
fn calculate_versions_behind(current: &str, latest: &str) -> u32 {
let current_parsed = parse_version(current);
let latest_parsed = parse_version(latest);
match (current_parsed, latest_parsed) {
(Some((c_major, c_minor, c_patch)), Some((l_major, l_minor, l_patch))) => {
if l_major < c_major
|| (l_major == c_major && l_minor < c_minor)
|| (l_major == c_major && l_minor == c_minor && l_patch <= c_patch)
{
0
} else {
let major_diff = (l_major - c_major) as u32;
let minor_diff = (l_minor.saturating_sub(c_minor)) as u32;
let patch_diff = (l_patch.saturating_sub(c_patch)) as u32;
(major_diff * 2 + minor_diff + patch_diff).min(10)
}
}
_ => 0,
}
}
pub async fn fetch_latest_version(crate_name: &str) -> Result<CrateVersion> {
let url = format!("https://crates.io/api/v1/crates/{}", crate_name);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.context("Failed to create HTTP client")?;
let response = client
.get(&url)
.header(
"User-Agent",
format!("oxi-cli/{}", env!("CARGO_PKG_VERSION")),
)
.header("Accept", "application/json")
.send()
.await
.context("Failed to fetch from crates.io")?;
if !response.status().is_success() {
anyhow::bail!("crates.io returned status: {}", response.status());
}
let data: serde_json::Value = response
.json()
.await
.context("Failed to parse crates.io response")?;
let latest = data
.get("crate")
.and_then(|c| c.get("latest_version"))
.and_then(|v| v.get("num"))
.and_then(|v| v.as_str())
.unwrap_or("0.0.0")
.to_string();
let created_at = data
.get("crate")
.and_then(|c| c.get("latest_version"))
.and_then(|v| v.get("created_at"))
.and_then(|v| v.as_str())
.unwrap_or("2024-01-01T00:00:00Z")
.to_string();
Ok(CrateVersion {
num: latest,
created_at,
downloads: None,
})
}
pub async fn check_for_updates() -> Result<VersionInfo> {
let current_version = env!("CARGO_PKG_VERSION");
if std::env::var("OXI_OFFLINE").is_ok() || std::env::var("OXI_SKIP_VERSION_CHECK").is_ok() {
debug!("Skipping version check (offline mode)");
return Ok(VersionInfo::new(
current_version.to_string(),
current_version.to_string(),
String::new(),
));
}
match fetch_latest_version("oxi-cli").await {
Ok(crate_info) => {
info!(
"Version check: current={}, latest={}",
current_version, crate_info.num
);
Ok(VersionInfo::new(
current_version.to_string(),
crate_info.num,
crate_info.created_at,
))
}
Err(e) => {
warn!("Failed to check for updates: {}", e);
Ok(VersionInfo::new(
current_version.to_string(),
current_version.to_string(),
String::new(),
))
}
}
}
pub fn show_version_notification(info: &VersionInfo) -> Option<String> {
if info.is_outdated {
Some(format!(
"oxi {} available (current: v{})",
info.latest_display(),
info.current
))
} else {
None
}
}
pub fn format_for_debug(info: &VersionInfo) -> String {
format!(
"Version check: current={}, latest={}, outdated={}, date={}",
info.current, info.latest, info.is_outdated, info.release_date
)
}
pub fn parse_release_date(date_str: &str) -> Option<String> {
if date_str.is_empty() {
return None;
}
let parts: Vec<&str> = date_str
.split(|c| c == '-' || c == 'T' || c == ':' || c == 'Z' || c == '+')
.collect();
if parts.len() >= 3 {
let year: u32 = parts[0].parse().ok()?;
let month: u32 = parts[1].parse().ok()?;
let day: u32 = parts[2].parse().ok()?;
let month_names = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
if month >= 1 && month <= 12 {
return Some(format!(
"{} {}, {}",
month_names[month as usize - 1],
day,
year
));
}
}
None
}
pub fn get_full_notification(info: &VersionInfo) -> String {
let base = info.notification_message();
if info.is_outdated {
let date = parse_release_date(&info.release_date)
.map(|d| format!(" (released {})", d))
.unwrap_or_default();
format!("{}{}", base, date)
} else {
base
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_version_basic() {
assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("0.1.0"), Some((0, 1, 0)));
assert_eq!(parse_version("10.20.30"), Some((10, 20, 30)));
}
#[test]
fn test_parse_version_with_v_prefix() {
assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("v0.0.1"), Some((0, 0, 1)));
}
#[test]
fn test_parse_version_with_prerelease() {
assert_eq!(parse_version("1.2.3-alpha"), Some((1, 2, 3)));
assert_eq!(parse_version("2.0.0-beta.1"), Some((2, 0, 0)));
assert_eq!(parse_version("1.0.0+build123"), Some((1, 0, 0)));
}
#[test]
fn test_parse_version_invalid() {
assert_eq!(parse_version("invalid"), None);
assert_eq!(parse_version("1.2"), None);
assert_eq!(parse_version(""), None);
assert_eq!(parse_version("1.2.3.4"), None);
}
#[test]
fn test_compare_versions_equal() {
assert_eq!(compare_versions("1.2.3", "1.2.3"), 0);
assert_eq!(compare_versions("0.1.0", "0.1.0"), 0);
assert_eq!(compare_versions("v1.0.0", "v1.0.0"), 0);
}
#[test]
fn test_compare_versions_less() {
assert!(compare_versions("1.2.3", "1.2.4") < 0);
assert!(compare_versions("1.2.3", "1.3.0") < 0);
assert!(compare_versions("1.2.3", "2.0.0") < 0);
assert!(compare_versions("0.9.9", "1.0.0") < 0);
}
#[test]
fn test_compare_versions_greater() {
assert!(compare_versions("1.2.4", "1.2.3") > 0);
assert!(compare_versions("1.3.0", "1.2.3") > 0);
assert!(compare_versions("2.0.0", "1.2.3") > 0);
assert!(compare_versions("1.0.1", "1.0.0") > 0);
}
#[test]
fn test_compare_versions_with_prerelease() {
assert_eq!(compare_versions("1.2.3-alpha", "1.2.3"), 0);
}
#[test]
fn test_compare_versions_invalid() {
assert_eq!(compare_versions("invalid", "1.0.0"), 0);
assert_eq!(compare_versions("1.0.0", "invalid"), 0);
}
#[test]
fn test_version_info_creation() {
let info = VersionInfo::new(
"1.0.0".to_string(),
"1.1.0".to_string(),
"2024-01-15T00:00:00Z".to_string(),
);
assert_eq!(info.current, "1.0.0");
assert_eq!(info.latest, "1.1.0");
assert!(info.is_outdated);
assert_eq!(info.versions_behind, 1);
}
#[test]
fn test_version_info_not_outdated() {
let info = VersionInfo::new(
"1.1.0".to_string(),
"1.0.0".to_string(),
"2024-01-15T00:00:00Z".to_string(),
);
assert!(!info.is_outdated);
assert_eq!(info.versions_behind, 0);
}
#[test]
fn test_version_info_display() {
let info = VersionInfo::new("1.2.3".to_string(), "1.3.0".to_string(), String::new());
assert_eq!(info.current_display(), "v1.2.3");
assert_eq!(info.latest_display(), "v1.3.0");
}
#[test]
fn test_version_info_notification() {
let outdated = VersionInfo::new("1.0.0".to_string(), "1.1.0".to_string(), String::new());
assert_eq!(
outdated.notification_message(),
"oxi v1.1.0 available (current: v1.0.0)"
);
let current = VersionInfo::new("1.1.0".to_string(), "1.1.0".to_string(), String::new());
assert_eq!(current.notification_message(), "oxi v1.1.0 (up to date)");
}
#[test]
fn test_version_info_summary() {
let outdated = VersionInfo::new("1.0.0".to_string(), "1.2.0".to_string(), String::new());
assert!(outdated.summary().contains("New version"));
assert!(outdated.summary().contains("1.2.0"));
let current = VersionInfo::new("1.0.0".to_string(), "1.0.0".to_string(), String::new());
assert_eq!(current.summary(), "You have the latest version.");
}
#[test]
fn test_calculate_versions_behind() {
assert_eq!(calculate_versions_behind("1.0.0", "1.0.0"), 0);
assert_eq!(calculate_versions_behind("1.0.0", "1.1.0"), 1);
assert_eq!(calculate_versions_behind("1.0.0", "2.0.0"), 2);
assert_eq!(calculate_versions_behind("1.0.0", "1.0.1"), 1);
}
#[test]
fn test_parse_release_date() {
assert_eq!(
parse_release_date("2024-01-15T10:30:00Z"),
Some("January 15, 2024".to_string())
);
assert_eq!(
parse_release_date("2023-12-01T00:00:00Z"),
Some("December 1, 2023".to_string())
);
assert_eq!(parse_release_date(""), None);
assert_eq!(parse_release_date("invalid"), None);
}
#[test]
fn test_show_version_notification_outdated() {
let info = VersionInfo::new("1.0.0".to_string(), "1.1.0".to_string(), String::new());
let notification = show_version_notification(&info);
assert!(notification.is_some());
assert!(notification.unwrap().contains("1.1.0"));
}
#[test]
fn test_show_version_notification_current() {
let info = VersionInfo::new("1.1.0".to_string(), "1.1.0".to_string(), String::new());
let notification = show_version_notification(&info);
assert!(notification.is_none());
}
#[test]
fn test_get_full_notification() {
let info = VersionInfo::new(
"1.0.0".to_string(),
"1.1.0".to_string(),
"2024-01-15T00:00:00Z".to_string(),
);
let full = get_full_notification(&info);
assert!(full.contains("v1.1.0"));
assert!(full.contains("January"));
}
#[test]
fn test_format_for_debug() {
let info = VersionInfo::new(
"1.0.0".to_string(),
"1.1.0".to_string(),
"2024-01-15T00:00:00Z".to_string(),
);
let debug = format_for_debug(&info);
assert!(debug.contains("1.0.0"));
assert!(debug.contains("1.1.0"));
assert!(debug.contains("true"));
}
#[test]
fn test_version_info_release_url() {
let info = VersionInfo::new("1.0.0".to_string(), "1.2.3".to_string(), String::new());
assert!(info.release_url.contains("crates.io"));
assert!(info.release_url.contains("1-2-3"));
}
#[test]
fn test_version_info_edge_cases() {
let info = VersionInfo::new(
"0.1.5-alpha".to_string(),
"0.2.0".to_string(),
String::new(),
);
assert!(info.is_outdated);
let info = VersionInfo::new("1.0.0".to_string(), "1.0.0".to_string(), String::new());
assert!(!info.is_outdated);
}
}