stmo-cli 0.5.4

CLI for version controlling Redash queries and dashboards
Documentation
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const CACHE_MAX_AGE_SECS: u64 = 86400;
const FETCH_TIMEOUT_SECS: u64 = 2;

#[derive(Debug, Serialize, Deserialize)]
struct VersionCache {
    latest_version: String,
    last_checked: u64,
}

#[must_use]
pub fn installed_via_cargo() -> bool {
    let Ok(exe) = std::env::current_exe() else {
        return false;
    };
    let Ok(exe) = exe.canonicalize() else {
        return false;
    };
    let Some(home) = dirs::home_dir() else {
        return false;
    };
    let cargo_bin = home.join(".cargo").join("bin");
    exe.starts_with(cargo_bin)
}

fn cache_path() -> Option<PathBuf> {
    Some(dirs::cache_dir()?.join("stmo-cli").join("version-check.json"))
}

fn read_cache() -> Option<VersionCache> {
    let path = cache_path()?;
    let data = std::fs::read_to_string(path).ok()?;
    serde_json::from_str(&data).ok()
}

fn write_cache(version: &str, timestamp: u64) {
    let Some(path) = cache_path() else { return };
    let Some(parent) = path.parent() else { return };
    let _ = std::fs::create_dir_all(parent);
    let cache = VersionCache {
        latest_version: version.to_string(),
        last_checked: timestamp,
    };
    if let Ok(data) = serde_json::to_string(&cache) {
        let _ = std::fs::write(path, data);
    }
}

fn should_check(cache: Option<&VersionCache>) -> bool {
    let Some(cache) = cache else { return true };
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    now.saturating_sub(cache.last_checked) > CACHE_MAX_AGE_SECS
}

async fn fetch_latest_version(base_url: &str) -> Option<String> {
    let url = format!("{base_url}/api/v1/crates/stmo-cli");
    let user_agent = format!("stmo-cli/{CURRENT_VERSION}");
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
        .build()
        .ok()?;
    let resp = client
        .get(&url)
        .header("User-Agent", user_agent)
        .send()
        .await
        .ok()?;
    if !resp.status().is_success() {
        return None;
    }
    let body: serde_json::Value = resp.json().await.ok()?;
    body["crate"]["max_version"]
        .as_str()
        .map(str::to_string)
}

#[must_use]
pub fn is_newer(latest: &str, current: &str) -> bool {
    let Ok(latest) = latest.parse::<semver::Version>() else {
        return false;
    };
    let Ok(current) = current.parse::<semver::Version>() else {
        return false;
    };
    latest > current
}

#[allow(dead_code)]
pub async fn check_for_update_from(base_url: &str) -> Option<String> {
    let latest = fetch_latest_version(base_url).await?;
    if is_newer(&latest, CURRENT_VERSION) {
        Some(latest)
    } else {
        None
    }
}

pub async fn check_and_auto_update() {
    if !installed_via_cargo() {
        return;
    }

    let cache = read_cache();
    let latest = if should_check(cache.as_ref()) {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let Some(version) = fetch_latest_version("https://crates.io").await else {
            return;
        };
        write_cache(&version, now);
        version
    } else {
        let Some(cache) = cache else { return };
        cache.latest_version
    };

    if !is_newer(&latest, CURRENT_VERSION) {
        return;
    }

    eprintln!("Updating stmo-cli {CURRENT_VERSION} → {latest}...");

    let status = std::process::Command::new("cargo")
        .args(["install", "stmo-cli"])
        .status();

    match status {
        Ok(s) if s.success() => {
            let args: Vec<String> = std::env::args().collect();
            #[cfg(unix)]
            {
                use std::os::unix::process::CommandExt as _;
                let mut cmd = std::process::Command::new(&args[0]);
                cmd.args(&args[1..]);
                let err = cmd.exec();
                eprintln!("Failed to re-exec: {err}");
            }
            #[cfg(not(unix))]
            {
                let _ = std::process::Command::new(&args[0])
                    .args(&args[1..])
                    .status();
                std::process::exit(0);
            }
        }
        _ => {
            eprintln!("Update failed, continuing with current version.");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn is_newer_detects_patch_bump() {
        assert!(is_newer("0.3.1", "0.3.0"));
    }

    #[test]
    fn is_newer_detects_minor_bump() {
        assert!(is_newer("0.4.0", "0.3.0"));
    }

    #[test]
    fn is_newer_detects_major_bump() {
        assert!(is_newer("1.0.0", "0.3.0"));
    }

    #[test]
    fn is_newer_same_version_false() {
        assert!(!is_newer("0.3.0", "0.3.0"));
    }

    #[test]
    fn is_newer_older_version_false() {
        assert!(!is_newer("0.2.0", "0.3.0"));
    }

    #[test]
    fn is_newer_invalid_latest_false() {
        assert!(!is_newer("not-a-version", "0.3.0"));
    }

    #[test]
    fn is_newer_invalid_current_false() {
        assert!(!is_newer("0.4.0", "not-a-version"));
    }

    #[test]
    fn should_check_returns_true_when_no_cache() {
        assert!(should_check(None));
    }

    #[test]
    fn should_check_returns_true_for_stale_cache() {
        let stale = VersionCache {
            latest_version: "0.3.0".to_string(),
            last_checked: 0,
        };
        assert!(should_check(Some(&stale)));
    }

    #[test]
    fn should_check_returns_false_for_fresh_cache() {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let fresh = VersionCache {
            latest_version: "0.3.0".to_string(),
            last_checked: now,
        };
        assert!(!should_check(Some(&fresh)));
    }

    #[test]
    fn cache_roundtrip() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("version-check.json");

        let cache = VersionCache {
            latest_version: "1.2.3".to_string(),
            last_checked: 9999,
        };
        std::fs::write(&path, serde_json::to_string(&cache).unwrap()).unwrap();

        let loaded: VersionCache =
            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(loaded.latest_version, "1.2.3");
        assert_eq!(loaded.last_checked, 9999);
    }
}