use std::time::Duration;
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct VersionStatus {
pub running: Option<String>,
pub latest_tag: String,
pub latest_published_at: String,
pub latest_html_url: String,
pub drift_detected: bool,
}
impl VersionStatus {
pub fn summary(&self) -> String {
let running = self
.running
.clone()
.unwrap_or_else(|| "(unknown)".to_string());
let drift_marker = if self.drift_detected {
" · DRIFT — upgrade available"
} else {
""
};
format!(
"running {} / latest {} ({} · {}){drift_marker}",
running, self.latest_tag, self.latest_published_at, self.latest_html_url,
)
}
}
#[derive(Deserialize)]
struct GitHubRelease {
tag_name: String,
html_url: String,
published_at: String,
}
pub async fn check_latest(running_version: Option<String>) -> Result<VersionStatus, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.user_agent(concat!("bee-tui/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| format!("client build: {e}"))?;
let resp = client
.get("https://api.github.com/repos/ethersphere/bee/releases/latest")
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| format!("GET github releases: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
"GitHub releases API returned HTTP {}",
resp.status()
));
}
let release: GitHubRelease = resp
.json()
.await
.map_err(|e| format!("decode github response: {e}"))?;
let drift = match (&running_version, &release.tag_name) {
(Some(r), tag) => version_drift_detected(r, tag),
_ => false,
};
Ok(VersionStatus {
running: running_version,
latest_tag: release.tag_name,
latest_published_at: release.published_at,
latest_html_url: release.html_url,
drift_detected: drift,
})
}
fn version_drift_detected(running: &str, latest_tag: &str) -> bool {
let r = parse_semver(running);
let l = parse_semver(latest_tag);
match (r, l) {
(Some(a), Some(b)) => a != b,
_ => false,
}
}
fn parse_semver(s: &str) -> Option<(u32, u32, u32)> {
let s = s.strip_prefix('v').unwrap_or(s);
let mut iter = s.split(['.', '-']);
let major: u32 = iter.next()?.parse().ok()?;
let minor: u32 = iter.next()?.parse().ok()?;
let patch_raw = iter.next()?;
let patch_digits: String = patch_raw.chars().take_while(|c| c.is_ascii_digit()).collect();
let patch: u32 = patch_digits.parse().ok()?;
Some((major, minor, patch))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_semver_strips_v_prefix() {
assert_eq!(parse_semver("v2.7.2"), Some((2, 7, 2)));
assert_eq!(parse_semver("2.7.2"), Some((2, 7, 2)));
}
#[test]
fn parse_semver_handles_dirty_suffix() {
assert_eq!(parse_semver("2.7.2-bcaf69d-dirty"), Some((2, 7, 2)));
assert_eq!(parse_semver("v2.7.2-rc1"), Some((2, 7, 2)));
}
#[test]
fn parse_semver_returns_none_on_non_numeric() {
assert_eq!(parse_semver("alpha"), None);
assert_eq!(parse_semver("2"), None);
assert_eq!(parse_semver(""), None);
}
#[test]
fn drift_detected_when_versions_differ() {
assert!(version_drift_detected("2.7.2", "v2.8.0"));
assert!(version_drift_detected("v2.7.2", "v2.7.3"));
}
#[test]
fn drift_not_detected_when_versions_match() {
assert!(!version_drift_detected("2.7.2", "v2.7.2"));
assert!(!version_drift_detected(
"2.7.2-bcaf69d-dirty",
"v2.7.2"
));
}
#[test]
fn drift_not_detected_when_either_unparseable() {
assert!(!version_drift_detected("alpha", "v2.7.2"));
assert!(!version_drift_detected("2.7.2", "weird-tag"));
}
#[test]
fn summary_renders_drift_marker_when_set() {
let s = VersionStatus {
running: Some("2.7.2".into()),
latest_tag: "v2.8.0".into(),
latest_published_at: "2026-04-15T10:00:00Z".into(),
latest_html_url: "https://github.com/ethersphere/bee/releases/tag/v2.8.0".into(),
drift_detected: true,
}
.summary();
assert!(s.contains("DRIFT"), "{s}");
assert!(s.contains("v2.8.0"));
}
#[test]
fn summary_omits_drift_marker_when_unset() {
let s = VersionStatus {
running: Some("2.7.2".into()),
latest_tag: "v2.7.2".into(),
latest_published_at: "2026-03-01T10:00:00Z".into(),
latest_html_url: "https://github.com/ethersphere/bee/releases/tag/v2.7.2".into(),
drift_detected: false,
}
.summary();
assert!(!s.contains("DRIFT"), "{s}");
}
}