use crate::cli::ui::{self, status_line, tip, Loader};
use colored::Colorize;
use semver::Version;
use serde::Deserialize;
use serde_json::json;
use std::process::Command;
const CRATE_PAGE_URL: &str = "https://crates.io/crates/xbp";
const CRATES_IO_API_URL: &str = "https://crates.io/api/v1/crates";
const DEFAULT_CRATE_NAME: &str = "xbp";
const USER_AGENT: &str = concat!(
"xbp/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/xylex-group/xbp)"
);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateStatus {
UpToDate,
UpdateAvailable,
LocalNewer,
Unknown,
}
impl UpdateStatus {
fn as_str(self) -> &'static str {
match self {
Self::UpToDate => "up_to_date",
Self::UpdateAvailable => "update_available",
Self::LocalNewer => "local_newer",
Self::Unknown => "unknown",
}
}
fn label(self) -> &'static str {
match self {
Self::UpToDate => "up to date",
Self::UpdateAvailable => "update available",
Self::LocalNewer => "local is newer",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone)]
pub struct UpdateCheckResult {
pub crate_name: String,
pub current_version: String,
pub latest_version: String,
pub published_at: Option<String>,
pub crate_url: String,
pub status: UpdateStatus,
pub install_command: String,
}
#[derive(Debug, Deserialize)]
struct CratesIoCrateResponse {
#[serde(rename = "crate")]
crate_meta: CratesIoCrateMeta,
#[serde(default)]
versions: Vec<CratesIoVersionEntry>,
}
#[derive(Debug, Deserialize)]
struct CratesIoCrateMeta {
newest_version: String,
#[serde(default)]
max_stable_version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CratesIoVersionEntry {
num: String,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
yanked: bool,
}
#[derive(Debug, Clone)]
pub struct UpdateCheckOptions {
pub crate_name: String,
pub json: bool,
pub install: bool,
pub fail_if_outdated: bool,
}
impl Default for UpdateCheckOptions {
fn default() -> Self {
Self {
crate_name: DEFAULT_CRATE_NAME.to_string(),
json: false,
install: false,
fail_if_outdated: false,
}
}
}
pub async fn run_update_check(options: UpdateCheckOptions) -> Result<(), String> {
let crate_name = normalize_crate_name(&options.crate_name)?;
let current_version = env!("CARGO_PKG_VERSION").to_string();
let loader = if options.json {
None
} else {
Some(Loader::start(&format!(
"Checking crates.io for {crate_name}"
)))
};
let result = match check_for_update(&crate_name, ¤t_version).await {
Ok(result) => {
if let Some(loader) = &loader {
loader.success_with(&format!(
"latest {} ({})",
result.latest_version,
result.status.label()
));
}
result
}
Err(error) => {
if let Some(loader) = &loader {
loader.fail("lookup failed");
}
return Err(error);
}
};
if options.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"crate": result.crate_name,
"current_version": result.current_version,
"latest_version": result.latest_version,
"published_at": result.published_at,
"status": result.status.as_str(),
"update_available": result.status == UpdateStatus::UpdateAvailable,
"crate_url": result.crate_url,
"install_command": result.install_command,
}))
.map_err(|error| format!("Failed to serialize update check result: {error}"))?
);
} else {
print_update_report(&result);
}
if options.install {
if result.status == UpdateStatus::UpdateAvailable {
run_cargo_install(&result.crate_name, &result.latest_version)?;
} else if !options.json {
tip("Nothing to install; local CLI is already current (or newer than crates.io).");
}
}
if options.fail_if_outdated && result.status == UpdateStatus::UpdateAvailable {
return Err(format!(
"Update available: {} -> {} (crates.io/{})",
result.current_version, result.latest_version, result.crate_name
));
}
Ok(())
}
pub async fn check_for_update(
crate_name: &str,
current_version: &str,
) -> Result<UpdateCheckResult, String> {
let latest = fetch_crates_io_latest(crate_name).await?;
let status = compare_versions(current_version, &latest.version);
Ok(UpdateCheckResult {
crate_name: crate_name.to_string(),
current_version: current_version.to_string(),
latest_version: latest.version.clone(),
published_at: latest.published_at,
crate_url: format!("https://crates.io/crates/{crate_name}"),
status,
install_command: format!("cargo install {crate_name} --locked"),
})
}
fn print_update_report(result: &UpdateCheckResult) {
ui::configure_color_output();
println!();
println!(
"{} {}",
"XBP".bright_magenta().bold(),
"update check".bright_white().bold()
);
ui::divider(56);
status_line("Crate", &result.crate_name, true);
status_line("Installed", &format!("v{}", result.current_version), true);
let latest_ok = matches!(
result.status,
UpdateStatus::UpToDate | UpdateStatus::LocalNewer
);
status_line(
"crates.io latest",
&format!("v{}", result.latest_version),
latest_ok,
);
if let Some(published_at) = result.published_at.as_deref() {
status_line("Published", published_at, true);
}
let (status_text, status_ok) = match result.status {
UpdateStatus::UpToDate => ("up to date".to_string(), true),
UpdateStatus::UpdateAvailable => (
format!(
"update available (v{} → v{})",
result.current_version, result.latest_version
),
false,
),
UpdateStatus::LocalNewer => (
format!(
"local is newer than crates.io (v{} > v{})",
result.current_version, result.latest_version
),
true,
),
UpdateStatus::Unknown => (
"could not compare versions (non-semver?)".to_string(),
false,
),
};
status_line("Status", &status_text, status_ok);
ui::divider(56);
match result.status {
UpdateStatus::UpdateAvailable => {
tip(&format!("Upgrade with `{}`", result.install_command));
tip(&format!("Or open {}", result.crate_url));
if result.crate_name == DEFAULT_CRATE_NAME {
tip("Quick install: `xbp update --install`");
}
}
UpdateStatus::UpToDate => {
tip(&format!("You are on the latest release from {CRATE_PAGE_URL}."));
}
UpdateStatus::LocalNewer => {
tip("This binary is ahead of the published crates.io release (dev build or unpublished bump).");
}
UpdateStatus::Unknown => {
tip(&format!("Inspect releases at {}", result.crate_url));
}
}
}
async fn fetch_crates_io_latest(crate_name: &str) -> Result<LatestRelease, String> {
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.build()
.map_err(|error| format!("Failed to build HTTP client: {error}"))?;
let url = format!("{CRATES_IO_API_URL}/{crate_name}");
let response = client
.get(&url)
.send()
.await
.map_err(|error| format!("Failed to query crates.io for `{crate_name}`: {error}"))?;
if !response.status().is_success() {
return Err(format!(
"crates.io lookup for `{crate_name}` returned HTTP {}.",
response.status().as_u16()
));
}
let payload: CratesIoCrateResponse = response
.json()
.await
.map_err(|error| format!("Failed to parse crates.io response for `{crate_name}`: {error}"))?;
let latest_version = payload
.crate_meta
.max_stable_version
.filter(|value| !value.trim().is_empty())
.unwrap_or(payload.crate_meta.newest_version);
if latest_version.trim().is_empty() {
return Err(format!(
"crates.io response for `{crate_name}` did not include a latest version."
));
}
let published_at = payload
.versions
.iter()
.find(|entry| entry.num == latest_version && !entry.yanked)
.and_then(|entry| entry.created_at.clone())
.or_else(|| {
payload
.versions
.iter()
.find(|entry| entry.num == latest_version)
.and_then(|entry| entry.created_at.clone())
});
Ok(LatestRelease {
version: latest_version,
published_at,
})
}
struct LatestRelease {
version: String,
published_at: Option<String>,
}
fn compare_versions(current: &str, latest: &str) -> UpdateStatus {
let Ok(current) = Version::parse(current.trim().trim_start_matches('v')) else {
return UpdateStatus::Unknown;
};
let Ok(latest) = Version::parse(latest.trim().trim_start_matches('v')) else {
return UpdateStatus::Unknown;
};
match current.cmp(&latest) {
std::cmp::Ordering::Less => UpdateStatus::UpdateAvailable,
std::cmp::Ordering::Equal => UpdateStatus::UpToDate,
std::cmp::Ordering::Greater => UpdateStatus::LocalNewer,
}
}
fn normalize_crate_name(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("Crate name must not be empty.".to_string());
}
if !trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
{
return Err(format!(
"Invalid crate name `{trimmed}`. Use letters, digits, `-`, or `_`."
));
}
Ok(trimmed.to_string())
}
fn run_cargo_install(crate_name: &str, version: &str) -> Result<(), String> {
if which_cargo().is_none() {
return Err(
"`cargo` was not found on PATH. Install Rust from https://rustup.rs then re-run `xbp update --install`."
.to_string(),
);
}
println!();
println!(
"{} {}",
"Installing".bright_cyan().bold(),
format!("{crate_name}@{version}").bright_white()
);
let status = Command::new("cargo")
.args([
"install",
crate_name,
"--locked",
"--version",
version,
"--force",
])
.status()
.map_err(|error| format!("Failed to spawn `cargo install`: {error}"))?;
if !status.success() {
return Err(format!(
"`cargo install {crate_name} --locked --version {version}` failed with status {status}."
));
}
println!(
"{} {}",
"OK".bright_green().bold(),
format!("Installed {crate_name}@{version}").bright_white()
);
Ok(())
}
fn which_cargo() -> Option<std::path::PathBuf> {
crate::utils::command_exists("cargo").then(|| std::path::PathBuf::from("cargo"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compares_semver_update_states() {
assert_eq!(
compare_versions("10.37.0", "10.38.0"),
UpdateStatus::UpdateAvailable
);
assert_eq!(
compare_versions("10.38.0", "10.38.0"),
UpdateStatus::UpToDate
);
assert_eq!(
compare_versions("10.38.1", "10.38.0"),
UpdateStatus::LocalNewer
);
assert_eq!(compare_versions("not-a-version", "1.0.0"), UpdateStatus::Unknown);
}
#[test]
fn normalizes_crate_names() {
assert_eq!(normalize_crate_name("xbp").unwrap(), "xbp");
assert!(normalize_crate_name("").is_err());
assert!(normalize_crate_name("evil/name").is_err());
}
}