Skip to main content

lean_ctx/core/
version_check.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5const VERSION_URL: &str = "https://leanctx.com/version.txt";
6const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
7const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
8
9#[derive(Serialize, Deserialize)]
10struct VersionCache {
11    latest: String,
12    checked_at: u64,
13}
14
15fn cache_path() -> Option<PathBuf> {
16    dirs::home_dir().map(|h| h.join(".lean-ctx/latest-version.json"))
17}
18
19fn now_secs() -> u64 {
20    SystemTime::now()
21        .duration_since(UNIX_EPOCH)
22        .map(|d| d.as_secs())
23        .unwrap_or(0)
24}
25
26fn read_cache() -> Option<VersionCache> {
27    let path = cache_path()?;
28    let content = std::fs::read_to_string(path).ok()?;
29    serde_json::from_str(&content).ok()
30}
31
32fn write_cache(latest: &str) {
33    if let Some(path) = cache_path() {
34        let cache = VersionCache {
35            latest: latest.to_string(),
36            checked_at: now_secs(),
37        };
38        if let Ok(json) = serde_json::to_string(&cache) {
39            let _ = std::fs::write(path, json);
40        }
41    }
42}
43
44fn is_cache_stale(cache: &VersionCache) -> bool {
45    let age = now_secs().saturating_sub(cache.checked_at);
46    age > CACHE_TTL_SECS
47}
48
49fn fetch_latest_version() -> Result<String, String> {
50    let agent = ureq::Agent::new_with_config(
51        ureq::config::Config::builder()
52            .timeout_global(Some(std::time::Duration::from_secs(5)))
53            .build(),
54    );
55
56    let body = agent
57        .get(VERSION_URL)
58        .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
59        .call()
60        .map_err(|e| e.to_string())?
61        .into_body()
62        .read_to_string()
63        .map_err(|e| e.to_string())?;
64
65    let version = body.trim().trim_start_matches('v').to_string();
66    if version.is_empty() || !version.contains('.') {
67        return Err("invalid version format".to_string());
68    }
69    Ok(version)
70}
71
72fn is_newer(latest: &str, current: &str) -> bool {
73    let parse =
74        |v: &str| -> Vec<u32> { v.split('.').filter_map(|p| p.parse::<u32>().ok()).collect() };
75    parse(latest) > parse(current)
76}
77
78/// Spawn a background thread to fetch latest version from leanctx.com/version.txt
79/// and write the result to ~/.lean-ctx/latest-version.json.
80/// Non-blocking, fire-and-forget. Skips if cache is fresh (<24h).
81/// Respects `update_check_disabled` config and `LEAN_CTX_NO_UPDATE_CHECK` env var.
82pub fn check_background() {
83    let cfg = super::config::Config::load();
84    if cfg.update_check_disabled_effective() {
85        return;
86    }
87
88    let cache = read_cache();
89    if let Some(ref c) = cache {
90        if !is_cache_stale(c) {
91            return;
92        }
93    }
94
95    std::thread::spawn(|| {
96        if let Ok(latest) = fetch_latest_version() {
97            write_cache(&latest);
98        }
99    });
100}
101
102/// Returns a formatted yellow update banner if a newer version is available.
103/// Reads only the local cache file — zero network calls, zero delay.
104pub fn get_update_banner() -> Option<String> {
105    let cache = read_cache()?;
106    if is_newer(&cache.latest, CURRENT_VERSION) {
107        Some(format!(
108            "  \x1b[33m\x1b[1m\u{27F3} Update available: v{CURRENT_VERSION} \u{2192} v{}\x1b[0m  \x1b[2m\u{2014} run:\x1b[0m \x1b[1mlean-ctx update\x1b[0m",
109            cache.latest
110        ))
111    } else {
112        None
113    }
114}
115
116/// Returns version info as JSON for the dashboard /api/version endpoint.
117pub fn version_info_json() -> String {
118    let cache = read_cache();
119    let (latest, update_available) = match cache {
120        Some(c) => {
121            let newer = is_newer(&c.latest, CURRENT_VERSION);
122            (c.latest, newer)
123        }
124        None => (CURRENT_VERSION.to_string(), false),
125    };
126
127    format!(
128        r#"{{"current":"{CURRENT_VERSION}","latest":"{latest}","update_available":{update_available}}}"#
129    )
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn newer_version_detected() {
138        assert!(is_newer("2.9.14", "2.9.13"));
139        assert!(is_newer("3.0.0", "2.9.99"));
140        assert!(is_newer("2.10.0", "2.9.14"));
141    }
142
143    #[test]
144    fn same_or_older_not_newer() {
145        assert!(!is_newer("2.9.13", "2.9.13"));
146        assert!(!is_newer("2.9.12", "2.9.13"));
147        assert!(!is_newer("1.0.0", "2.9.13"));
148    }
149
150    #[test]
151    fn cache_fresh_within_ttl() {
152        let fresh = VersionCache {
153            latest: "2.9.14".to_string(),
154            checked_at: now_secs(),
155        };
156        assert!(!is_cache_stale(&fresh));
157    }
158
159    #[test]
160    fn cache_stale_after_ttl() {
161        let old = VersionCache {
162            latest: "2.9.14".to_string(),
163            checked_at: now_secs() - CACHE_TTL_SECS - 1,
164        };
165        assert!(is_cache_stale(&old));
166    }
167
168    #[test]
169    fn version_json_has_required_fields() {
170        let json = version_info_json();
171        assert!(json.contains("current"));
172        assert!(json.contains("latest"));
173        assert!(json.contains("update_available"));
174    }
175
176    #[test]
177    fn banner_none_for_current_version() {
178        assert!(!is_newer(CURRENT_VERSION, CURRENT_VERSION));
179    }
180}