lean_ctx/core/
version_check.rs1use 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 let agent = ureq::Agent::new_with_config(
52 ureq::config::Config::builder()
53 .timeout_global(Some(std::time::Duration::from_secs(5)))
54 .build(),
55 );
56
57 let body = agent
58 .get(GITHUB_API_RELEASES)
59 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
60 .header("Accept", "application/vnd.github.v3+json")
61 .call()
62 .map_err(|e| e.to_string())?
63 .into_body()
64 .read_to_string()
65 .map_err(|e| e.to_string())?;
66
67 let release: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
68 let tag = release["tag_name"]
69 .as_str()
70 .ok_or_else(|| "missing tag_name in GitHub releases response".to_string())?;
71
72 let version = tag.trim().trim_start_matches('v').to_string();
73 if version.is_empty() || !version.contains('.') {
74 return Err("invalid version format".to_string());
75 }
76 Ok(version)
77}
78
79fn is_newer(latest: &str, current: &str) -> bool {
80 let parse =
81 |v: &str| -> Vec<u32> { v.split('.').filter_map(|p| p.parse::<u32>().ok()).collect() };
82 parse(latest) > parse(current)
83}
84
85pub 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 if !is_cache_stale(c) {
98 return;
99 }
100 }
101
102 std::thread::spawn(|| {
103 if let Ok(latest) = fetch_latest_version() {
104 write_cache(&latest);
105 }
106 });
107}
108
109pub 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
123pub 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
146pub 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 same_or_older_not_newer() {
177 assert!(!is_newer("2.9.13", "2.9.13"));
178 assert!(!is_newer("2.9.12", "2.9.13"));
179 assert!(!is_newer("1.0.0", "2.9.13"));
180 }
181
182 #[test]
183 fn cache_fresh_within_ttl() {
184 let fresh = VersionCache {
185 latest: "2.9.14".to_string(),
186 checked_at: now_secs(),
187 };
188 assert!(!is_cache_stale(&fresh));
189 }
190
191 #[test]
192 fn cache_stale_after_ttl() {
193 let old = VersionCache {
194 latest: "2.9.14".to_string(),
195 checked_at: now_secs() - CACHE_TTL_SECS - 1,
196 };
197 assert!(is_cache_stale(&old));
198 }
199
200 #[test]
201 fn version_json_has_required_fields() {
202 let json = version_info_json();
203 assert!(json.contains("current"));
204 assert!(json.contains("latest"));
205 assert!(json.contains("update_available"));
206 assert!(json.contains("checked_age_secs"));
207 }
208
209 #[test]
210 fn banner_none_for_current_version() {
211 assert!(!is_newer(CURRENT_VERSION, CURRENT_VERSION));
212 }
213
214 #[test]
215 fn session_hint_returns_once() {
216 NOTIFIED_THIS_SESSION.store(false, Ordering::Relaxed);
217 NOTIFIED_THIS_SESSION.store(false, Ordering::Relaxed);
219 let first_swap = NOTIFIED_THIS_SESSION.swap(true, Ordering::Relaxed);
220 assert!(
221 !first_swap,
222 "First call should get false (not yet notified)"
223 );
224 let second_swap = NOTIFIED_THIS_SESSION.swap(true, Ordering::Relaxed);
225 assert!(
226 second_swap,
227 "Second call should get true (already notified)"
228 );
229 }
230}