use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use crate::state::State;
use crate::time_fmt::{parse_rfc3339_secs, rfc3339_now};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandKind {
Quick,
LongLived,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckMode {
Never,
Daily,
Always,
}
#[derive(Debug, Clone)]
pub struct ShouldCheckInput<'a> {
pub command_kind: CommandKind,
pub stdout_is_tty: bool,
pub disabled_env: bool,
pub mode: CheckMode,
pub last_check: Option<&'a str>,
pub now: SystemTime,
}
const DAILY_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
pub fn should_check(input: &ShouldCheckInput<'_>) -> bool {
if input.disabled_env {
return false;
}
if matches!(input.mode, CheckMode::Never) {
return false;
}
if matches!(input.command_kind, CommandKind::LongLived) {
return false;
}
if !input.stdout_is_tty {
return false;
}
if matches!(input.mode, CheckMode::Always) {
return true;
}
match input.last_check.and_then(parse_rfc3339_secs) {
Some(last_secs) => match input.now.duration_since(SystemTime::UNIX_EPOCH) {
Ok(now_secs) => {
now_secs.saturating_sub(Duration::from_secs(last_secs)) >= DAILY_INTERVAL
}
Err(_) => true,
},
None => true,
}
}
pub fn parse_check_mode(raw: Option<&str>) -> CheckMode {
match raw.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
Some("never") => CheckMode::Never,
Some("always") => CheckMode::Always,
_ => CheckMode::Daily,
}
}
pub fn parse_disabled_env(raw: Option<&str>) -> bool {
matches!(raw, Some(v) if !v.is_empty())
}
#[derive(Debug)]
pub struct MaybeSpawnInput<'a> {
pub command_kind: CommandKind,
pub stdout_is_tty: bool,
pub disabled_env: Option<&'a str>,
pub mode_env: Option<&'a str>,
pub state_path: Option<&'a std::path::Path>,
pub api_url: &'a str,
pub current_version: &'a str,
pub now: SystemTime,
}
pub fn maybe_spawn(input: MaybeSpawnInput<'_>) -> bool {
let disabled = parse_disabled_env(input.disabled_env);
let mode = parse_check_mode(input.mode_env);
if disabled
|| matches!(mode, CheckMode::Never)
|| matches!(input.command_kind, CommandKind::LongLived)
|| !input.stdout_is_tty
{
return false;
}
let Some(state_path) = input.state_path else {
return false;
};
let last_check = State::load_from(state_path).last_update_check;
let predicate_input = ShouldCheckInput {
command_kind: input.command_kind,
stdout_is_tty: input.stdout_is_tty,
disabled_env: disabled,
mode,
last_check: last_check.as_deref(),
now: input.now,
};
if !should_check(&predicate_input) {
return false;
}
spawn_check(
input.api_url.to_string(),
Some(state_path.to_path_buf()),
input.current_version.to_string(),
);
true
}
pub fn spawn_check(api_url: String, state_path: Option<PathBuf>, current_version: String) {
std::thread::spawn(move || {
if let Err(e) = run_check_once(&api_url, state_path.as_deref(), ¤t_version) {
tracing::debug!(error = %e, "background update check failed");
}
});
}
pub fn run_check_once(
api_url: &str,
state_path: Option<&std::path::Path>,
current_version: &str,
) -> Result<(), CheckOnceError> {
let latest = fetch_latest_tag(api_url)?;
let outdated = compare_stable_versions(current_version, &latest) == std::cmp::Ordering::Less;
if let Some(path) = state_path {
let mut state = State::load_from(path);
state.last_update_check = Some(rfc3339_now());
state.last_known_latest = Some(latest);
state.last_known_outdated = outdated;
state
.save_to(path)
.map_err(|e| CheckOnceError::StateSave(e.to_string()))?;
}
Ok(())
}
#[derive(Debug)]
pub enum CheckOnceError {
Network(String),
Parse(String),
StateSave(String),
}
impl std::fmt::Display for CheckOnceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CheckOnceError::Network(m) => write!(f, "network error: {m}"),
CheckOnceError::Parse(m) => write!(f, "parse error: {m}"),
CheckOnceError::StateSave(m) => write!(f, "state save failed: {m}"),
}
}
}
impl std::error::Error for CheckOnceError {}
fn fetch_latest_tag(url: &str) -> Result<String, CheckOnceError> {
let client = reqwest::blocking::Client::builder()
.user_agent(concat!("cartog/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| CheckOnceError::Network(e.to_string()))?;
let response = client
.get(url)
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.map_err(|e| CheckOnceError::Network(e.to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(CheckOnceError::Network(format!("HTTP {status}")));
}
let body = response
.text()
.map_err(|e| CheckOnceError::Network(e.to_string()))?;
parse_release_tag(&body)
.ok_or_else(|| CheckOnceError::Parse("no stable release tag in response".to_string()))
}
fn parse_release_tag(json: &str) -> Option<String> {
let parsed: serde_json::Value = serde_json::from_str(json).ok()?;
let tag = parsed.get("tag_name")?.as_str()?;
let trimmed = tag.strip_prefix('v').unwrap_or(tag);
if trimmed.contains('-') {
return None;
}
if !is_stable_semver(trimmed) {
return None;
}
Some(trimmed.to_string())
}
fn is_stable_semver(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
}
fn compare_stable_versions(a: &str, b: &str) -> std::cmp::Ordering {
let parse = |s: &str| -> [u64; 3] {
let mut parts = s.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
[
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
]
};
parse(a).cmp(&parse(b))
}
#[cfg(test)]
mod tests {
use super::*;
fn epoch_plus(secs: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
}
fn input_default<'a>(now_secs: u64) -> ShouldCheckInput<'a> {
ShouldCheckInput {
command_kind: CommandKind::Quick,
stdout_is_tty: true,
disabled_env: false,
mode: CheckMode::Daily,
last_check: None,
now: epoch_plus(now_secs),
}
}
#[test]
fn should_check_first_run_with_tty_returns_true() {
let input = input_default(1_000_000);
assert!(should_check(&input));
}
#[test]
fn should_check_disabled_env_blocks() {
let mut input = input_default(1_000_000);
input.disabled_env = true;
assert!(!should_check(&input));
}
#[test]
fn should_check_never_mode_blocks() {
let mut input = input_default(1_000_000);
input.mode = CheckMode::Never;
assert!(!should_check(&input));
}
#[test]
fn should_check_long_lived_commands_blocked() {
let mut input = input_default(1_000_000);
input.command_kind = CommandKind::LongLived;
assert!(!should_check(&input));
}
#[test]
fn should_check_non_tty_blocked() {
let mut input = input_default(1_000_000);
input.stdout_is_tty = false;
assert!(!should_check(&input));
}
#[test]
fn should_check_always_mode_overrides_interval() {
let mut input = input_default(1_000_000);
input.mode = CheckMode::Always;
input.last_check = Some("1970-01-12T13:46:39Z");
assert!(should_check(&input));
}
#[test]
fn should_check_daily_within_24h_blocked() {
let last_secs: u64 = 1_704_067_200;
let now_secs = last_secs + 12 * 3600;
let mut input = input_default(now_secs);
input.last_check = Some("2024-01-01T00:00:00Z");
assert!(!should_check(&input));
}
#[test]
fn should_check_daily_after_24h_allowed() {
let last_secs: u64 = 1_704_067_200;
let now_secs = last_secs + 25 * 3600;
let mut input = input_default(now_secs);
input.last_check = Some("2024-01-01T00:00:00Z");
assert!(should_check(&input));
}
#[test]
fn should_check_daily_unparseable_last_treated_as_never() {
let mut input = input_default(1_000_000);
input.last_check = Some("not a real timestamp");
assert!(should_check(&input));
}
#[test]
fn should_check_long_lived_beats_always_mode() {
let mut input = input_default(1_000_000);
input.command_kind = CommandKind::LongLived;
input.mode = CheckMode::Always;
assert!(!should_check(&input));
}
#[test]
fn should_check_disabled_env_beats_always_mode() {
let mut input = input_default(1_000_000);
input.disabled_env = true;
input.mode = CheckMode::Always;
assert!(!should_check(&input));
}
#[test]
fn parse_check_mode_known_values() {
assert_eq!(parse_check_mode(Some("never")), CheckMode::Never);
assert_eq!(parse_check_mode(Some("NEVER")), CheckMode::Never);
assert_eq!(parse_check_mode(Some("always")), CheckMode::Always);
assert_eq!(parse_check_mode(Some("Daily")), CheckMode::Daily);
assert_eq!(parse_check_mode(Some(" always ")), CheckMode::Always);
}
#[test]
fn parse_check_mode_unknown_falls_back_to_daily() {
assert_eq!(parse_check_mode(None), CheckMode::Daily);
assert_eq!(parse_check_mode(Some("")), CheckMode::Daily);
assert_eq!(parse_check_mode(Some("something-else")), CheckMode::Daily);
}
#[test]
fn parse_disabled_env_truthy() {
assert!(parse_disabled_env(Some("1")));
assert!(parse_disabled_env(Some("yes")));
assert!(parse_disabled_env(Some("0"))); }
#[test]
fn parse_disabled_env_empty_or_unset() {
assert!(!parse_disabled_env(None));
assert!(!parse_disabled_env(Some("")));
}
}