use std::time::Duration;
use colored::Colorize;
use serde::Deserialize;
use crate::config::{Config, read_config, store_config};
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const GITHUB_API_URL: &str = "https://api.github.com/repos/timrogers/formanator/releases";
const UPDATE_CHECK_TIMEOUT_SECS: u64 = 2;
const SECONDS_PER_DAY: u64 = 86_400;
pub const DISABLE_UPDATE_CHECK_ENV: &str = "FORMANATOR_DISABLE_UPDATE_CHECK";
#[derive(Deserialize)]
struct GitHubRelease {
tag_name: String,
published_at: String,
}
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn should_check_for_updates(config: &Config) -> bool {
let Some(last_check) = config.last_update_check_timestamp else {
return true;
};
current_timestamp().saturating_sub(last_check) >= SECONDS_PER_DAY
}
fn is_release_old_enough(published_at: &str) -> bool {
use chrono::{DateTime, Duration as ChronoDuration, Utc};
let Ok(release_time) = DateTime::parse_from_rfc3339(published_at) else {
return false;
};
let cutoff = Utc::now() - ChronoDuration::hours(72);
release_time < cutoff
}
fn is_newer_version(latest: &str, current: &str) -> bool {
let parse_version = |v: &str| -> Option<(u32, u32, u32)> {
let parts: Vec<&str> = v.split('.').collect();
match parts.len() {
n if n >= 3 => Some((
parts[0].parse().ok()?,
parts[1].parse().ok()?,
parts[2].parse().ok()?,
)),
2 => Some((parts[0].parse().ok()?, parts[1].parse().ok()?, 0)),
1 => Some((parts[0].parse().ok()?, 0, 0)),
_ => None,
}
};
match (parse_version(latest), parse_version(current)) {
(Some(l), Some(c)) => l > c,
_ => false,
}
}
fn format_update_message(latest_version: &str) -> String {
format!(
"A new version of formanator is available: {} (current: v{})\n\
If you installed Formanator from Homebrew, you can upgrade by running `brew upgrade formanator`.\n\
If you installed it via Cargo, you can upgrade by running `cargo install formanator`.\n\
Otherwise, you can download the latest release at https://github.com/timrogers/formanator/releases/tag/{}",
latest_version, CURRENT_VERSION, latest_version
)
.yellow()
.to_string()
}
fn check_for_updates() -> Option<String> {
if std::env::var(DISABLE_UPDATE_CHECK_ENV).is_ok() {
return None;
}
let existing = read_config().ok().flatten();
let mut config = existing.clone().unwrap_or_default();
if !should_check_for_updates(&config) {
return None;
}
config.last_update_check_timestamp = Some(current_timestamp());
if existing.is_some() {
let _ = store_config(&config);
}
let client = match reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(UPDATE_CHECK_TIMEOUT_SECS))
.user_agent(format!("formanator/{CURRENT_VERSION}"))
.build()
{
Ok(client) => client,
Err(_) => return None,
};
let response = match client
.get(GITHUB_API_URL)
.header("Accept", "application/vnd.github.v3+json")
.send()
{
Ok(response) => response,
Err(e) => {
if e.is_timeout() {
eprintln!(
"Warning: Update check timed out after {UPDATE_CHECK_TIMEOUT_SECS} seconds"
);
}
return None;
}
};
let releases: Vec<GitHubRelease> = match response.json() {
Ok(releases) => releases,
Err(_) => return None,
};
let mut best_version: Option<String> = None;
for release in releases {
if !is_release_old_enough(&release.published_at) {
continue;
}
let release_version = release.tag_name.trim_start_matches('v');
if !is_newer_version(release_version, CURRENT_VERSION) {
continue;
}
match &best_version {
None => best_version = Some(release.tag_name),
Some(current_best) => {
if is_newer_version(release_version, current_best.trim_start_matches('v')) {
best_version = Some(release.tag_name);
}
}
}
}
best_version
}
pub fn print_update_notification() {
if let Some(latest_version) = check_for_updates() {
eprintln!("{}\n", format_update_message(&latest_version));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_newer_version_major() {
assert!(is_newer_version("4.0.0", "3.2.0"));
assert!(is_newer_version("2.0.0", "1.0.0"));
assert!(!is_newer_version("1.0.0", "2.0.0"));
assert!(!is_newer_version("3.0.0", "4.0.0"));
}
#[test]
fn is_newer_version_minor() {
assert!(is_newer_version("3.3.0", "3.2.0"));
assert!(is_newer_version("1.2.0", "1.1.0"));
assert!(!is_newer_version("1.1.0", "1.2.0"));
assert!(!is_newer_version("3.2.0", "3.3.0"));
}
#[test]
fn is_newer_version_patch() {
assert!(is_newer_version("3.2.1", "3.2.0"));
assert!(is_newer_version("1.0.5", "1.0.4"));
assert!(!is_newer_version("1.0.4", "1.0.5"));
assert!(!is_newer_version("3.2.0", "3.2.1"));
}
#[test]
fn is_newer_version_same_version() {
assert!(!is_newer_version("3.2.0", "3.2.0"));
assert!(!is_newer_version("1.0.0", "1.0.0"));
}
#[test]
fn is_newer_version_edge_cases() {
assert!(is_newer_version("3.3", "3.2"));
assert!(!is_newer_version("3.2", "3.3"));
assert!(is_newer_version("4", "3"));
assert!(!is_newer_version("3", "4"));
assert!(!is_newer_version("invalid", "3.2.0"));
assert!(!is_newer_version("3.2.0", "invalid"));
assert!(!is_newer_version("", "3.2.0"));
}
#[test]
fn should_check_for_updates_never_checked() {
let config = Config::default();
assert!(should_check_for_updates(&config));
}
#[test]
fn should_check_for_updates_checked_recently() {
let config = Config {
last_update_check_timestamp: Some(current_timestamp()),
..Config::default()
};
assert!(!should_check_for_updates(&config));
}
#[test]
fn should_check_for_updates_checked_long_ago() {
let config = Config {
last_update_check_timestamp: Some(current_timestamp() - SECONDS_PER_DAY - 1),
..Config::default()
};
assert!(should_check_for_updates(&config));
}
#[test]
fn should_check_for_updates_exactly_one_day() {
let config = Config {
last_update_check_timestamp: Some(current_timestamp() - SECONDS_PER_DAY),
..Config::default()
};
assert!(should_check_for_updates(&config));
}
#[test]
fn is_release_old_enough_handles_past_present_and_invalid() {
assert!(is_release_old_enough("2020-01-01T00:00:00Z"));
assert!(!is_release_old_enough("2099-01-01T00:00:00Z"));
assert!(!is_release_old_enough("invalid"));
}
#[test]
fn format_update_message_includes_versions_and_url() {
let message = format_update_message("v3.3.0");
assert!(message.contains("v3.3.0"));
assert!(message.contains(CURRENT_VERSION));
assert!(message.contains("https://github.com/timrogers/formanator/releases/tag/v3.3.0"));
}
}