use super::types::{CachedCheck, Channel, ReleaseInfo, UpdateCheck, UpdateError, Version};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
const GITHUB_API_BASE: &str = "https://api.github.com";
const REPO_OWNER: &str = "Dicklesworthstone";
const REPO_NAME: &str = "remote_compilation_helper";
fn get_cache_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("rch"))
}
fn get_cache_file() -> Option<PathBuf> {
get_cache_dir().map(|d| d.join("version_check.json"))
}
pub fn read_cached_check() -> Option<UpdateCheck> {
let cache_file = get_cache_file()?;
let content = fs::read_to_string(&cache_file).ok()?;
let cached: CachedCheck = serde_json::from_str(&content).ok()?;
if cached.is_valid() {
Some(cached.result)
} else {
let _ = fs::remove_file(&cache_file);
None
}
}
fn write_cache(check: &UpdateCheck) {
let Some(cache_file) = get_cache_file() else {
return;
};
if let Some(cache_dir) = get_cache_dir() {
let _ = fs::create_dir_all(&cache_dir);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let cached = CachedCheck {
result: check.clone(),
cached_at_secs: now,
};
if let Ok(json) = serde_json::to_string_pretty(&cached)
&& let Ok(mut file) = fs::File::create(&cache_file)
{
let _ = file.write_all(json.as_bytes());
}
}
pub fn spawn_update_check_if_needed() {
if std::env::var("RCH_NO_UPDATE_CHECK").is_ok() {
return;
}
if read_cached_check().is_some() {
return;
}
std::thread::spawn(|| {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
rt.block_on(async {
let _ = check_for_updates(Channel::Stable, None, true).await;
});
});
}
pub async fn check_for_updates(
channel: Channel,
target_version: Option<String>,
allow_cache: bool,
) -> Result<UpdateCheck, UpdateError> {
let current_version = get_current_version()?;
if let Some(ref version) = target_version {
return fetch_specific_version(¤t_version, version).await;
}
if allow_cache
&& channel == Channel::Stable
&& let Some(cached) = read_cached_check()
{
if cached.current_version == current_version {
return Ok(cached);
}
}
let releases = fetch_releases().await?;
let latest = filter_by_channel(&releases, channel)
.ok_or_else(|| UpdateError::CheckFailed("No releases found for channel".to_string()))?;
let latest_version = Version::parse(&latest.tag_name)?;
let update_available = latest_version > current_version;
let changelog_diff = if update_available {
compute_changelog_diff(&releases, ¤t_version, &latest_version)
} else {
None
};
let check = UpdateCheck {
current_version,
latest_version,
update_available,
release_url: latest.html_url.clone(),
release_notes: latest.body.clone(),
changelog_diff,
assets: latest.assets.clone(),
};
if channel == Channel::Stable {
write_cache(&check);
}
Ok(check)
}
fn get_current_version() -> Result<Version, UpdateError> {
let version_str = env!("CARGO_PKG_VERSION");
Version::parse(version_str)
}
async fn fetch_specific_version(
current: &Version,
target: &str,
) -> Result<UpdateCheck, UpdateError> {
let tag = if target.starts_with('v') {
target.to_string()
} else {
format!("v{}", target)
};
let url = format!(
"{}/repos/{}/{}/releases/tags/{}",
GITHUB_API_BASE, REPO_OWNER, REPO_NAME, tag
);
let release = fetch_release_from_url(&url).await?;
let target_version = Version::parse(&release.tag_name)?;
let update_available = target_version != *current;
let changelog_diff = if update_available && target_version > *current {
match fetch_releases().await {
Ok(releases) => compute_changelog_diff(&releases, current, &target_version),
Err(_) => None, }
} else {
None
};
Ok(UpdateCheck {
current_version: current.clone(),
latest_version: target_version,
update_available,
release_url: release.html_url.clone(),
release_notes: release.body.clone(),
changelog_diff,
assets: release.assets.clone(),
})
}
const MAX_API_ATTEMPTS: u32 = 4;
const API_BACKOFF: [Duration; 3] = [
Duration::from_secs(2),
Duration::from_secs(5),
Duration::from_secs(15),
];
fn is_retryable_api_status(status: reqwest::StatusCode) -> bool {
status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
}
async fn api_get_with_retry<T, F, Fut>(label: &str, op: F) -> Result<T, UpdateError>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<Option<T>, UpdateError>>,
{
let mut last_err: Option<UpdateError> = None;
for attempt in 1..=MAX_API_ATTEMPTS {
match op().await {
Ok(Some(value)) => return Ok(value),
Ok(None) if attempt < MAX_API_ATTEMPTS => {
let wait = backoff_for(attempt);
tracing::warn!(
"{label} attempt {attempt}/{MAX_API_ATTEMPTS} hit a transient status; \
retrying in {wait:?}"
);
tokio::time::sleep(wait).await;
}
Ok(None) => {
return Err(last_err.unwrap_or_else(|| {
UpdateError::CheckFailed(format!(
"{label} failed after {MAX_API_ATTEMPTS} attempts (transient status)"
))
}));
}
Err(e) if matches!(e, UpdateError::NetworkError(_)) && attempt < MAX_API_ATTEMPTS => {
let wait = backoff_for(attempt);
tracing::warn!(
"{label} attempt {attempt}/{MAX_API_ATTEMPTS} failed ({e}); \
retrying in {wait:?}"
);
last_err = Some(e);
tokio::time::sleep(wait).await;
}
Err(e) => return Err(e),
}
}
Err(last_err.unwrap_or_else(|| {
UpdateError::CheckFailed(format!("{label} failed after {MAX_API_ATTEMPTS} attempts"))
}))
}
fn backoff_for(attempt: u32) -> Duration {
API_BACKOFF
.get((attempt - 1) as usize)
.copied()
.unwrap_or_else(|| API_BACKOFF[API_BACKOFF.len() - 1])
}
async fn fetch_releases() -> Result<Vec<ReleaseInfo>, UpdateError> {
let url = format!(
"{}/repos/{}/{}/releases",
GITHUB_API_BASE, REPO_OWNER, REPO_NAME
);
api_get_with_retry("Fetching releases", || async {
let client = build_http_client()?;
let response = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", format!("rch/{}", env!("CARGO_PKG_VERSION")))
.send()
.await
.map_err(|e| UpdateError::NetworkError(e.to_string()))?;
let status = response.status();
if !status.is_success() {
if is_retryable_api_status(status) {
return Ok(None);
}
return Err(UpdateError::CheckFailed(format!(
"GitHub API returned {status}"
)));
}
let releases: Vec<ReleaseInfo> = response
.json()
.await
.map_err(|e| UpdateError::CheckFailed(format!("Failed to parse releases: {}", e)))?;
Ok(Some(releases))
})
.await
}
async fn fetch_release_from_url(url: &str) -> Result<ReleaseInfo, UpdateError> {
api_get_with_retry("Fetching release", || async {
let client = build_http_client()?;
let response = client
.get(url)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", format!("rch/{}", env!("CARGO_PKG_VERSION")))
.send()
.await
.map_err(|e| UpdateError::NetworkError(e.to_string()))?;
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err(UpdateError::CheckFailed("Release not found".to_string()));
}
if !status.is_success() {
if is_retryable_api_status(status) {
return Ok(None);
}
return Err(UpdateError::CheckFailed(format!(
"GitHub API returned {status}"
)));
}
let release: ReleaseInfo = response
.json()
.await
.map_err(|e| UpdateError::CheckFailed(format!("Failed to parse release: {}", e)))?;
Ok(Some(release))
})
.await
}
fn filter_by_channel(releases: &[ReleaseInfo], channel: Channel) -> Option<&ReleaseInfo> {
releases
.iter()
.filter_map(|release| {
if release.draft {
return None;
}
let version = Version::parse(&release.tag_name).ok()?;
let is_prerelease = release.prerelease || version.is_prerelease();
let matches_channel = match channel {
Channel::Stable => !is_prerelease,
Channel::Beta => is_prerelease,
Channel::Nightly => true,
};
matches_channel.then_some((version, release))
})
.max_by(|(left_version, _), (right_version, _)| left_version.cmp(right_version))
.map(|(_, release)| release)
}
fn compute_changelog_diff(
releases: &[ReleaseInfo],
current: &Version,
target: &Version,
) -> Option<String> {
let mut notes = Vec::new();
for release in releases {
if release.draft {
continue;
}
let Ok(version) = Version::parse(&release.tag_name) else {
continue;
};
if version > *current
&& version <= *target
&& let Some(ref body) = release.body
&& !body.trim().is_empty()
{
notes.push((version, format!("## {}\n{}", release.tag_name, body)));
}
}
if notes.is_empty() {
None
} else {
notes.sort_by(|(left_version, _), (right_version, _)| left_version.cmp(right_version));
Some(
notes
.into_iter()
.map(|(_, note)| note)
.collect::<Vec<_>>()
.join("\n\n---\n\n"),
)
}
}
fn build_http_client() -> Result<reqwest::Client, UpdateError> {
reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|e| UpdateError::NetworkError(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_current_version() {
let version = get_current_version().unwrap();
assert!(!version.to_string().is_empty());
}
#[test]
fn test_is_retryable_api_status() {
use reqwest::StatusCode;
assert!(is_retryable_api_status(StatusCode::GATEWAY_TIMEOUT)); assert!(is_retryable_api_status(StatusCode::BAD_GATEWAY)); assert!(is_retryable_api_status(StatusCode::SERVICE_UNAVAILABLE)); assert!(is_retryable_api_status(StatusCode::TOO_MANY_REQUESTS));
assert!(!is_retryable_api_status(StatusCode::NOT_FOUND)); assert!(!is_retryable_api_status(StatusCode::UNAUTHORIZED)); assert!(!is_retryable_api_status(StatusCode::FORBIDDEN)); }
#[test]
fn test_api_backoff_is_bounded() {
let total: Duration = API_BACKOFF.iter().copied().sum();
assert_eq!(API_BACKOFF.len() as u32, MAX_API_ATTEMPTS - 1);
assert!(total <= Duration::from_secs(45));
}
#[test]
fn test_filter_by_channel_stable() {
let releases = vec![
ReleaseInfo {
tag_name: "v0.2.0-beta.1".to_string(),
name: "Beta".to_string(),
prerelease: true,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v0.1.0".to_string(),
name: "Stable".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
];
let stable = filter_by_channel(&releases, Channel::Stable).unwrap();
assert_eq!(stable.tag_name, "v0.1.0");
let beta = filter_by_channel(&releases, Channel::Beta).unwrap();
assert_eq!(beta.tag_name, "v0.2.0-beta.1");
}
#[test]
fn test_filter_by_channel_selects_highest_semver_not_first_release() {
let releases = vec![
ReleaseInfo {
tag_name: "v1.0.1".to_string(),
name: "Backfilled patch".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.2.0".to_string(),
name: "Actual latest".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.1.0".to_string(),
name: "Intermediate".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
];
let stable = filter_by_channel(&releases, Channel::Stable).unwrap();
assert_eq!(stable.tag_name, "v1.2.0");
}
#[test]
fn test_filter_by_channel_selects_highest_prerelease_semver() {
let releases = vec![
ReleaseInfo {
tag_name: "v1.0.0-beta.2".to_string(),
name: "Older beta".to_string(),
prerelease: true,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.0.0-beta.10".to_string(),
name: "Newer beta".to_string(),
prerelease: true,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
];
let beta = filter_by_channel(&releases, Channel::Beta).unwrap();
assert_eq!(beta.tag_name, "v1.0.0-beta.10");
}
#[test]
fn test_filter_by_channel_skips_unparseable_release_tags() {
let releases = vec![
ReleaseInfo {
tag_name: "nightly-build".to_string(),
name: "Invalid tag".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.2.0".to_string(),
name: "Valid tag".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
];
let stable = filter_by_channel(&releases, Channel::Stable).unwrap();
assert_eq!(stable.tag_name, "v1.2.0");
}
#[test]
fn test_filter_by_channel_stable_rejects_prerelease_tag_even_without_github_flag() {
let releases = vec![
ReleaseInfo {
tag_name: "v2.0.0-beta.1".to_string(),
name: "Misflagged prerelease".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.2.0".to_string(),
name: "Stable".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
];
let stable = filter_by_channel(&releases, Channel::Stable).unwrap();
assert_eq!(stable.tag_name, "v1.2.0");
let beta = filter_by_channel(&releases, Channel::Beta).unwrap();
assert_eq!(beta.tag_name, "v2.0.0-beta.1");
}
#[test]
fn test_filter_by_channel_skips_drafts() {
let releases = vec![ReleaseInfo {
tag_name: "v0.1.0".to_string(),
name: "Draft".to_string(),
prerelease: false,
draft: true,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
}];
assert!(filter_by_channel(&releases, Channel::Stable).is_none());
}
#[test]
fn test_filter_by_channel_nightly_accepts_any() {
let releases = vec![
ReleaseInfo {
tag_name: "v0.3.0-alpha.1".to_string(),
name: "Alpha".to_string(),
prerelease: true,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v0.2.0".to_string(),
name: "Stable".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
},
];
let nightly = filter_by_channel(&releases, Channel::Nightly).unwrap();
assert_eq!(nightly.tag_name, "v0.3.0-alpha.1");
}
#[test]
fn test_filter_by_channel_empty_releases() {
let releases: Vec<ReleaseInfo> = vec![];
assert!(filter_by_channel(&releases, Channel::Stable).is_none());
assert!(filter_by_channel(&releases, Channel::Beta).is_none());
assert!(filter_by_channel(&releases, Channel::Nightly).is_none());
}
#[test]
fn test_filter_by_channel_beta_only() {
let releases = vec![ReleaseInfo {
tag_name: "v0.1.0".to_string(),
name: "Stable Only".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: None,
assets: vec![],
published_at: None,
}];
assert!(filter_by_channel(&releases, Channel::Beta).is_none());
}
#[test]
fn test_compute_changelog_diff_collects_intermediate_releases() {
let releases = vec![
ReleaseInfo {
tag_name: "v1.3.0".to_string(),
name: "Latest".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("Added feature C".to_string()),
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.2.0".to_string(),
name: "Middle".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("Added feature B".to_string()),
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.1.0".to_string(),
name: "Old".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("Added feature A".to_string()),
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.0.0".to_string(),
name: "Current".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("Initial release".to_string()),
assets: vec![],
published_at: None,
},
];
let current = Version::parse("v1.0.0").unwrap();
let target = Version::parse("v1.3.0").unwrap();
let diff = compute_changelog_diff(&releases, ¤t, &target);
assert!(diff.is_some());
let diff = diff.unwrap();
assert!(diff.contains("v1.1.0"));
assert!(diff.contains("v1.2.0"));
assert!(diff.contains("v1.3.0"));
assert!(diff.contains("feature A"));
assert!(diff.contains("feature B"));
assert!(diff.contains("feature C"));
assert!(!diff.contains("Initial release"));
}
#[test]
fn test_compute_changelog_diff_empty_when_no_intermediate() {
let releases = vec![
ReleaseInfo {
tag_name: "v1.1.0".to_string(),
name: "Target".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: None, assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.0.0".to_string(),
name: "Current".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("Current release".to_string()),
assets: vec![],
published_at: None,
},
];
let current = Version::parse("v1.0.0").unwrap();
let target = Version::parse("v1.1.0").unwrap();
let diff = compute_changelog_diff(&releases, ¤t, &target);
assert!(diff.is_none());
}
#[test]
fn test_compute_changelog_diff_skips_drafts() {
let releases = vec![
ReleaseInfo {
tag_name: "v1.2.0".to_string(),
name: "Target".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("Target release".to_string()),
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.1.0".to_string(),
name: "Draft".to_string(),
prerelease: false,
draft: true, html_url: "".to_string(),
body: Some("Draft notes".to_string()),
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.0.0".to_string(),
name: "Current".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("Current release".to_string()),
assets: vec![],
published_at: None,
},
];
let current = Version::parse("v1.0.0").unwrap();
let target = Version::parse("v1.2.0").unwrap();
let diff = compute_changelog_diff(&releases, ¤t, &target);
assert!(diff.is_some());
let diff = diff.unwrap();
assert!(diff.contains("v1.2.0"));
assert!(diff.contains("Target release"));
assert!(!diff.contains("Draft notes"));
}
#[test]
fn test_compute_changelog_diff_chronological_order() {
let releases = vec![
ReleaseInfo {
tag_name: "v1.3.0".to_string(),
name: "Latest".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("Third".to_string()),
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.2.0".to_string(),
name: "Middle".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("Second".to_string()),
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.1.0".to_string(),
name: "First".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("First".to_string()),
assets: vec![],
published_at: None,
},
];
let current = Version::parse("v1.0.0").unwrap();
let target = Version::parse("v1.3.0").unwrap();
let diff = compute_changelog_diff(&releases, ¤t, &target).unwrap();
let first_pos = diff.find("v1.1.0").unwrap();
let second_pos = diff.find("v1.2.0").unwrap();
let third_pos = diff.find("v1.3.0").unwrap();
assert!(first_pos < second_pos);
assert!(second_pos < third_pos);
}
#[test]
fn test_compute_changelog_diff_sorts_unsorted_release_input() {
let releases = vec![
ReleaseInfo {
tag_name: "v1.1.0".to_string(),
name: "First".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("First".to_string()),
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.3.0".to_string(),
name: "Third".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("Third".to_string()),
assets: vec![],
published_at: None,
},
ReleaseInfo {
tag_name: "v1.2.0".to_string(),
name: "Second".to_string(),
prerelease: false,
draft: false,
html_url: "".to_string(),
body: Some("Second".to_string()),
assets: vec![],
published_at: None,
},
];
let current = Version::parse("v1.0.0").unwrap();
let target = Version::parse("v1.3.0").unwrap();
let diff = compute_changelog_diff(&releases, ¤t, &target).unwrap();
let first_pos = diff.find("v1.1.0").unwrap();
let second_pos = diff.find("v1.2.0").unwrap();
let third_pos = diff.find("v1.3.0").unwrap();
assert!(first_pos < second_pos);
assert!(second_pos < third_pos);
}
}