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