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