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