use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Read;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{headroom, rtk, ui, version};
const REMOTE_VERSION_URL: &str = "https://raw.githubusercontent.com/z19r/whetstone/main/VERSION";
const RELEASE_URL_BASE: &str = "https://github.com/z19r/whetstone/releases/download";
const CACHE_TTL_SECS: u64 = 12 * 60 * 60;
#[derive(Debug, Serialize, Deserialize)]
struct VersionCache {
whetstone_latest: String,
#[serde(default)]
rtk_latest: Option<String>,
#[serde(default)]
rtk_current: Option<String>,
#[serde(default)]
headroom_latest: Option<String>,
#[serde(default)]
headroom_current: Option<String>,
timestamp: u64,
}
pub struct OutdatedComponent {
pub name: &'static str,
pub current: String,
pub latest: String,
}
fn cache_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("could not determine home directory")?;
let dir = home.join(".cache").join("whetstone");
fs::create_dir_all(&dir)?;
Ok(dir.join("update-check"))
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn read_cache() -> Option<VersionCache> {
let path = cache_path().ok()?;
let content = fs::read_to_string(&path).ok()?;
if let Ok(cache) = serde_json::from_str::<VersionCache>(&content) {
return Some(cache);
}
let mut lines = content.lines();
let ver = lines.next()?.trim().to_string();
let ts: u64 = lines.next()?.trim().parse().ok()?;
Some(VersionCache {
whetstone_latest: ver,
rtk_latest: None,
rtk_current: None,
headroom_latest: None,
headroom_current: None,
timestamp: ts,
})
}
fn write_cache(cache: &VersionCache) {
if let Ok(path) = cache_path() {
if let Ok(json) = serde_json::to_string(cache) {
let _ = fs::write(path, json);
}
}
}
fn fetch_remote_version() -> Result<String> {
let body = ureq::get(REMOTE_VERSION_URL)
.call()
.context("fetching remote VERSION")?
.into_string()
.context("reading remote VERSION body")?;
version::extract_semver(body.trim()).context("no valid semver in remote VERSION")
}
pub fn check_cached_upgrade() -> Vec<OutdatedComponent> {
let mut outdated = Vec::new();
let Some(cache) = read_cache() else {
return outdated;
};
if now_epoch().saturating_sub(cache.timestamp) > CACHE_TTL_SECS {
return outdated;
}
let current_whetstone = version::current().to_string();
if version::is_older(¤t_whetstone, &cache.whetstone_latest) {
outdated.push(OutdatedComponent {
name: "whetstone",
current: current_whetstone,
latest: cache.whetstone_latest.clone(),
});
}
if let (Some(current), Some(latest)) = (&cache.rtk_current, &cache.rtk_latest) {
if version::is_older(current, latest) {
outdated.push(OutdatedComponent {
name: "rtk",
current: current.clone(),
latest: latest.clone(),
});
}
}
if let (Some(current), Some(latest)) = (&cache.headroom_current, &cache.headroom_latest) {
if version::is_older(current, latest) {
outdated.push(OutdatedComponent {
name: "headroom",
current: current.clone(),
latest: latest.clone(),
});
}
}
outdated
}
fn detect_target() -> Option<&'static str> {
let arch = std::env::consts::ARCH;
let os = std::env::consts::OS;
match (os, arch) {
("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"),
("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"),
("macos", "x86_64") => Some("x86_64-apple-darwin"),
("macos", "aarch64") => Some("aarch64-apple-darwin"),
_ => None,
}
}
fn self_update(latest: &str) -> Result<ui::ComponentStatus> {
let current = version::current().to_string();
if !version::is_older(¤t, latest) {
return Ok(ui::ComponentStatus::UpToDate(current));
}
let target = detect_target().context("unsupported platform for self-update")?;
let url = format!("{RELEASE_URL_BASE}/v{latest}/whetstone-{target}.tar.gz");
let mut sp = ui::spinner(&format!("downloading whetstone {latest}"));
let resp = ureq::get(&url)
.call()
.with_context(|| format!("downloading {url}"))?;
let mut compressed = Vec::new();
resp.into_reader()
.read_to_end(&mut compressed)
.context("reading release tarball")?;
sp.finish_and_clear();
let decoder = flate2::read::GzDecoder::new(compressed.as_slice());
let mut archive = tar::Archive::new(decoder);
let current_exe = std::env::current_exe().context("locating current binary")?;
let parent = current_exe
.parent()
.context("no parent dir for current binary")?;
let staging = parent.join(".whetstone-update");
for entry in archive.entries().context("reading tar entries")? {
let mut entry = entry.context("reading tar entry")?;
let path = entry.path().context("reading entry path")?;
if path.file_name().and_then(|n| n.to_str()) == Some("whetstone") {
entry
.unpack(&staging)
.context("extracting whetstone binary")?;
break;
}
}
if !staging.exists() {
bail!("whetstone binary not found in release tarball");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755)) {
let _ = fs::remove_file(&staging);
return Err(e).context("setting permissions on new binary");
}
}
let mut backup_name = current_exe
.file_name()
.context("no file name on current_exe")?
.to_os_string();
backup_name.push(".old");
let backup = current_exe.with_file_name(backup_name);
if let Err(e) = fs::rename(¤t_exe, &backup) {
let _ = fs::remove_file(&staging);
return Err(e).context("backing up current binary");
}
if let Err(e) = fs::rename(&staging, ¤t_exe) {
let _ = fs::rename(&backup, ¤t_exe);
let _ = fs::remove_file(&staging);
return Err(e).context("replacing binary with new version");
}
let _ = fs::remove_file(&backup);
Ok(ui::ComponentStatus::Updated(current, latest.to_string()))
}
pub fn run(_full: bool) -> Result<()> {
ui::section("whetstone update");
let mut sp = ui::spinner("checking for updates");
let whetstone_latest = fetch_remote_version()?;
let rtk_remote = rtk::latest_remote_version();
let headroom_remote = headroom::latest_remote_version();
sp.finish_and_clear();
let current = version::current().to_string();
let rtk_current = rtk::installed_version();
let headroom_current = headroom::installed_version();
let mut updated_count = 0u32;
let whetstone_status = match self_update(&whetstone_latest) {
Ok(status) => status,
Err(e) => ui::ComponentStatus::Failed(format!("{e:#}")),
};
if matches!(&whetstone_status, ui::ComponentStatus::Updated(_, _)) {
updated_count += 1;
}
ui::component_line("whetstone", &whetstone_status);
let rtk_status = match (&rtk_current, &rtk_remote) {
(Some(cur), Some(latest)) if !version::is_older(cur, latest) => {
ui::ComponentStatus::UpToDate(cur.clone())
}
(Some(_), _) => match rtk::update() {
Ok(status) => status,
Err(e) => ui::ComponentStatus::Failed(format!("{e:#}")),
},
(None, _) => ui::ComponentStatus::NotInstalled,
};
if matches!(&rtk_status, ui::ComponentStatus::Updated(_, _)) {
updated_count += 1;
}
ui::component_line("rtk", &rtk_status);
let headroom_status = match (&headroom_current, &headroom_remote) {
(Some(cur), Some(latest)) if !version::is_older(cur, latest) => {
ui::ComponentStatus::UpToDate(cur.clone())
}
(Some(_), _) => match headroom::update() {
Ok(status) => status,
Err(e) => ui::ComponentStatus::Failed(format!("{e:#}")),
},
(None, _) => ui::ComponentStatus::NotInstalled,
};
if matches!(&headroom_status, ui::ComponentStatus::Updated(_, _)) {
updated_count += 1;
}
ui::component_line("headroom", &headroom_status);
let memory_status = ui::ComponentStatus::UpToDate("embedded".into());
ui::component_line("memory (ICM)", &memory_status);
write_cache(&VersionCache {
whetstone_latest: whetstone_latest.clone(),
rtk_current: rtk::installed_version(),
rtk_latest: rtk_remote,
headroom_current: headroom::installed_version(),
headroom_latest: headroom_remote,
timestamp: now_epoch(),
});
if updated_count > 0 {
ui::summary_ok(&format!("Updated {updated_count} component(s)"));
if matches!(&whetstone_status, ui::ComponentStatus::Updated(_, _)) {
ui::info(&format!(
"whetstone updated from {current} → {whetstone_latest} — \
restart your shell to use it"
));
}
} else {
let has_failures = matches!(&whetstone_status, ui::ComponentStatus::Failed(_))
|| matches!(&rtk_status, ui::ComponentStatus::Failed(_))
|| matches!(&headroom_status, ui::ComponentStatus::Failed(_));
if has_failures {
ui::summary_info("Some components failed to update — see errors above");
} else {
ui::summary_ok("Everything is up to date");
}
}
Ok(())
}