use std::process::Command;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use triage_core::config::UpdateConfig;
const RELEASE_REPO_URL: &str = "https://github.com/hyeons-lab/triage.git";
pub fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateStatus {
pub current: String,
pub latest: Option<String>,
pub update_available: bool,
}
impl UpdateStatus {
pub fn current() -> Self {
Self {
current: current_version().to_string(),
latest: None,
update_available: false,
}
}
}
pub fn spawn_poller(
config: UpdateConfig,
status: Arc<RwLock<UpdateStatus>>,
on_update_available: impl Fn(&UpdateStatus) + Send + 'static,
) {
if !config.check {
return;
}
let spawned = thread::Builder::new()
.name("triage-update-poll".to_string())
.spawn(move || run_poll_loop(config, status, on_update_available));
if let Err(error) = spawned {
tracing::warn!(error = ?error, "failed to spawn update-check thread; updates won't be detected");
}
}
fn run_poll_loop(
config: UpdateConfig,
status: Arc<RwLock<UpdateStatus>>,
on_update_available: impl Fn(&UpdateStatus),
) {
let interval = Duration::from_secs(config.interval_hours.saturating_mul(3600).max(60));
loop {
match fetch_latest_tag(RELEASE_REPO_URL) {
Ok(latest) => {
let next = compute_status(current_version(), latest.as_deref());
if let Some(newly_available) = store_status(&status, next.clone())
&& newly_available
{
tracing::info!(
current = %next.current,
latest = ?next.latest,
"a newer Triage release is available",
);
on_update_available(&next);
}
}
Err(error) => {
tracing::debug!(error = ?error, "update check failed; will retry");
}
}
thread::sleep(interval);
}
}
fn store_status(status: &RwLock<UpdateStatus>, next: UpdateStatus) -> Option<bool> {
let mut guard = status.write().ok()?;
let newly_available = next.update_available && !guard.update_available;
*guard = next;
Some(newly_available)
}
pub fn fetch_latest_tag(repo_url: &str) -> Result<Option<String>> {
let output = Command::new("git")
.args(["ls-remote", "--tags", "--refs", repo_url])
.output()
.context("running git ls-remote to check for updates")?;
if !output.status.success() {
bail!(
"git ls-remote failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(latest_tag_from_ls_remote(&String::from_utf8_lossy(
&output.stdout,
)))
}
fn latest_tag_from_ls_remote(text: &str) -> Option<String> {
text.lines()
.filter_map(|line| line.rsplit_once("refs/tags/").map(|(_, tag)| tag.trim()))
.filter_map(|tag| parse_semver(tag).map(|version| (version, tag)))
.max_by_key(|(version, _)| *version)
.map(|(_, tag)| normalize_version(tag))
}
fn normalize_version(tag: &str) -> String {
tag.strip_prefix('v').unwrap_or(tag).to_string()
}
fn parse_semver(tag: &str) -> Option<(u64, u64, u64)> {
let trimmed = tag.strip_prefix('v').unwrap_or(tag);
let mut parts = trimmed.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
pub fn compute_status(current: &str, latest_tag: Option<&str>) -> UpdateStatus {
let latest = latest_tag.map(normalize_version);
let update_available = match (
parse_semver(current),
latest.as_deref().and_then(parse_semver),
) {
(Some(current), Some(latest)) => latest > current,
_ => false,
};
UpdateStatus {
current: current.to_string(),
latest,
update_available,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_semver_accepts_plain_and_v_prefixed() {
assert_eq!(parse_semver("0.1.6"), Some((0, 1, 6)));
assert_eq!(parse_semver("v0.1.6"), Some((0, 1, 6)));
assert_eq!(parse_semver("v12.34.56"), Some((12, 34, 56)));
}
#[test]
fn parse_semver_rejects_non_releases() {
assert_eq!(parse_semver("v0.2.0-rc1"), None); assert_eq!(parse_semver("1.2"), None); assert_eq!(parse_semver("1.2.3.4"), None); assert_eq!(parse_semver("latest"), None); assert_eq!(parse_semver("v0.1.6^{}"), None); }
#[test]
fn picks_highest_tag_ignoring_noise() {
let sample = "\
abc123\trefs/tags/v0.1.0
def456\trefs/tags/v0.1.3
aaa111\trefs/tags/v0.1.4
bbb222\trefs/tags/v0.1.5
ccc333\trefs/tags/v0.2.0-rc1
ddd444\trefs/tags/nightly
eee555\trefs/tags/v0.1.5^{}";
assert_eq!(latest_tag_from_ls_remote(sample), Some("0.1.5".to_string()));
}
#[test]
fn empty_or_tagless_output_yields_none() {
assert_eq!(latest_tag_from_ls_remote(""), None);
assert_eq!(latest_tag_from_ls_remote("abc123\trefs/heads/main"), None);
}
#[test]
fn compute_status_flags_newer_release() {
let status = compute_status("0.1.5", Some("v0.1.6"));
assert!(status.update_available);
assert_eq!(status.latest.as_deref(), Some("0.1.6"));
assert_eq!(status.current, "0.1.5");
}
#[test]
fn compute_status_is_false_when_current_or_newer() {
assert!(!compute_status("0.1.6", Some("0.1.6")).update_available); assert!(!compute_status("0.2.0", Some("0.1.6")).update_available); assert!(!compute_status("0.1.6", None).update_available); }
#[test]
fn store_status_reports_only_the_transition() {
let status = Arc::new(RwLock::new(UpdateStatus::current()));
assert_eq!(
store_status(&status, compute_status("0.1.5", Some("0.1.6"))),
Some(true)
);
assert_eq!(
store_status(&status, compute_status("0.1.5", Some("0.1.6"))),
Some(false)
);
}
}