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