1use std::env;
9use std::fs::{self, File};
10use std::io::{self, Write};
11use std::path::PathBuf;
12use std::process::Command;
13
14use crate::python::{Arch, Os, Platform};
15use crate::{Error, Result};
16
17const GITHUB_REPO: &str = "pro-rx/rx";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum InstallMethod {
23 Pip,
25 Cargo,
27 Binary,
29}
30
31impl std::fmt::Display for InstallMethod {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 InstallMethod::Pip => write!(f, "pip"),
35 InstallMethod::Cargo => write!(f, "cargo"),
36 InstallMethod::Binary => write!(f, "binary"),
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct ReleaseInfo {
44 pub version: String,
45 pub tag_name: String,
46 pub download_url: String,
47 pub asset_name: String,
48}
49
50pub struct SelfUpdater {
52 platform: Platform,
53 current_version: String,
54 install_method: InstallMethod,
55 exe_path: PathBuf,
56}
57
58impl SelfUpdater {
59 pub fn new(current_version: &str) -> Result<Self> {
61 let platform = Platform::current()?;
62 let exe_path = env::current_exe()
63 .map_err(|e| Error::Io(io::Error::new(io::ErrorKind::NotFound, e.to_string())))?;
64 let install_method = Self::detect_install_method(&exe_path);
65
66 Ok(Self {
67 platform,
68 current_version: current_version.to_string(),
69 install_method,
70 exe_path,
71 })
72 }
73
74 fn detect_install_method(exe_path: &PathBuf) -> InstallMethod {
76 let path_str = exe_path.to_string_lossy().to_lowercase();
77
78 if path_str.contains(".cargo") && path_str.contains("bin") {
80 return InstallMethod::Cargo;
81 }
82
83 if path_str.contains("target/release") || path_str.contains("target/debug") {
85 return InstallMethod::Cargo;
86 }
87
88 if path_str.contains("site-packages")
94 || (path_str.contains("bin") && path_str.contains("python"))
95 || (path_str.contains("scripts") && path_str.contains("python"))
96 || path_str.contains("/lib/python")
97 || path_str.contains("\\lib\\python")
98 {
99 return InstallMethod::Pip;
100 }
101
102 if Self::check_pip_owns_binary(exe_path) {
105 return InstallMethod::Pip;
106 }
107
108 InstallMethod::Binary
110 }
111
112 fn check_pip_owns_binary(exe_path: &PathBuf) -> bool {
114 let output = Command::new("pip")
116 .args(["show", "-f", "rx-pro"])
117 .output();
118
119 if let Ok(output) = output {
120 if output.status.success() {
121 let stdout = String::from_utf8_lossy(&output.stdout);
122 for line in stdout.lines() {
124 if let Some(location) = line.strip_prefix("Location: ") {
125 let exe_str = exe_path.to_string_lossy();
126 if exe_str.to_lowercase().contains(&location.to_lowercase()) {
127 return true;
128 }
129 }
130 }
131 }
132 }
133 false
134 }
135
136 pub fn install_method(&self) -> InstallMethod {
138 self.install_method
139 }
140
141 pub fn exe_path(&self) -> &PathBuf {
143 &self.exe_path
144 }
145
146 fn asset_name(&self) -> String {
148 let ext = match self.platform.os {
149 Os::Windows => "zip",
150 _ => "tar.gz",
151 };
152 format!("rx-{}.{}", self.platform.triple(), ext)
153 }
154
155 pub async fn check_latest(&self) -> Result<Option<ReleaseInfo>> {
157 let client = reqwest::Client::builder()
158 .user_agent("rx-self-update")
159 .build()
160 .map_err(|e| Error::UpdateError(e.to_string()))?;
161
162 let url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);
163
164 let response = client
165 .get(&url)
166 .send()
167 .await
168 .map_err(|e| Error::UpdateError(e.to_string()))?;
169
170 if !response.status().is_success() {
171 return Err(Error::UpdateError(format!(
172 "Failed to fetch release info: {}",
173 response.status()
174 )));
175 }
176
177 let release: serde_json::Value = response
178 .json()
179 .await
180 .map_err(|e| Error::UpdateError(e.to_string()))?;
181
182 let tag_name = release["tag_name"]
183 .as_str()
184 .ok_or_else(|| Error::UpdateError("Missing tag_name in release".to_string()))?;
185
186 let version = tag_name.strip_prefix('v').unwrap_or(tag_name);
188
189 if version == self.current_version {
191 return Ok(None);
192 }
193
194 let asset_name = self.asset_name();
196 let assets = release["assets"]
197 .as_array()
198 .ok_or_else(|| Error::UpdateError("Missing assets in release".to_string()))?;
199
200 let asset = assets
201 .iter()
202 .find(|a| a["name"].as_str() == Some(&asset_name))
203 .ok_or_else(|| {
204 Error::UpdateError(format!(
205 "No release asset found for platform: {}",
206 self.platform.triple()
207 ))
208 })?;
209
210 let download_url = asset["browser_download_url"]
211 .as_str()
212 .ok_or_else(|| Error::UpdateError("Missing download URL".to_string()))?;
213
214 Ok(Some(ReleaseInfo {
215 version: version.to_string(),
216 tag_name: tag_name.to_string(),
217 download_url: download_url.to_string(),
218 asset_name,
219 }))
220 }
221
222 pub fn update_via_pip(&self) -> Result<()> {
224 let status = Command::new("pip")
225 .args(["install", "--upgrade", "rx-pro"])
226 .status()
227 .map_err(|e| Error::Io(e))?;
228
229 if !status.success() {
230 return Err(Error::UpdateError("pip upgrade failed".to_string()));
231 }
232 Ok(())
233 }
234
235 pub fn update_via_cargo(&self) -> Result<()> {
237 let status = Command::new("cargo")
238 .args(["install", "pro-cli"])
239 .status()
240 .map_err(|e| Error::Io(e))?;
241
242 if !status.success() {
243 return Err(Error::UpdateError("cargo install failed".to_string()));
244 }
245 Ok(())
246 }
247
248 pub async fn update_binary(&self, release: &ReleaseInfo) -> Result<PathBuf> {
250 let client = reqwest::Client::builder()
251 .user_agent("rx-self-update")
252 .build()
253 .map_err(|e| Error::UpdateError(e.to_string()))?;
254
255 let response = client
257 .get(&release.download_url)
258 .send()
259 .await
260 .map_err(|e| Error::UpdateError(e.to_string()))?;
261
262 if !response.status().is_success() {
263 return Err(Error::UpdateError(format!(
264 "Failed to download release: {}",
265 response.status()
266 )));
267 }
268
269 let bytes = response
270 .bytes()
271 .await
272 .map_err(|e| Error::UpdateError(e.to_string()))?;
273
274 let temp_dir = env::temp_dir().join(format!("rx-update-{}", release.version));
276 if temp_dir.exists() {
277 fs::remove_dir_all(&temp_dir)?;
278 }
279 fs::create_dir_all(&temp_dir)?;
280
281 let archive_path = temp_dir.join(&release.asset_name);
283 let mut file = File::create(&archive_path)?;
284 file.write_all(&bytes)?;
285 drop(file);
286
287 let new_exe_path = if self.platform.os == Os::Windows {
289 self.extract_zip(&archive_path, &temp_dir)?
290 } else {
291 self.extract_tar_gz(&archive_path, &temp_dir)?
292 };
293
294 self.replace_executable(&self.exe_path, &new_exe_path)?;
296
297 let _ = fs::remove_dir_all(&temp_dir);
299
300 Ok(self.exe_path.clone())
301 }
302
303 fn extract_tar_gz(&self, archive_path: &PathBuf, temp_dir: &PathBuf) -> Result<PathBuf> {
305 let file = File::open(archive_path)?;
306 let decoder = flate2::read::GzDecoder::new(file);
307 let mut archive = tar::Archive::new(decoder);
308 archive.unpack(temp_dir)?;
309
310 let rx_path = temp_dir.join("rx");
312 if rx_path.exists() {
313 return Ok(rx_path);
314 }
315
316 for entry in fs::read_dir(temp_dir)? {
318 let entry = entry?;
319 let path = entry.path();
320 if path.is_dir() {
321 let rx_in_dir = path.join("rx");
322 if rx_in_dir.exists() {
323 return Ok(rx_in_dir);
324 }
325 }
326 }
327
328 Err(Error::Io(io::Error::new(
329 io::ErrorKind::NotFound,
330 "rx binary not found in archive",
331 )))
332 }
333
334 fn extract_zip(&self, archive_path: &PathBuf, temp_dir: &PathBuf) -> Result<PathBuf> {
336 let file = File::open(archive_path)?;
337 let mut archive = zip::ZipArchive::new(file)
338 .map_err(|e| Error::Io(io::Error::new(io::ErrorKind::InvalidData, e.to_string())))?;
339
340 archive
341 .extract(temp_dir)
342 .map_err(|e| Error::Io(io::Error::new(io::ErrorKind::InvalidData, e.to_string())))?;
343
344 let rx_path = temp_dir.join("rx.exe");
346 if rx_path.exists() {
347 return Ok(rx_path);
348 }
349
350 for entry in fs::read_dir(temp_dir)? {
352 let entry = entry?;
353 let path = entry.path();
354 if path.is_dir() {
355 let rx_in_dir = path.join("rx.exe");
356 if rx_in_dir.exists() {
357 return Ok(rx_in_dir);
358 }
359 }
360 }
361
362 Err(Error::Io(io::Error::new(
363 io::ErrorKind::NotFound,
364 "rx.exe binary not found in archive",
365 )))
366 }
367
368 fn replace_executable(&self, current: &PathBuf, new: &PathBuf) -> Result<()> {
370 #[cfg(unix)]
372 {
373 use std::os::unix::fs::PermissionsExt;
374 let mut perms = fs::metadata(new)?.permissions();
375 perms.set_mode(0o755);
376 fs::set_permissions(new, perms)?;
377 }
378
379 #[cfg(windows)]
382 {
383 let backup = current.with_extension("exe.old");
384 if backup.exists() {
385 fs::remove_file(&backup)?;
386 }
387 fs::rename(current, &backup)?;
388 fs::copy(new, current)?;
389 let _ = fs::remove_file(&backup);
391 }
392
393 #[cfg(not(windows))]
394 {
395 fs::copy(new, current)?;
397 }
398
399 Ok(())
400 }
401
402 pub fn is_newer(current: &str, latest: &str) -> bool {
404 let parse = |v: &str| -> (u32, u32, u32) {
406 let parts: Vec<&str> = v.split('.').collect();
407 let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
408 let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
409 let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
410 (major, minor, patch)
411 };
412
413 parse(latest) > parse(current)
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn test_is_newer() {
423 assert!(SelfUpdater::is_newer("0.1.11", "0.1.12"));
424 assert!(SelfUpdater::is_newer("0.1.12", "0.2.0"));
425 assert!(SelfUpdater::is_newer("0.1.12", "1.0.0"));
426 assert!(!SelfUpdater::is_newer("0.1.12", "0.1.12"));
427 assert!(!SelfUpdater::is_newer("0.1.12", "0.1.11"));
428 }
429
430 #[test]
431 fn test_asset_name() {
432 let updater = SelfUpdater {
433 platform: Platform::new(Os::Linux, Arch::X86_64),
434 current_version: "0.1.0".to_string(),
435 };
436 assert_eq!(updater.asset_name(), "rx-x86_64-unknown-linux-gnu.tar.gz");
437
438 let updater = SelfUpdater {
439 platform: Platform::new(Os::MacOS, Arch::Aarch64),
440 current_version: "0.1.0".to_string(),
441 };
442 assert_eq!(updater.asset_name(), "rx-aarch64-apple-darwin.tar.gz");
443
444 let updater = SelfUpdater {
445 platform: Platform::new(Os::Windows, Arch::X86_64),
446 current_version: "0.1.0".to_string(),
447 };
448 assert_eq!(updater.asset_name(), "rx-x86_64-pc-windows-msvc.zip");
449 }
450}