Skip to main content

koda_core/
version.rs

1//! Version checker — non-blocking startup check for newer crate versions.
2//!
3//! Spawns a background task that queries crates.io for `koda-cli`.
4//! If a newer version exists, prints a one-line hint after the banner.
5//! Never blocks startup — the REPL is ready immediately.
6//!
7//! ## Behavior
8//!
9//! - Runs once per session at startup
10//! - Timeout: 3 seconds (fails silently on slow networks)
11//! - Compares `semver::Version` of current binary vs latest on crates.io
12//! - Shows: `📦 Update available: 0.2.1 → 0.3.0 (cargo install koda-cli)`
13
14use std::time::Duration;
15
16const CRATE_NAME: &str = "koda-cli";
17const CRATES_IO_URL: &str = "https://crates.io/api/v1/crates/koda-cli";
18const CHECK_TIMEOUT: Duration = Duration::from_secs(3);
19
20/// Spawn a background version check. Returns a handle that can be awaited.
21pub fn spawn_version_check() -> tokio::task::JoinHandle<Option<String>> {
22    tokio::spawn(async move { check_latest_version().await })
23}
24
25/// Check whether `latest` is newer than the current version.
26/// Returns `Some((current, latest))` if an update is available.
27pub fn update_available(latest: &str) -> Option<(&'static str, String)> {
28    let current = env!("CARGO_PKG_VERSION");
29    if latest != current && is_newer(latest, current) {
30        Some((current, latest.to_string()))
31    } else {
32        None
33    }
34}
35
36/// The crate name, useful for building install commands.
37pub fn crate_name() -> &'static str {
38    CRATE_NAME
39}
40
41/// Query crates.io for the latest version.
42async fn check_latest_version() -> Option<String> {
43    let client = reqwest::Client::builder()
44        .timeout(CHECK_TIMEOUT)
45        .build()
46        .ok()?;
47
48    let resp = client
49        .get(CRATES_IO_URL)
50        .header(
51            "User-Agent",
52            format!("Koda/{} (version-check)", env!("CARGO_PKG_VERSION")),
53        )
54        .send()
55        .await
56        .ok()?;
57
58    if !resp.status().is_success() {
59        return None;
60    }
61
62    let body: serde_json::Value = resp.json().await.ok()?;
63    body.get("crate")?
64        .get("max_version")?
65        .as_str()
66        .map(|s| s.to_string())
67}
68
69/// Simple semver comparison: is `a` newer than `b`?
70fn is_newer(a: &str, b: &str) -> bool {
71    let parse = |s: &str| -> Vec<u64> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
72    let va = parse(a);
73    let vb = parse(b);
74    va > vb
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn test_is_newer() {
83        assert!(is_newer("0.2.0", "0.1.0"));
84        assert!(is_newer("1.0.0", "0.9.9"));
85        assert!(is_newer("0.1.1", "0.1.0"));
86        assert!(!is_newer("0.1.0", "0.1.0"));
87        assert!(!is_newer("0.1.0", "0.2.0"));
88    }
89
90    #[test]
91    fn test_is_newer_same_version() {
92        assert!(!is_newer("0.1.0", "0.1.0"));
93    }
94
95    #[test]
96    fn test_is_newer_single_component() {
97        assert!(is_newer("2", "1"));
98        assert!(!is_newer("1", "2"));
99    }
100
101    #[test]
102    fn test_is_newer_patch_only() {
103        assert!(is_newer("0.1.10", "0.1.9"));
104        assert!(!is_newer("0.1.9", "0.1.10"));
105    }
106
107    #[test]
108    fn test_crate_name() {
109        assert_eq!(crate_name(), "koda-cli");
110    }
111
112    #[test]
113    fn test_update_available_same_version_returns_none() {
114        let current = env!("CARGO_PKG_VERSION");
115        // Same version → no update
116        assert!(update_available(current).is_none());
117    }
118
119    #[test]
120    fn test_update_available_older_version_returns_none() {
121        // "0.0.1" is almost certainly older than any real build
122        assert!(update_available("0.0.1").is_none());
123    }
124
125    #[test]
126    fn test_update_available_future_version_returns_some() {
127        // "999.0.0" is always newer than any real build
128        let result = update_available("999.0.0");
129        assert!(result.is_some());
130        let (current, latest) = result.unwrap();
131        assert_eq!(latest, "999.0.0");
132        assert_eq!(current, env!("CARGO_PKG_VERSION"));
133    }
134}