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() == "0" {
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 _ =
121 fs::File::create(&cache_path).and_then(|mut file| file.write_all(content.as_bytes()));
122 }
123}
124
125fn fetch_latest_version(tool_name: &str) -> Option<String> {
126 let url = format!("https://crates.io/api/v1/crates/{}", tool_name);
127
128 let client = reqwest::blocking::Client::builder()
129 .timeout(Duration::from_secs(CHECK_TIMEOUT_SECONDS))
130 .user_agent(format!("{}/version-check", tool_name))
131 .build()
132 .ok()?;
133
134 let response: CratesIoResponse = client.get(&url).send().ok()?.json().ok()?;
135
136 Some(response.crate_info.max_version)
137}
138
139fn is_newer_version(current: &str, latest: &str) -> bool {
140 let parse_version = |v: &str| -> Vec<u32> {
141 v.trim_start_matches('v')
142 .split('.')
143 .filter_map(|s| s.parse().ok())
144 .collect()
145 };
146
147 let current_parts = parse_version(current);
148 let latest_parts = parse_version(latest);
149
150 for (c, l) in current_parts.iter().zip(latest_parts.iter()) {
151 if l > c {
152 return true;
153 } else if l < c {
154 return false;
155 }
156 }
157
158 latest_parts.len() > current_parts.len()
159}
160
161fn check_version(tool_name: &str, current_version: &str) -> Option<String> {
162 let mut cache = load_cache();
163 let now = get_current_timestamp();
164
165 if let Some(info) = cache.tools.get(tool_name) {
166 if now - info.last_check < CACHE_VALIDITY_SECONDS {
167 if is_newer_version(current_version, &info.latest) {
168 return Some(info.latest.clone());
169 }
170 return None;
171 }
172 }
173
174 let latest_version = fetch_latest_version(tool_name)?;
175
176 cache.tools.insert(
177 tool_name.to_string(),
178 ToolVersionInfo {
179 last_check: now,
180 latest: latest_version.clone(),
181 },
182 );
183
184 save_cache(&cache);
185
186 if is_newer_version(current_version, &latest_version) {
187 Some(latest_version)
188 } else {
189 None
190 }
191}