From 3644f02b21fa939c896edb64ba196379ec65e0b2 Mon Sep 17 00:00:00 2001 From: cinco euzebio Date: Sun, 1 Mar 2026 04:07:38 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20periodic=20update=20check?= =?UTF-8?q?=20on=20startup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On startup, if `update_check_interval_hours` have elapsed since the last check, fetches the latest release tag from the Gitea API and prints a notice when a newer version is available. Silent on network failure or no update found. - New `update_check` module with injected fetcher for full testability - Cache at ~/.cache/tmuxido/update_check.json tracks timestamp + version - `fetch_latest_tag` and `version_compare` promoted to `pub(crate)` - 6 unit tests covering disabled, interval not elapsed, fetch triggered, equal versions, update available, and current-newer edge case --- src/lib.rs | 2 + src/main.rs | 4 + src/self_update.rs | 4 +- src/update_check.rs | 219 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/update_check.rs diff --git a/src/lib.rs b/src/lib.rs index 5e72580..3a93ee8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod deps; pub mod self_update; pub mod session; pub mod ui; +pub mod update_check; use anyhow::Result; use cache::ProjectCache; @@ -192,6 +193,7 @@ mod tests { max_depth: 3, cache_enabled, cache_ttl_hours: 24, + update_check_interval_hours: 24, default_session: session::SessionConfig { windows: vec![] }, } } diff --git a/src/main.rs b/src/main.rs index ef22052..20eb2f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use std::process::{Command, Stdio}; use tmuxido::config::Config; use tmuxido::deps::ensure_dependencies; use tmuxido::self_update; +use tmuxido::update_check; use tmuxido::{get_projects, launch_tmux_session, show_cache_status}; #[derive(Parser, Debug)] @@ -48,6 +49,9 @@ fn main() -> Result<()> { // Load config let config = Config::load()?; + // Periodic update check (silent on failure or no update) + update_check::check_and_notify(&config); + // Handle cache status command if args.cache_status { show_cache_status(&config)?; diff --git a/src/self_update.rs b/src/self_update.rs index 3f5b4b4..e2099e9 100644 --- a/src/self_update.rs +++ b/src/self_update.rs @@ -27,7 +27,7 @@ fn detect_arch() -> Result<&'static str> { } /// Fetch latest release tag from Gitea API -fn fetch_latest_tag() -> Result { +pub(crate) fn fetch_latest_tag() -> Result { let url = format!("{}/api/v1/repos/{}/releases?limit=1&page=1", BASE_URL, REPO); let output = Command::new("curl") @@ -166,7 +166,7 @@ pub fn self_update() -> Result<()> { } /// Compare two semver versions -fn version_compare(a: &str, b: &str) -> std::cmp::Ordering { +pub(crate) fn version_compare(a: &str, b: &str) -> std::cmp::Ordering { let parse = |s: &str| { s.split('.') .filter_map(|n| n.parse::().ok()) diff --git a/src/update_check.rs b/src/update_check.rs new file mode 100644 index 0000000..67e56ff --- /dev/null +++ b/src/update_check.rs @@ -0,0 +1,219 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::config::Config; +use crate::self_update; + +#[derive(Debug, Default, Serialize, Deserialize)] +struct UpdateCheckCache { + last_checked: u64, + latest_version: String, +} + +pub fn check_and_notify(config: &Config) { + let cache = load_cache(); + check_and_notify_internal( + config.update_check_interval_hours, + cache, + &|| self_update::fetch_latest_tag(), + &save_cache, + ); +} + +fn check_and_notify_internal( + interval_hours: u64, + mut cache: UpdateCheckCache, + fetcher: &dyn Fn() -> Result, + saver: &dyn Fn(&UpdateCheckCache), +) -> bool { + if interval_hours == 0 { + return false; + } + + let elapsed = elapsed_hours(cache.last_checked); + + if elapsed >= interval_hours + && let Ok(latest) = fetcher() + { + let latest_clean = latest.trim_start_matches('v').to_string(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + cache.last_checked = now; + cache.latest_version = latest_clean; + saver(&cache); + } + + let current = self_update::current_version(); + let latest_clean = cache.latest_version.trim_start_matches('v'); + if !latest_clean.is_empty() + && self_update::version_compare(latest_clean, current) == std::cmp::Ordering::Greater + { + print_update_notice(current, latest_clean); + return true; + } + + false +} + +fn print_update_notice(current: &str, latest: &str) { + let msg1 = format!(" Update available: {} \u{2192} {} ", current, latest); + let msg2 = " Run tmuxido --update to install. "; + let w1 = msg1.chars().count(); + let w2 = msg2.chars().count(); + let width = w1.max(w2); + let border = "\u{2500}".repeat(width); + println!("\u{250c}{}\u{2510}", border); + println!("\u{2502}{}\u{2502}", pad_to_chars(&msg1, width)); + println!("\u{2502}{}\u{2502}", pad_to_chars(msg2, width)); + println!("\u{2514}{}\u{2518}", border); +} + +fn pad_to_chars(s: &str, width: usize) -> String { + let char_count = s.chars().count(); + if char_count >= width { + s.to_string() + } else { + format!("{}{}", s, " ".repeat(width - char_count)) + } +} + +fn cache_path() -> Result { + let cache_dir = dirs::cache_dir() + .ok_or_else(|| anyhow::anyhow!("Could not determine cache directory"))? + .join("tmuxido"); + Ok(cache_dir.join("update_check.json")) +} + +fn load_cache() -> UpdateCheckCache { + cache_path() + .ok() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_cache(cache: &UpdateCheckCache) { + if let Ok(path) = cache_path() { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string(cache) { + let _ = std::fs::write(path, json); + } + } +} + +fn elapsed_hours(ts: u64) -> u64 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now.saturating_sub(ts) / 3600 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + fn make_cache(last_checked: u64, latest_version: &str) -> UpdateCheckCache { + UpdateCheckCache { + last_checked, + latest_version: latest_version.to_string(), + } + } + + fn now_ts() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + #[test] + fn should_not_notify_when_interval_is_zero() { + let cache = make_cache(0, "99.0.0"); + let fetcher_called = RefCell::new(false); + + let result = check_and_notify_internal( + 0, + cache, + &|| { + *fetcher_called.borrow_mut() = true; + Ok("99.0.0".to_string()) + }, + &|_| {}, + ); + + assert!(!result); + assert!(!fetcher_called.into_inner()); + } + + #[test] + fn should_not_check_when_interval_not_elapsed() { + let cache = make_cache(now_ts(), ""); + let fetcher_called = RefCell::new(false); + + check_and_notify_internal( + 24, + cache, + &|| { + *fetcher_called.borrow_mut() = true; + Ok("99.0.0".to_string()) + }, + &|_| {}, + ); + + assert!(!fetcher_called.into_inner()); + } + + #[test] + fn should_check_when_interval_elapsed() { + let cache = make_cache(0, ""); + let fetcher_called = RefCell::new(false); + + check_and_notify_internal( + 1, + cache, + &|| { + *fetcher_called.borrow_mut() = true; + Ok(self_update::current_version().to_string()) + }, + &|_| {}, + ); + + assert!(fetcher_called.into_inner()); + } + + #[test] + fn should_not_notify_when_versions_equal() { + let current = self_update::current_version(); + let cache = make_cache(now_ts(), current); + + let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {}); + + assert!(!result); + } + + #[test] + fn should_detect_update_available() { + let cache = make_cache(now_ts(), "99.0.0"); + + let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {}); + + assert!(result); + } + + #[test] + fn should_not_detect_update_when_current_is_newer() { + let cache = make_cache(now_ts(), "0.0.1"); + + let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {}); + + assert!(!result); + } +}