lean_ctx/core/
version_check.rs1use 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
77pub 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
101pub 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
115pub 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}