use std::io::Write;
use std::time::Duration;
use anyhow::Result;
use serde::Serialize;
use super::output::OutputMode;
const CURRENT: &str = env!("CARGO_PKG_VERSION");
const RELEASES_API: &str = "https://api.github.com/repos/sotashimozono/doiget/releases";
const CHECK_TIMEOUT: Duration = Duration::from_secs(10);
fn releases_url() -> anyhow::Result<String> {
match std::env::var("DOIGET_GITHUB_BASE").ok() {
Some(base) => {
url::Url::parse(&base)
.map_err(|e| anyhow::anyhow!("invalid DOIGET_GITHUB_BASE: {e}"))?;
Ok(format!(
"{}/repos/sotashimozono/doiget/releases",
base.trim_end_matches('/')
))
}
None => Ok(RELEASES_API.to_string()),
}
}
#[derive(Debug, Serialize)]
pub struct VersionCheckResult {
pub current: &'static str,
pub latest: Option<String>,
pub newer_available: Option<bool>,
pub html_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub async fn run(check: bool, mode: OutputMode) -> Result<()> {
if mode == OutputMode::Quiet || mode == OutputMode::Mcp {
return Ok(());
}
if !check {
let stdout = std::io::stdout();
let mut out = stdout.lock();
if mode == OutputMode::Json {
writeln!(
out,
"{}",
serde_json::to_string_pretty(&serde_json::json!({ "version": CURRENT }))?
)?;
} else {
writeln!(out, "doiget {CURRENT}")?;
}
return Ok(());
}
let result = fetch_latest().await;
let stdout = std::io::stdout();
let mut out = stdout.lock();
if mode == OutputMode::Json {
writeln!(out, "{}", serde_json::to_string_pretty(&result)?)?;
} else {
match (&result.latest, &result.newer_available) {
(Some(latest), Some(true)) => {
writeln!(out, "doiget {CURRENT} — update available: {latest}")?;
if let Some(url) = &result.html_url {
writeln!(out, " {url}")?;
}
}
(Some(latest), _) => {
writeln!(
out,
"doiget {CURRENT} — up to date (latest stable: {latest})"
)?;
}
(None, _) => {
let reason = result.error.as_deref().unwrap_or("unknown");
writeln!(out, "doiget {CURRENT} — version check failed: {reason}")?;
}
}
}
Ok(())
}
async fn fetch_latest() -> VersionCheckResult {
match try_fetch_latest().await {
Ok(r) => r,
Err(e) => VersionCheckResult {
current: CURRENT,
latest: None,
newer_available: None,
html_url: None,
error: Some(e.to_string()),
},
}
}
async fn try_fetch_latest() -> anyhow::Result<VersionCheckResult> {
doiget_core::http::init_tls();
let client = reqwest::Client::builder()
.user_agent(format!("doiget/{CURRENT}"))
.timeout(CHECK_TIMEOUT)
.build()?;
let url = releases_url()?;
let resp = client.get(url).send().await.map_err(|e| {
if e.is_connect() || e.is_timeout() {
anyhow::anyhow!("unreachable")
} else {
anyhow::anyhow!("{e}")
}
})?;
if resp.status().as_u16() == 403 {
return Err(anyhow::anyhow!("rate_limited"));
}
let releases: serde_json::Value = resp.error_for_status()?.json().await?;
let arr = releases
.as_array()
.ok_or_else(|| anyhow::anyhow!("unexpected API shape"))?;
let stable = arr.iter().find(|r| {
let draft = r.get("draft").and_then(|v| v.as_bool()).unwrap_or(true);
let prerelease = r
.get("prerelease")
.and_then(|v| v.as_bool())
.unwrap_or(true);
if draft || prerelease {
return false;
}
let tag = r.get("tag_name").and_then(|v| v.as_str()).unwrap_or("");
let bare = tag.strip_prefix('v').unwrap_or(tag);
!has_prerelease_suffix(bare)
});
let Some(release) = stable else {
return Err(anyhow::anyhow!("no stable release found"));
};
let tag = release
.get("tag_name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing tag_name"))?;
let html_url = release
.get("html_url")
.and_then(|v| v.as_str())
.map(String::from);
let latest = tag.strip_prefix('v').unwrap_or(tag).to_string();
Ok(VersionCheckResult {
current: CURRENT,
newer_available: Some(is_newer(&latest, CURRENT)),
latest: Some(latest),
html_url,
error: None,
})
}
fn is_newer(candidate: &str, current: &str) -> bool {
let parse = |s: &str| -> Vec<u64> {
let core = s.split('-').next().unwrap_or(s);
core.split('.').map(|p| p.parse().unwrap_or(0)).collect()
};
let mut a = parse(candidate);
let mut b = parse(current);
let len = a.len().max(b.len());
a.resize(len, 0);
b.resize(len, 0);
a > b
}
fn has_prerelease_suffix(version: &str) -> bool {
let lower = version.to_ascii_lowercase();
lower.contains("-beta") || lower.contains("-alpha") || lower.contains("-rc")
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn is_newer_detects_upgrade() {
assert!(is_newer("0.5.0", "0.4.0"));
assert!(is_newer("0.4.1", "0.4.0"));
assert!(!is_newer("0.4.0", "0.4.0"));
assert!(!is_newer("0.3.9", "0.4.0"));
}
#[test]
fn is_newer_ignores_prerelease_suffix_in_current() {
assert!(!is_newer("0.4.0", "0.4.1-beta.0"));
assert!(is_newer("0.5.0", "0.4.1-beta.0"));
}
#[test]
fn is_newer_pads_mismatched_component_counts() {
assert!(!is_newer("1.0", "1.0.0"));
assert!(!is_newer("1.0.0", "1.0"));
assert!(is_newer("1.0.1", "1.0"));
}
#[test]
fn has_prerelease_suffix_cases() {
assert!(has_prerelease_suffix("0.4.1-beta.0"));
assert!(has_prerelease_suffix("1.0.0-alpha"));
assert!(has_prerelease_suffix("1.0.0-rc.1"));
assert!(!has_prerelease_suffix("0.4.0"));
}
#[test]
fn releases_url_rejects_invalid_base() {
std::env::set_var("DOIGET_GITHUB_BASE", "not a url !!!");
let result = releases_url();
std::env::remove_var("DOIGET_GITHUB_BASE");
assert!(result.is_err(), "invalid base must return Err");
}
#[test]
fn releases_url_falls_back_to_production_when_unset() {
std::env::remove_var("DOIGET_GITHUB_BASE");
let url = releases_url().expect("fallback must succeed");
assert_eq!(url, RELEASES_API);
}
}