use std::error::Error;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::time::Duration;
use axoupdater::{AxoUpdater, AxoupdateError, ReleaseSource, ReleaseSourceType, Version};
use repograph_core::RepographError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateOutcome {
Updated { from: Option<String>, to: String },
AlreadyCurrent,
UpdateAvailable { latest: String },
DeferToPackageManager,
}
pub const GITHUB_OWNER: &str = "maikbasel";
pub const GITHUB_REPO: &str = "repograph";
pub const APP_NAME: &str = "repograph";
pub const NO_UPDATE_CHECK_ENV: &str = "REPOGRAPH_NO_UPDATE_CHECK";
pub const NO_UPDATE_NOTIFIER_ENV: &str = "NO_UPDATE_NOTIFIER";
pub const CACHE_TTL_SECS: i64 = 24 * 60 * 60;
pub const QUERY_TIMEOUT_SECS: u64 = 3;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateCheckCache {
pub last_checked_unix: i64,
pub latest_seen: String,
}
#[must_use]
pub fn is_newer(current: &str, latest: &str) -> bool {
match (Version::parse(current), Version::parse(latest)) {
(Ok(current), Ok(latest)) => latest > current,
_ => false,
}
}
#[allow(clippy::fn_params_excessive_bools)]
#[must_use]
pub const fn should_notify(
stdout_is_tty: bool,
command_is_update: bool,
no_update_check_set: bool,
no_notifier_set: bool,
) -> bool {
stdout_is_tty && !command_is_update && !no_update_check_set && !no_notifier_set
}
#[must_use]
pub const fn cache_is_fresh(last_checked_unix: i64, now_unix: i64, ttl_secs: i64) -> bool {
now_unix.saturating_sub(last_checked_unix) < ttl_secs
}
#[must_use]
pub fn read_cache(path: &Path) -> Option<UpdateCheckCache> {
let raw = fs_err::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
}
pub fn write_cache(path: &Path, cache: &UpdateCheckCache) -> Result<(), RepographError> {
if let Some(parent) = path.parent() {
fs_err::create_dir_all(parent)?;
}
let json = serde_json::to_string(cache)
.map_err(|e| RepographError::UpdateFailed(format!("cache serialize: {e}")))?;
fs_err::write(path, json)?;
Ok(())
}
#[must_use]
pub fn cache_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join(APP_NAME).join("update-check.json"))
}
fn release_source() -> ReleaseSource {
ReleaseSource {
release_type: ReleaseSourceType::GitHub,
owner: GITHUB_OWNER.to_string(),
name: GITHUB_REPO.to_string(),
app_name: APP_NAME.to_string(),
}
}
fn runtime() -> Result<tokio::runtime::Runtime, RepographError> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| RepographError::UpdateFailed(format!("could not start async runtime: {e}")))
}
fn query_with_timeout<'a>(
rt: &tokio::runtime::Runtime,
fut: impl std::future::Future<Output = Result<Option<&'a Version>, AxoupdateError>>,
) -> Result<Option<Version>, RepographError> {
rt.block_on(async {
match tokio::time::timeout(Duration::from_secs(QUERY_TIMEOUT_SECS), fut).await {
Ok(Ok(latest)) => Ok(latest.cloned()),
Ok(Err(e)) => Err(RepographError::UpdateFailed(e.to_string())),
Err(_) => Err(RepographError::UpdateFailed(format!(
"version check timed out after {QUERY_TIMEOUT_SECS}s"
))),
}
})
}
pub fn query_latest() -> Result<Option<Version>, RepographError> {
let mut updater = AxoUpdater::new_for(APP_NAME);
updater.set_release_source(release_source());
let current = Version::parse(env!("CARGO_PKG_VERSION"))
.map_err(|e| RepographError::UpdateFailed(format!("bad current version: {e}")))?;
updater
.set_current_version(current)
.map_err(|e| RepographError::UpdateFailed(e.to_string()))?;
let rt = runtime()?;
query_with_timeout(&rt, updater.query_new_version())
}
pub fn run_update(check_only: bool) -> Result<UpdateOutcome, RepographError> {
let mut updater = AxoUpdater::new_for(APP_NAME);
match updater.load_receipt() {
Ok(_) => {}
Err(AxoupdateError::NoReceipt { .. }) => return Ok(UpdateOutcome::DeferToPackageManager),
Err(e) => return Err(RepographError::UpdateFailed(e.to_string())),
}
let rt = runtime()?;
if check_only {
let latest = query_with_timeout(&rt, updater.query_new_version())?;
return Ok(latest.map_or(UpdateOutcome::AlreadyCurrent, |version| {
UpdateOutcome::UpdateAvailable {
latest: version.to_string(),
}
}));
}
let install_path = updater
.install_prefix_root()
.ok()
.map(|p| p.as_std_path().to_path_buf());
match rt.block_on(updater.run()) {
Ok(Some(result)) => Ok(UpdateOutcome::Updated {
from: result.old_version.map(|v| v.to_string()),
to: result.new_version.to_string(),
}),
Ok(None) => Ok(UpdateOutcome::AlreadyCurrent),
Err(e) => Err(classify_run_error(&e, install_path)),
}
}
fn classify_run_error(err: &AxoupdateError, install_path: Option<PathBuf>) -> RepographError {
if has_permission_denied(err) {
RepographError::PermissionDenied {
path: install_path.unwrap_or_else(|| PathBuf::from("the repograph binary")),
}
} else {
RepographError::UpdateFailed(err.to_string())
}
}
fn has_permission_denied(err: &(dyn Error + 'static)) -> bool {
let mut current: Option<&(dyn Error + 'static)> = Some(err);
while let Some(e) = current {
if let Some(io) = e.downcast_ref::<std::io::Error>() {
if io.kind() == ErrorKind::PermissionDenied {
return true;
}
}
current = e.source();
}
false
}
fn env_flag(name: &str) -> bool {
std::env::var_os(name).is_some_and(|v| !v.is_empty())
}
fn latest_throttled() -> Option<String> {
let now = time::OffsetDateTime::now_utc().unix_timestamp();
let path = cache_path();
if let Some(path) = path.as_ref() {
if let Some(cache) = read_cache(path) {
if cache_is_fresh(cache.last_checked_unix, now, CACHE_TTL_SECS) {
return Some(cache.latest_seen);
}
}
}
let latest = match query_latest() {
Ok(Some(version)) => version.to_string(),
Ok(None) => env!("CARGO_PKG_VERSION").to_string(),
Err(_) => return None,
};
if let Some(path) = path {
let _ = write_cache(
&path,
&UpdateCheckCache {
last_checked_unix: now,
latest_seen: latest.clone(),
},
);
}
Some(latest)
}
pub fn notify(command_is_update: bool) {
use is_terminal::IsTerminal;
let stdout_is_tty = std::io::stdout().is_terminal();
if !should_notify(
stdout_is_tty,
command_is_update,
env_flag(NO_UPDATE_CHECK_ENV),
env_flag(NO_UPDATE_NOTIFIER_ENV),
) {
return;
}
let current = env!("CARGO_PKG_VERSION");
let Some(latest) = latest_throttled() else {
return;
};
if is_newer(current, &latest) {
let mut stderr = std::io::stderr().lock();
let _ = crate::output::render_update_notice(&mut stderr, current, &latest);
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn is_newer_true_when_latest_greater() {
assert!(is_newer("0.2.1", "0.3.0"));
assert!(is_newer("0.2.1", "0.2.2"));
assert!(is_newer("1.0.0", "2.0.0"));
}
#[test]
fn is_newer_false_when_equal_or_older() {
assert!(!is_newer("0.2.1", "0.2.1"));
assert!(!is_newer("0.3.0", "0.2.9"));
assert!(!is_newer("2.0.0", "1.9.9"));
}
#[test]
fn is_newer_false_on_unparseable() {
assert!(!is_newer("not-a-version", "0.3.0"));
assert!(!is_newer("0.2.1", "garbage"));
}
#[test]
fn is_newer_respects_prerelease_ordering() {
assert!(is_newer("0.3.0-rc.1", "0.3.0"));
assert!(!is_newer("0.3.0", "0.3.0-rc.1"));
}
#[test]
fn should_notify_true_only_when_all_gates_pass() {
assert!(should_notify(true, false, false, false));
}
#[test]
fn should_notify_false_when_stdout_not_tty() {
assert!(!should_notify(false, false, false, false));
}
#[test]
fn should_notify_false_for_update_command() {
assert!(!should_notify(true, true, false, false));
}
#[test]
fn should_notify_false_when_opted_out() {
assert!(!should_notify(true, false, true, false));
assert!(!should_notify(true, false, false, true));
}
#[test]
fn cache_fresh_within_ttl() {
assert!(cache_is_fresh(
1_000,
1_000 + CACHE_TTL_SECS - 1,
CACHE_TTL_SECS
));
assert!(cache_is_fresh(1_000, 1_000, CACHE_TTL_SECS)); }
#[test]
fn cache_stale_at_or_past_ttl() {
assert!(!cache_is_fresh(
1_000,
1_000 + CACHE_TTL_SECS,
CACHE_TTL_SECS
));
assert!(!cache_is_fresh(
1_000,
1_000 + CACHE_TTL_SECS + 1,
CACHE_TTL_SECS
));
}
#[test]
fn cache_roundtrips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("update-check.json");
let cache = UpdateCheckCache {
last_checked_unix: 1_700_000_000,
latest_seen: "0.3.0".to_string(),
};
write_cache(&path, &cache).unwrap();
assert_eq!(read_cache(&path), Some(cache));
}
#[test]
fn read_cache_missing_file_is_none() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(read_cache(&dir.path().join("absent.json")), None);
}
#[test]
fn read_cache_malformed_file_is_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, b"{ not valid json").unwrap();
assert_eq!(read_cache(&path), None);
}
#[derive(Debug)]
struct Wrapper(std::io::Error);
impl std::fmt::Display for Wrapper {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "wrapper")
}
}
impl Error for Wrapper {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.0)
}
}
#[test]
fn permission_denied_found_through_source_chain() {
let err = Wrapper(std::io::Error::from(ErrorKind::PermissionDenied));
assert!(has_permission_denied(&err));
}
#[test]
fn permission_denied_at_top_level() {
let err = std::io::Error::from(ErrorKind::PermissionDenied);
assert!(has_permission_denied(&err));
}
#[test]
fn non_permission_error_is_not_flagged() {
let err = Wrapper(std::io::Error::from(ErrorKind::NotFound));
assert!(!has_permission_denied(&err));
}
}