use std::path::Path;
use anyhow::{Context, Result};
use crate::{http::HttpClient, remote::release::Release, user_version::VersionSpec};
const GO_DL_API: &str = "https://go.dev/dl/?mode=json&include=all";
const CACHE_FILE: &str = "release-index.json";
const CACHE_TTL_SECS: u64 = 60 * 60;
#[derive(serde::Serialize, serde::Deserialize)]
struct ReleaseCache {
last_checked_unix: u64,
releases: Vec<Release>,
}
pub fn fetch_releases(client: &HttpClient, root: &Path) -> Result<Vec<Release>> {
let cache_path = root.join(CACHE_FILE);
let now = now_unix();
if let Some(cache) = load_cache(&cache_path) {
if !is_stale(cache.last_checked_unix, now) {
return Ok(cache.releases);
}
}
let releases = fetch_releases_uncached(client)?;
let _ = save_cache(
&cache_path,
&ReleaseCache {
last_checked_unix: now,
releases: releases.clone(),
},
);
Ok(releases)
}
fn fetch_releases_uncached(client: &HttpClient) -> Result<Vec<Release>> {
crate::http::log_request(client, "GET", GO_DL_API);
let mut response = client
.agent()
.get(GO_DL_API)
.call()
.context("Failed to reach go.dev - check your internet connection")?;
crate::http::log_response(
client,
response.status().as_u16(),
response.status().canonical_reason().unwrap_or(""),
response.headers(),
);
response
.body_mut()
.read_json::<Vec<Release>>()
.context("Failed to parse Go releases JSON")
}
fn load_cache(path: &Path) -> Option<ReleaseCache> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_cache(path: &Path, cache: &ReleaseCache) -> std::io::Result<()> {
let content = serde_json::to_string(cache).unwrap_or_default();
std::fs::write(path, content)
}
fn is_stale(last_checked_unix: u64, now: u64) -> bool {
now.saturating_sub(last_checked_unix) >= CACHE_TTL_SECS
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn resolve<'a>(spec: &VersionSpec, releases: &'a [Release]) -> Result<&'a Release> {
let mut stable = releases.iter().filter(|r| r.stable);
match spec {
VersionSpec::Latest => stable
.next()
.ok_or_else(|| anyhow::anyhow!("No stable releases found")),
_ => stable
.find(|r| r.go_version().is_some_and(|v| spec.matches(&v)))
.ok_or_else(|| {
anyhow::anyhow!(
"Go version '{}' not found. Run 'gvsn list-remote' to see available versions.",
spec
)
}),
}
}
pub fn download_url(filename: &str) -> String {
format!("https://go.dev/dl/{filename}")
}
pub fn host_os() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"darwin"
} else {
"linux"
}
}
pub fn host_arch() -> &'static str {
if cfg!(target_arch = "x86_64") {
"amd64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else if cfg!(target_arch = "arm") {
"arm"
} else if cfg!(target_arch = "x86") {
"386"
} else if cfg!(target_arch = "riscv64") {
"riscv64"
} else if cfg!(target_arch = "s390x") {
"s390x"
} else if cfg!(target_arch = "powerpc64") {
"ppc64le"
} else {
"386"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::remote::release::{Release, ReleaseFile};
use tempfile::tempdir;
#[test]
fn is_stale_before_ttl_is_false() {
assert!(!is_stale(1000, 1000 + CACHE_TTL_SECS - 1));
}
#[test]
fn is_stale_at_or_after_ttl_is_true() {
assert!(is_stale(1000, 1000 + CACHE_TTL_SECS));
}
#[test]
fn is_stale_handles_clock_going_backwards() {
assert!(!is_stale(1000, 500));
}
#[test]
fn cache_round_trips_through_disk() {
let dir = tempdir().unwrap();
let path = dir.path().join(CACHE_FILE);
let cache = ReleaseCache {
last_checked_unix: 12345,
releases: vec![release("go1.22.4", true)],
};
save_cache(&path, &cache).unwrap();
let loaded = load_cache(&path).unwrap();
assert_eq!(loaded.last_checked_unix, 12345);
assert_eq!(loaded.releases.len(), 1);
assert_eq!(loaded.releases[0].version, "go1.22.4");
}
#[test]
fn load_cache_returns_none_when_missing_or_corrupt() {
let dir = tempdir().unwrap();
assert!(load_cache(&dir.path().join("missing.json")).is_none());
let corrupt = dir.path().join("corrupt.json");
std::fs::write(&corrupt, "not json").unwrap();
assert!(load_cache(&corrupt).is_none());
}
#[test]
fn fetch_releases_uses_fresh_cache_without_a_network_call() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join(CACHE_FILE);
save_cache(
&cache_path,
&ReleaseCache {
last_checked_unix: now_unix(),
releases: vec![release("go1.22.4", true)],
},
)
.unwrap();
let client = crate::http::HttpClient::new(false, 0).unwrap();
let releases = fetch_releases(&client, dir.path()).unwrap();
assert_eq!(releases.len(), 1);
assert_eq!(releases[0].version, "go1.22.4");
}
fn release(version: &str, stable: bool) -> Release {
Release {
version: version.to_string(),
stable,
files: vec![ReleaseFile {
filename: format!("{version}.linux-amd64.tar.gz"),
os: "linux".to_string(),
arch: "amd64".to_string(),
sha256: "deadbeef".to_string(),
size: 123,
kind: "archive".to_string(),
}],
}
}
#[test]
fn resolve_latest_returns_first_stable_release() {
let releases = vec![
release("go1.23.0", true),
release("go1.22.4", true),
release("go1.22.3", true),
];
let resolved = resolve(&VersionSpec::Latest, &releases).unwrap();
assert_eq!(resolved.version, "go1.23.0");
}
#[test]
fn resolve_latest_skips_unstable_releases() {
let releases = vec![release("go1.24.0rc1", false), release("go1.23.0", true)];
let resolved = resolve(&VersionSpec::Latest, &releases).unwrap();
assert_eq!(resolved.version, "go1.23.0");
}
#[test]
fn resolve_latest_errors_when_no_stable_releases() {
let releases = vec![release("go1.24.0rc1", false)];
let err = resolve(&VersionSpec::Latest, &releases).unwrap_err();
assert!(err.to_string().contains("No stable releases"));
}
#[test]
fn resolve_partial_matches_newest_patch() {
let releases = vec![
release("go1.23.0", true),
release("go1.22.5", true),
release("go1.22.4", true),
];
let spec = VersionSpec::Partial {
major: 1,
minor: 22,
};
let resolved = resolve(&spec, &releases).unwrap();
assert_eq!(resolved.version, "go1.22.5");
}
#[test]
fn resolve_exact_requires_full_match() {
let releases = vec![release("go1.22.4", true), release("go1.22.5", true)];
let spec = VersionSpec::Exact {
major: 1,
minor: 22,
patch: 4,
};
let resolved = resolve(&spec, &releases).unwrap();
assert_eq!(resolved.version, "go1.22.4");
}
#[test]
fn resolve_errors_with_helpful_message_when_not_found() {
let releases = vec![release("go1.22.4", true)];
let spec = VersionSpec::Exact {
major: 9,
minor: 9,
patch: 9,
};
let err = resolve(&spec, &releases).unwrap_err();
assert!(err.to_string().contains("not found"));
assert!(err.to_string().contains("list-remote"));
}
#[test]
fn resolve_ignores_unstable_release_for_partial_and_exact() {
let releases = vec![release("go1.22.4", false)];
let partial = VersionSpec::Partial {
major: 1,
minor: 22,
};
assert!(resolve(&partial, &releases).is_err());
}
#[test]
fn download_url_builds_expected_link() {
assert_eq!(
download_url("go1.22.4.linux-amd64.tar.gz"),
"https://go.dev/dl/go1.22.4.linux-amd64.tar.gz"
);
}
#[test]
fn host_os_matches_a_known_platform() {
assert!(["windows", "darwin", "linux"].contains(&host_os()));
}
#[test]
fn host_arch_matches_a_known_architecture() {
assert!(
["amd64", "arm64", "arm", "386", "riscv64", "s390x", "ppc64le"].contains(&host_arch())
);
}
}