Skip to main content

moz_cli_version_check/
lib.rs

1use 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, &current_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}