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 GITHUB_API_RELEASES: &str = "https://api.github.com/repos/yvgude/lean-ctx/releases/latest";
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    crate::core::paths::cache_dir()
17        .ok()
18        .map(|d| d.join("latest-version.json"))
19}
20
21fn now_secs() -> u64 {
22    SystemTime::now()
23        .duration_since(UNIX_EPOCH)
24        .map_or(0, |d| d.as_secs())
25}
26
27fn read_cache() -> Option<VersionCache> {
28    let path = cache_path()?;
29    let content = std::fs::read_to_string(path).ok()?;
30    serde_json::from_str(&content).ok()
31}
32
33fn write_cache(latest: &str) {
34    if let Some(path) = cache_path() {
35        let cache = VersionCache {
36            latest: latest.to_string(),
37            checked_at: now_secs(),
38        };
39        if let Ok(json) = serde_json::to_string(&cache) {
40            let _ = std::fs::write(path, json);
41        }
42    }
43}
44
45fn is_cache_stale(cache: &VersionCache) -> bool {
46    let age = now_secs().saturating_sub(cache.checked_at);
47    age > CACHE_TTL_SECS
48}
49
50fn fetch_latest_version() -> Result<String, String> {
51    // Same client as `lean-ctx update`: token-aware and rate-limit aware (#2037).
52    let release = crate::core::updater::github_api_json(GITHUB_API_RELEASES)?;
53    let tag = release["tag_name"]
54        .as_str()
55        .ok_or_else(|| "missing tag_name in GitHub releases response".to_string())?;
56
57    let version = tag.trim().trim_start_matches('v').to_string();
58    if version.is_empty() || !version.contains('.') {
59        return Err("invalid version format".to_string());
60    }
61    Ok(version)
62}
63
64/// SemVer precedence on `MAJOR.MINOR.PATCH[-prerelease]`: a prerelease sorts
65/// before its release (`3.12.1-dogfood.9` < `3.12.1`). Splitting the whole
66/// string on `.` read `3.12.1-dogfood.9` as 3.12.9, so dogfood and rc builds
67/// never saw their own release as an update.
68pub fn is_newer(latest: &str, current: &str) -> bool {
69    let parse = |v: &str| -> (Vec<u32>, bool) {
70        let v = v.trim().trim_start_matches('v');
71        let (core, pre) = match v.split_once('-') {
72            Some((core, pre)) => (core, !pre.is_empty()),
73            None => (v, false),
74        };
75        let core = core
76            .split('.')
77            .map(|p| p.parse::<u32>().unwrap_or(0))
78            .collect();
79        // A release outranks every prerelease of the same core.
80        (core, !pre)
81    };
82    parse(latest) > parse(current)
83}
84
85/// Spawn a background thread to fetch latest version from GitHub Releases
86/// and write the result to the lean-ctx data dir (`latest-version.json`).
87/// Non-blocking, fire-and-forget. Skips if cache is fresh (<24h).
88/// Respects `update_check_disabled` config and `LEAN_CTX_NO_UPDATE_CHECK` env var.
89pub fn check_background() {
90    let cfg = super::config::Config::load();
91    if cfg.update_check_disabled_effective() {
92        return;
93    }
94
95    let cache = read_cache();
96    if let Some(ref c) = cache
97        && !is_cache_stale(c)
98    {
99        return;
100    }
101
102    std::thread::spawn(|| {
103        if let Ok(latest) = fetch_latest_version() {
104            write_cache(&latest);
105        }
106    });
107}
108
109/// Returns a formatted yellow update banner if a newer version is available.
110/// Reads only the local cache file — zero network calls, zero delay.
111pub fn get_update_banner() -> Option<String> {
112    let cache = read_cache()?;
113    if is_newer(&cache.latest, CURRENT_VERSION) {
114        Some(format!(
115            "  \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",
116            cache.latest
117        ))
118    } else {
119        None
120    }
121}
122
123/// Returns version info as JSON for the dashboard /api/version endpoint.
124/// Includes the cache age so the UI can be honest about staleness (#563).
125pub fn version_info_json() -> String {
126    let cache = read_cache();
127    let (latest, update_available, age_secs) = match cache {
128        Some(c) => {
129            let newer = is_newer(&c.latest, CURRENT_VERSION);
130            let age = now_secs().saturating_sub(c.checked_at);
131            (c.latest, newer, Some(age))
132        }
133        None => (CURRENT_VERSION.to_string(), false, None),
134    };
135
136    let age_json = age_secs.map_or("null".to_string(), |a| a.to_string());
137    format!(
138        r#"{{"current":"{CURRENT_VERSION}","latest":"{latest}","update_available":{update_available},"checked_age_secs":{age_json}}}"#
139    )
140}
141
142use std::sync::atomic::{AtomicBool, Ordering};
143
144static NOTIFIED_THIS_SESSION: AtomicBool = AtomicBool::new(false);
145
146/// Returns a one-line update notification if available, exactly once per session.
147/// Safe to call from any tool — returns None after first notification.
148pub fn session_update_hint() -> Option<String> {
149    if NOTIFIED_THIS_SESSION.swap(true, Ordering::Relaxed) {
150        return None;
151    }
152
153    let cache = read_cache()?;
154    if !is_newer(&cache.latest, CURRENT_VERSION) {
155        return None;
156    }
157
158    Some(format!(
159        "[lean-ctx] Update available: v{CURRENT_VERSION} → v{} (run: lean-ctx update)",
160        cache.latest
161    ))
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn newer_version_detected() {
170        assert!(is_newer("2.9.14", "2.9.13"));
171        assert!(is_newer("3.0.0", "2.9.99"));
172        assert!(is_newer("2.10.0", "2.9.14"));
173    }
174
175    #[test]
176    fn a_release_is_newer_than_its_prereleases() {
177        assert!(is_newer("3.12.1", "3.12.1-dogfood.9"));
178        assert!(is_newer("v3.12.1", "3.12.1-rc.1"));
179        assert!(is_newer("3.12.2", "3.12.1-dogfood.9"));
180        assert!(!is_newer("3.12.1-dogfood.9", "3.12.1"));
181        assert!(!is_newer("3.12.0", "3.12.1-dogfood.9"));
182        assert!(!is_newer("3.12.1", "3.12.1"));
183    }
184
185    #[test]
186    fn same_or_older_not_newer() {
187        assert!(!is_newer("2.9.13", "2.9.13"));
188        assert!(!is_newer("2.9.12", "2.9.13"));
189        assert!(!is_newer("1.0.0", "2.9.13"));
190    }
191
192    #[test]
193    fn cache_fresh_within_ttl() {
194        let fresh = VersionCache {
195            latest: "2.9.14".to_string(),
196            checked_at: now_secs(),
197        };
198        assert!(!is_cache_stale(&fresh));
199    }
200
201    #[test]
202    fn cache_stale_after_ttl() {
203        let old = VersionCache {
204            latest: "2.9.14".to_string(),
205            checked_at: now_secs() - CACHE_TTL_SECS - 1,
206        };
207        assert!(is_cache_stale(&old));
208    }
209
210    #[test]
211    fn version_json_has_required_fields() {
212        let json = version_info_json();
213        assert!(json.contains("current"));
214        assert!(json.contains("latest"));
215        assert!(json.contains("update_available"));
216        assert!(json.contains("checked_age_secs"));
217    }
218
219    #[test]
220    fn banner_none_for_current_version() {
221        assert!(!is_newer(CURRENT_VERSION, CURRENT_VERSION));
222    }
223
224    #[test]
225    fn session_hint_returns_once() {
226        NOTIFIED_THIS_SESSION.store(false, Ordering::Relaxed);
227        // No cache file in test env, so we verify the atomic gate directly
228        NOTIFIED_THIS_SESSION.store(false, Ordering::Relaxed);
229        let first_swap = NOTIFIED_THIS_SESSION.swap(true, Ordering::Relaxed);
230        assert!(
231            !first_swap,
232            "First call should get false (not yet notified)"
233        );
234        let second_swap = NOTIFIED_THIS_SESSION.swap(true, Ordering::Relaxed);
235        assert!(
236            second_swap,
237            "Second call should get true (already notified)"
238        );
239    }
240}