trackWork 0.15.0

A terminal-based time tracking application for managing work sessions
//! Crates.io "is there a newer release?" probe.
//!
//! Spawns a background thread on startup so the TUI never waits on the
//! network. Result is written into a shared `Arc<Mutex<Option<String>>>` that
//! the top bar reads each frame — when `Some(v)`, `v` is a stable release on
//! crates.io strictly newer than the running `CARGO_PKG_VERSION`.
//!
//! A successful (or unsuccessful) lookup is cached in
//! `~/.timetrack.update_check` for 24h so we don't ping crates.io on every
//! launch. All failures (offline, parse error, missing cache) degrade to
//! "no nudge"; we never block startup and never surface errors to the user.
//!
//! Designed to use the existing `reqwest` (blocking) + `serde_json` deps; no
//! new crates pulled in.

use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

const CRATE_NAME: &str = "trackWork";
const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
const HTTP_TIMEOUT: Duration = Duration::from_secs(4);

/// Shared slot the background thread fills with the latest crates.io version
/// when it's strictly newer than the running build. UI reads via `.lock()`.
pub type UpdateSlot = Arc<Mutex<Option<String>>>;

/// Kick off the background check. Returns the slot immediately; the thread
/// fills it asynchronously. The returned `JoinHandle` is dropped — we don't
/// care about the result on the main thread.
pub fn spawn_check() -> UpdateSlot {
    let slot: UpdateSlot = Arc::new(Mutex::new(None));
    let slot_for_thread = slot.clone();
    std::thread::spawn(move || {
        if let Some(latest) = check_blocking() {
            if is_newer(&latest, env!("CARGO_PKG_VERSION")) {
                if let Ok(mut g) = slot_for_thread.lock() {
                    *g = Some(latest);
                }
            }
        }
    });
    slot
}

/// Returns the newest stable version string from crates.io, preferring the
/// cache when it's fresh. `None` on any failure (offline, bad JSON, etc.).
fn check_blocking() -> Option<String> {
    if let Some(cached) = read_cache_if_fresh() {
        return Some(cached);
    }
    let fetched = fetch_latest_from_crates_io()?;
    let _ = write_cache(&fetched);
    Some(fetched)
}

fn fetch_latest_from_crates_io() -> Option<String> {
    let url = format!("https://crates.io/api/v1/crates/{}", CRATE_NAME);
    // crates.io requires a descriptive User-Agent (with contact info) on all
    // requests, or it rejects with 403.
    let ua = format!("trackWork/{} (https://crates.io/crates/trackWork)", env!("CARGO_PKG_VERSION"));
    let client = reqwest::blocking::Client::builder()
        .timeout(HTTP_TIMEOUT)
        .user_agent(ua)
        .build()
        .ok()?;
    let resp = client.get(url).send().ok()?;
    if !resp.status().is_success() {
        return None;
    }
    let json: serde_json::Value = resp.json().ok()?;
    // Prefer max_stable_version (skips pre-releases); fall back to max_version.
    json.get("crate")
        .and_then(|c| {
            c.get("max_stable_version")
                .or_else(|| c.get("max_version"))
        })
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
}

fn cache_path() -> Option<PathBuf> {
    dirs::home_dir().map(|p| p.join(".timetrack.update_check"))
}

fn read_cache_if_fresh() -> Option<String> {
    let path = cache_path()?;
    let text = std::fs::read_to_string(&path).ok()?;
    // Tiny hand-rolled parser: two `key = "value"` lines. Avoids pulling in
    // `toml` here just for this — the file is ours and trivial.
    let mut checked_at: Option<u64> = None;
    let mut latest: Option<String> = None;
    for line in text.lines() {
        let line = line.trim();
        if let Some(rest) = line.strip_prefix("checked_at = ") {
            checked_at = rest.trim().parse().ok();
        } else if let Some(rest) = line.strip_prefix("latest = ") {
            latest = Some(rest.trim().trim_matches('"').to_string());
        }
    }
    let checked = checked_at?;
    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
    if now.saturating_sub(checked) > CACHE_TTL_SECS {
        return None;
    }
    latest.filter(|s| !s.is_empty())
}

fn write_cache(latest: &str) -> std::io::Result<()> {
    let Some(path) = cache_path() else {
        return Ok(());
    };
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let body = format!("checked_at = {}\nlatest = \"{}\"\n", now, latest);
    std::fs::write(path, body)
}

/// Strict `a > b` over `MAJOR.MINOR.PATCH`. A pre-release `remote`
/// (`0.14.3-rc.1`) never nudges — we only surface stable releases.
fn is_newer(remote: &str, local: &str) -> bool {
    // Refuse to nudge to a pre-release.
    if remote.contains('-') {
        return false;
    }
    let parse = |s: &str| -> Option<(u32, u32, u32)> {
        let core = s.split(['-', '+']).next()?;
        let mut it = core.split('.');
        let a = it.next()?.parse().ok()?;
        let b = it.next()?.parse().ok()?;
        let c = it.next().unwrap_or("0").parse().ok()?;
        Some((a, b, c))
    };
    match (parse(remote), parse(local)) {
        (Some(r), Some(l)) => r > l,
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::is_newer;

    #[test]
    fn newer_versions_detected() {
        assert!(is_newer("0.14.3", "0.14.2"));
        assert!(is_newer("0.15.0", "0.14.99"));
        assert!(is_newer("1.0.0", "0.99.99"));
    }

    #[test]
    fn same_or_older_not_newer() {
        assert!(!is_newer("0.14.2", "0.14.2"));
        assert!(!is_newer("0.14.1", "0.14.2"));
        assert!(!is_newer("0.13.99", "0.14.0"));
    }

    #[test]
    fn prereleases_ignored() {
        // Pre-releases shouldn't nudge stable users.
        assert!(!is_newer("0.14.3-rc.1", "0.14.2"));
    }
}