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