use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct LatestRelease {
pub tag_name: String,
#[serde(default)]
pub html_url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallMethod {
CargoInstall,
DevBuild,
DirectBinary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UpdateCheckState {
pub last_checked_unix: u64,
pub last_known_latest: Option<String>,
}
pub fn default_interval() -> Duration {
Duration::from_secs(86400)
}
pub fn parse_interval(s: &str) -> Result<Duration> {
Ok(humantime::parse_duration(s)?)
}
pub fn detect_install_method(exe: &Path) -> InstallMethod {
let s = exe.to_string_lossy().replace('\\', "/").to_lowercase();
if s.contains("/target/debug/") || s.contains("/target/release/") {
return InstallMethod::DevBuild;
}
let cargo_bin = std::env::var("CARGO_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
.map(|p| {
p.join("bin")
.to_string_lossy()
.replace('\\', "/")
.to_lowercase()
});
if let Some(bin) = cargo_bin
&& s.starts_with(&format!("{}/", bin))
{
return InstallMethod::CargoInstall;
}
if s.contains("/.cargo/bin/") || s.contains("/cargo/bin/") {
return InstallMethod::CargoInstall;
}
InstallMethod::DirectBinary
}
pub fn is_update_available(current: &str, latest_tag: &str) -> Result<bool> {
let cur = semver::Version::parse(current)
.map_err(|e| anyhow!("invalid current version `{}`: {}", current, e))?;
let lat_str = latest_tag.trim_start_matches('v');
let lat = semver::Version::parse(lat_str)
.map_err(|e| anyhow!("invalid latest tag `{}`: {}", latest_tag, e))?;
Ok(lat > cur)
}
pub async fn check_latest_release() -> Result<LatestRelease> {
let url = "https://api.github.com/repos/yukimemi/rvpm/releases/latest";
let client = reqwest::Client::builder()
.user_agent(format!("rvpm/{}", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(5))
.build()?;
let res = client.get(url).send().await?;
if !res.status().is_success() {
return Err(anyhow!("GitHub releases API returned {}", res.status()));
}
let release: LatestRelease = res.json().await?;
Ok(release)
}
fn state_path(cache_root: &Path) -> PathBuf {
cache_root.join("last_update_check.json")
}
pub fn load_check_state(cache_root: &Path) -> Option<UpdateCheckState> {
let content = std::fs::read_to_string(state_path(cache_root)).ok()?;
serde_json::from_str(&content).ok()
}
pub fn save_check_state(cache_root: &Path, state: &UpdateCheckState) -> Result<()> {
std::fs::create_dir_all(cache_root)?;
let json = serde_json::to_string(state)?;
std::fs::write(state_path(cache_root), json)?;
Ok(())
}
pub fn should_auto_check(
state: Option<&UpdateCheckState>,
interval: Duration,
now: SystemTime,
) -> bool {
let Some(state) = state else {
return true;
};
let Ok(now_unix) = now.duration_since(SystemTime::UNIX_EPOCH) else {
return true;
};
let elapsed = now_unix.as_secs().saturating_sub(state.last_checked_unix);
elapsed >= interval.as_secs()
}
pub fn format_update_banner(current: &str, latest: &LatestRelease) -> String {
let tag = latest.tag_name.trim_start_matches('v');
let mut s = format!(
"\u{2699} rvpm {} available (current {}) — run `rvpm self-update` to upgrade",
tag, current
);
if !latest.html_url.is_empty() {
s.push_str(&format!("\n release notes: {}", latest.html_url));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_detect_install_method_cargo_unix() {
let p = PathBuf::from("/home/u/.cargo/bin/rvpm");
assert_eq!(detect_install_method(&p), InstallMethod::CargoInstall);
}
#[test]
fn test_detect_install_method_cargo_windows() {
let p = PathBuf::from(r"C:\Users\yukimemi\.cargo\bin\rvpm.exe");
assert_eq!(detect_install_method(&p), InstallMethod::CargoInstall);
}
#[test]
fn test_detect_install_method_dev_release_build() {
let p = PathBuf::from(
r"C:\Users\yukimemi\src\github.com\yukimemi\rvpm\target\release\rvpm.exe",
);
assert_eq!(detect_install_method(&p), InstallMethod::DevBuild);
}
#[test]
fn test_detect_install_method_dev_debug_build() {
let p = PathBuf::from("/home/u/src/rvpm/target/debug/rvpm");
assert_eq!(detect_install_method(&p), InstallMethod::DevBuild);
}
#[test]
fn test_detect_install_method_direct_binary() {
let p = PathBuf::from("/usr/local/bin/rvpm");
assert_eq!(detect_install_method(&p), InstallMethod::DirectBinary);
}
#[test]
fn test_detect_install_method_direct_binary_windows() {
let p = PathBuf::from(r"C:\tools\rvpm.exe");
assert_eq!(detect_install_method(&p), InstallMethod::DirectBinary);
}
#[test]
fn test_detect_install_method_respects_cargo_home() {
let prev = std::env::var("CARGO_HOME").ok();
unsafe {
std::env::set_var("CARGO_HOME", "/opt/rust");
}
let result = detect_install_method(&PathBuf::from("/opt/rust/bin/rvpm"));
unsafe {
match prev {
Some(v) => std::env::set_var("CARGO_HOME", v),
None => std::env::remove_var("CARGO_HOME"),
}
}
assert_eq!(result, InstallMethod::CargoInstall);
}
#[test]
fn test_is_update_available_newer() {
assert!(is_update_available("3.31.3", "v3.31.4").unwrap());
}
#[test]
fn test_is_update_available_same() {
assert!(!is_update_available("3.31.3", "v3.31.3").unwrap());
}
#[test]
fn test_is_update_available_older() {
assert!(!is_update_available("3.32.0", "v3.31.3").unwrap());
}
#[test]
fn test_is_update_available_handles_no_v_prefix() {
assert!(is_update_available("3.31.3", "3.31.4").unwrap());
}
#[test]
fn test_is_update_available_minor_bump() {
assert!(is_update_available("3.31.99", "v3.32.0").unwrap());
}
#[test]
fn test_is_update_available_major_bump() {
assert!(is_update_available("3.31.3", "v4.0.0").unwrap());
}
#[test]
fn test_is_update_available_invalid_current() {
assert!(is_update_available("not-a-version", "v3.31.4").is_err());
}
#[test]
fn test_is_update_available_invalid_latest() {
assert!(is_update_available("3.31.3", "vBROKEN").is_err());
}
#[test]
fn test_parse_interval_24h() {
assert_eq!(parse_interval("24h").unwrap(), Duration::from_secs(86400));
}
#[test]
fn test_parse_interval_30min() {
assert_eq!(parse_interval("30m").unwrap(), Duration::from_secs(1800));
}
#[test]
fn test_parse_interval_1d() {
assert_eq!(parse_interval("1d").unwrap(), Duration::from_secs(86400));
}
#[test]
fn test_parse_interval_invalid() {
assert!(parse_interval("not-a-duration").is_err());
}
#[test]
fn test_default_interval_is_24h() {
assert_eq!(default_interval(), Duration::from_secs(86400));
}
#[test]
fn test_should_auto_check_no_state() {
assert!(should_auto_check(
None,
Duration::from_secs(86400),
SystemTime::now()
));
}
#[test]
fn test_should_auto_check_recent_state_skipped() {
let now = SystemTime::now();
let now_unix = now
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let state = UpdateCheckState {
last_checked_unix: now_unix - 100, last_known_latest: None,
};
assert!(!should_auto_check(
Some(&state),
Duration::from_secs(86400),
now
));
}
#[test]
fn test_should_auto_check_old_state_runs() {
let now = SystemTime::now();
let now_unix = now
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let state = UpdateCheckState {
last_checked_unix: now_unix - 2 * 86400, last_known_latest: None,
};
assert!(should_auto_check(
Some(&state),
Duration::from_secs(86400),
now
));
}
#[test]
fn test_should_auto_check_at_exact_boundary_runs() {
let now = SystemTime::now();
let now_unix = now
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let state = UpdateCheckState {
last_checked_unix: now_unix - 86400,
last_known_latest: None,
};
assert!(should_auto_check(
Some(&state),
Duration::from_secs(86400),
now
));
}
#[test]
fn test_save_and_load_check_state_roundtrip() {
let tmp = TempDir::new().unwrap();
let cache = tmp.path();
let state = UpdateCheckState {
last_checked_unix: 1714752000,
last_known_latest: Some("v3.31.4".to_string()),
};
save_check_state(cache, &state).unwrap();
let loaded = load_check_state(cache).unwrap();
assert_eq!(loaded, state);
}
#[test]
fn test_load_check_state_missing_file_returns_none() {
let tmp = TempDir::new().unwrap();
assert!(load_check_state(tmp.path()).is_none());
}
#[test]
fn test_load_check_state_malformed_json_returns_none() {
let tmp = TempDir::new().unwrap();
std::fs::write(state_path(tmp.path()), "not-json").unwrap();
assert!(load_check_state(tmp.path()).is_none());
}
#[test]
fn test_save_check_state_creates_cache_dir() {
let tmp = TempDir::new().unwrap();
let cache = tmp.path().join("nested").join("dir");
let state = UpdateCheckState {
last_checked_unix: 1,
last_known_latest: None,
};
save_check_state(&cache, &state).unwrap();
assert!(cache.join("last_update_check.json").exists());
}
#[test]
fn test_format_update_banner_includes_versions() {
let release = LatestRelease {
tag_name: "v3.31.4".to_string(),
html_url: "https://github.com/yukimemi/rvpm/releases/tag/v3.31.4".to_string(),
};
let s = format_update_banner("3.31.3", &release);
assert!(s.contains("3.31.4"));
assert!(s.contains("3.31.3"));
assert!(s.contains("rvpm self-update"));
assert!(s.contains("github.com/yukimemi/rvpm/releases"));
}
#[test]
fn test_format_update_banner_omits_url_when_empty() {
let release = LatestRelease {
tag_name: "v3.31.4".to_string(),
html_url: String::new(),
};
let s = format_update_banner("3.31.3", &release);
assert!(s.contains("3.31.4"));
assert!(!s.contains("release notes"));
}
#[test]
fn test_format_update_banner_strips_v_prefix() {
let release = LatestRelease {
tag_name: "v3.31.4".to_string(),
html_url: String::new(),
};
let s = format_update_banner("3.31.3", &release);
assert!(s.contains("rvpm 3.31.4"), "got: {}", s);
assert!(!s.contains("rvpm v3.31.4"), "got: {}", s);
}
}