moz_cli_version_check/
lib.rs1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::fs;
4use std::io::Write;
5use std::path::PathBuf;
6use std::sync::mpsc::Receiver;
7use std::sync::Mutex;
8use std::thread;
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11const DEFAULT_CHECK_INTERVAL_SECONDS: u64 = 86400;
12const CHECK_TIMEOUT_SECONDS: u64 = 5;
13const UPDATE_CHECK_INTERVAL_ENV: &str = "MOZTOOLS_UPDATE_CHECK_INTERVAL_SECONDS";
14
15#[derive(Debug, Serialize, Deserialize)]
16struct ToolVersionInfo {
17 last_check: u64,
18 #[serde(default)]
19 latest: String,
20}
21
22#[derive(Debug, Serialize, Deserialize, Default)]
23struct VersionCache {
24 #[serde(flatten)]
25 tools: HashMap<String, ToolVersionInfo>,
26}
27
28#[derive(Debug, Deserialize)]
29struct CratesIoResponse {
30 #[serde(rename = "crate")]
31 crate_info: CrateInfo,
32}
33
34#[derive(Debug, Deserialize)]
35struct CrateInfo {
36 max_version: String,
37}
38
39pub struct VersionChecker {
40 tool_name: String,
41 current_version: String,
42 check_interval: Duration,
43 receiver: Mutex<Option<Receiver<Option<String>>>>,
44}
45
46impl VersionChecker {
47 pub fn new(tool_name: impl Into<String>, current_version: impl Into<String>) -> Self {
48 Self::with_check_interval(
49 tool_name,
50 current_version,
51 Duration::from_secs(get_check_interval_seconds()),
52 )
53 }
54
55 pub fn with_check_interval(
56 tool_name: impl Into<String>,
57 current_version: impl Into<String>,
58 check_interval: Duration,
59 ) -> Self {
60 Self {
61 tool_name: tool_name.into(),
62 current_version: current_version.into(),
63 check_interval,
64 receiver: Mutex::new(None),
65 }
66 }
67
68 pub fn check_async(&self) {
69 if std::env::var("MOZTOOLS_UPDATE_CHECK").unwrap_or_default() == "0" {
70 return;
71 }
72
73 let (tx, rx) = std::sync::mpsc::sync_channel(1);
74 if let Ok(mut guard) = self.receiver.lock() {
75 *guard = Some(rx);
76 }
77
78 let tool_name = self.tool_name.clone();
79 let current_version = self.current_version.clone();
80 let check_interval = self.check_interval;
81
82 thread::spawn(move || {
83 let result = check_version(&tool_name, ¤t_version, check_interval);
84 let _ = tx.send(result);
85 });
86 }
87
88 fn recv_update(&self, timeout: Duration) -> Option<String> {
89 let mut guard = self.receiver.lock().ok()?;
90 let rx = guard.as_ref()?;
91 match rx.recv_timeout(timeout) {
92 Ok(result) => {
93 *guard = None;
94 result
95 }
96 Err(_) => None,
97 }
98 }
99
100 pub fn print_warning(&self) {
101 if let Some(ref latest_version) = self.recv_update(Duration::from_millis(500)) {
102 self.print_update_message(latest_version);
103 }
104 }
105
106 pub fn print_warning_sync(&self) {
107 if let Some(ref latest_version) = self.recv_update(Duration::from_secs(6)) {
108 self.print_update_message(latest_version);
109 }
110 }
111
112 fn print_update_message(&self, latest_version: &str) {
113 eprintln!(
114 "Note: A newer version of {} is available (current: {}, latest: {})",
115 self.tool_name, self.current_version, latest_version
116 );
117 eprintln!(" Run: cargo binstall {}", self.tool_name);
118 }
119}
120
121fn get_cache_path() -> Option<PathBuf> {
122 dirs::home_dir().map(|home| home.join(".mozbuild").join("tool-versions.json"))
123}
124
125fn get_current_timestamp() -> u64 {
126 SystemTime::now()
127 .duration_since(UNIX_EPOCH)
128 .map(|d| d.as_secs())
129 .unwrap_or(0)
130}
131
132fn get_check_interval_seconds() -> u64 {
133 std::env::var(UPDATE_CHECK_INTERVAL_ENV)
134 .ok()
135 .and_then(|value| value.parse::<u64>().ok())
136 .filter(|seconds| *seconds > 0)
137 .unwrap_or(DEFAULT_CHECK_INTERVAL_SECONDS)
138}
139
140fn load_cache() -> VersionCache {
141 let cache_path = match get_cache_path() {
142 Some(path) => path,
143 None => return VersionCache::default(),
144 };
145
146 if !cache_path.exists() {
147 return VersionCache::default();
148 }
149
150 fs::read_to_string(&cache_path)
151 .ok()
152 .and_then(|content| serde_json::from_str(&content).ok())
153 .unwrap_or_default()
154}
155
156fn save_cache(cache: &VersionCache) {
157 let cache_path = match get_cache_path() {
158 Some(path) => path,
159 None => return,
160 };
161
162 if let Some(parent) = cache_path.parent() {
163 let _ = fs::create_dir_all(parent);
164 }
165
166 if let Ok(content) = serde_json::to_string_pretty(cache) {
167 let _ =
168 fs::File::create(&cache_path).and_then(|mut file| file.write_all(content.as_bytes()));
169 }
170}
171
172fn fetch_latest_version(tool_name: &str) -> Option<String> {
173 let url = format!("https://crates.io/api/v1/crates/{}", tool_name);
174
175 let client = reqwest::blocking::Client::builder()
176 .timeout(Duration::from_secs(CHECK_TIMEOUT_SECONDS))
177 .user_agent(format!("{}/version-check", tool_name))
178 .build()
179 .ok()?;
180
181 let response: CratesIoResponse = client.get(&url).send().ok()?.json().ok()?;
182
183 Some(response.crate_info.max_version)
184}
185
186fn is_newer_version(current: &str, latest: &str) -> bool {
187 let parse_version = |v: &str| -> Vec<u32> {
188 v.trim_start_matches('v')
189 .split('.')
190 .filter_map(|s| s.parse().ok())
191 .collect()
192 };
193
194 let current_parts = parse_version(current);
195 let latest_parts = parse_version(latest);
196
197 for (c, l) in current_parts.iter().zip(latest_parts.iter()) {
198 if l > c {
199 return true;
200 } else if l < c {
201 return false;
202 }
203 }
204
205 latest_parts.len() > current_parts.len()
206}
207
208fn check_version(
209 tool_name: &str,
210 current_version: &str,
211 check_interval: Duration,
212) -> Option<String> {
213 if let Ok(fake) = std::env::var("MOZTOOLS_FAKE_LATEST") {
214 return if is_newer_version(current_version, &fake) {
215 Some(fake)
216 } else {
217 None
218 };
219 }
220
221 let mut cache = load_cache();
222 let now = get_current_timestamp();
223 let check_interval = check_interval.as_secs();
224
225 if let Some(info) = cache.tools.get(tool_name) {
226 if now.saturating_sub(info.last_check) < check_interval {
227 if is_newer_version(current_version, &info.latest) {
228 return Some(info.latest.clone());
229 }
230 if is_newer_version(&info.latest, current_version) {
231 cache.tools.remove(tool_name);
232 save_cache(&cache);
233 }
234 return None;
235 }
236 }
237
238 let previous_latest = cache
239 .tools
240 .get(tool_name)
241 .map(|info| info.latest.clone())
242 .unwrap_or_default();
243
244 cache.tools.insert(
245 tool_name.to_string(),
246 ToolVersionInfo {
247 last_check: now,
248 latest: previous_latest.clone(),
249 },
250 );
251 save_cache(&cache);
252
253 let latest_version = match fetch_latest_version(tool_name) {
254 Some(version) => version,
255 None => {
256 if is_newer_version(current_version, &previous_latest) {
257 return Some(previous_latest);
258 }
259 return None;
260 }
261 };
262
263 cache.tools.insert(
264 tool_name.to_string(),
265 ToolVersionInfo {
266 last_check: now,
267 latest: latest_version.clone(),
268 },
269 );
270
271 save_cache(&cache);
272
273 if is_newer_version(current_version, &latest_version) {
274 Some(latest_version)
275 } else {
276 None
277 }
278}