1use std::env;
9use std::fs::{self, File};
10use std::io::{self, Write};
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14#[cfg(test)]
15use crate::python::Arch;
16use crate::python::{Os, Platform};
17use crate::{Error, Result};
18
19const GITHUB_REPO: &str = "get-rx/rx-pro";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum InstallMethod {
25 Pip,
27 Cargo,
29 Binary,
31}
32
33impl std::fmt::Display for InstallMethod {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 InstallMethod::Pip => write!(f, "pip"),
37 InstallMethod::Cargo => write!(f, "cargo"),
38 InstallMethod::Binary => write!(f, "binary"),
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct ReleaseInfo {
46 pub version: String,
47 pub tag_name: String,
48 pub download_url: String,
49 pub asset_name: String,
50}
51
52pub struct SelfUpdater {
54 platform: Platform,
55 current_version: String,
56 install_method: InstallMethod,
57 exe_path: PathBuf,
58}
59
60impl SelfUpdater {
61 pub fn new(current_version: &str) -> Result<Self> {
63 let platform = Platform::current()?;
64 let exe_path = env::current_exe()
65 .map_err(|e| Error::Io(io::Error::new(io::ErrorKind::NotFound, e.to_string())))?;
66 let install_method = Self::detect_install_method(&exe_path);
67
68 Ok(Self {
69 platform,
70 current_version: current_version.to_string(),
71 install_method,
72 exe_path,
73 })
74 }
75
76 fn detect_install_method(exe_path: &Path) -> InstallMethod {
78 let path_str = exe_path.to_string_lossy().to_lowercase();
79
80 if path_str.contains(".cargo") && path_str.contains("bin") {
82 return InstallMethod::Cargo;
83 }
84
85 if path_str.contains("target/release") || path_str.contains("target/debug") {
87 return InstallMethod::Cargo;
88 }
89
90 if path_str.contains("site-packages")
96 || (path_str.contains("bin") && path_str.contains("python"))
97 || (path_str.contains("scripts") && path_str.contains("python"))
98 || path_str.contains("/lib/python")
99 || path_str.contains("\\lib\\python")
100 {
101 return InstallMethod::Pip;
102 }
103
104 if Self::check_pip_owns_binary(exe_path) {
107 return InstallMethod::Pip;
108 }
109
110 InstallMethod::Binary
112 }
113
114 fn check_pip_owns_binary(exe_path: &Path) -> bool {
116 let output = Command::new("pip").args(["show", "-f", "rx-pro"]).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!(
163 "https://api.github.com/repos/{}/releases/latest",
164 GITHUB_REPO
165 );
166
167 let response = client
168 .get(&url)
169 .send()
170 .await
171 .map_err(|e| Error::UpdateError(e.to_string()))?;
172
173 if !response.status().is_success() {
174 return Err(Error::UpdateError(format!(
175 "Failed to fetch release info: {}",
176 response.status()
177 )));
178 }
179
180 let release: serde_json::Value = response
181 .json()
182 .await
183 .map_err(|e| Error::UpdateError(e.to_string()))?;
184
185 let tag_name = release["tag_name"]
186 .as_str()
187 .ok_or_else(|| Error::UpdateError("Missing tag_name in release".to_string()))?;
188
189 let version = tag_name.strip_prefix('v').unwrap_or(tag_name);
191
192 if version == self.current_version {
194 return Ok(None);
195 }
196
197 let asset_name = self.asset_name();
199 let assets = release["assets"]
200 .as_array()
201 .ok_or_else(|| Error::UpdateError("Missing assets in release".to_string()))?;
202
203 let asset = assets
204 .iter()
205 .find(|a| a["name"].as_str() == Some(&asset_name))
206 .ok_or_else(|| {
207 Error::UpdateError(format!(
208 "No release asset found for platform: {}",
209 self.platform.triple()
210 ))
211 })?;
212
213 let download_url = asset["browser_download_url"]
214 .as_str()
215 .ok_or_else(|| Error::UpdateError("Missing download URL".to_string()))?;
216
217 Ok(Some(ReleaseInfo {
218 version: version.to_string(),
219 tag_name: tag_name.to_string(),
220 download_url: download_url.to_string(),
221 asset_name,
222 }))
223 }
224
225 pub fn update_via_pip(&self) -> Result<()> {
227 let status = Command::new("pip")
228 .args(["install", "--upgrade", "rx-pro"])
229 .status()
230 .map_err(Error::Io)?;
231
232 if !status.success() {
233 return Err(Error::UpdateError("pip upgrade failed".to_string()));
234 }
235 Ok(())
236 }
237
238 pub fn update_via_cargo(&self) -> Result<()> {
240 let status = Command::new("cargo")
241 .args(["install", "pro-cli"])
242 .status()
243 .map_err(Error::Io)?;
244
245 if !status.success() {
246 return Err(Error::UpdateError("cargo install failed".to_string()));
247 }
248 Ok(())
249 }
250
251 pub async fn update_binary(&self, release: &ReleaseInfo) -> Result<PathBuf> {
253 let client = reqwest::Client::builder()
254 .user_agent("rx-self-update")
255 .build()
256 .map_err(|e| Error::UpdateError(e.to_string()))?;
257
258 let response = client
260 .get(&release.download_url)
261 .send()
262 .await
263 .map_err(|e| Error::UpdateError(e.to_string()))?;
264
265 if !response.status().is_success() {
266 return Err(Error::UpdateError(format!(
267 "Failed to download release: {}",
268 response.status()
269 )));
270 }
271
272 let bytes = response
273 .bytes()
274 .await
275 .map_err(|e| Error::UpdateError(e.to_string()))?;
276
277 let temp_dir = env::temp_dir().join(format!("rx-update-{}", release.version));
279 if temp_dir.exists() {
280 fs::remove_dir_all(&temp_dir)?;
281 }
282 fs::create_dir_all(&temp_dir)?;
283
284 let archive_path = temp_dir.join(&release.asset_name);
286 let mut file = File::create(&archive_path)?;
287 file.write_all(&bytes)?;
288 drop(file);
289
290 let new_exe_path = if self.platform.os == Os::Windows {
292 self.extract_zip(&archive_path, &temp_dir)?
293 } else {
294 self.extract_tar_gz(&archive_path, &temp_dir)?
295 };
296
297 self.replace_executable(&self.exe_path, &new_exe_path)?;
299
300 let _ = fs::remove_dir_all(&temp_dir);
302
303 Ok(self.exe_path.clone())
304 }
305
306 fn extract_tar_gz(&self, archive_path: &PathBuf, temp_dir: &PathBuf) -> Result<PathBuf> {
308 let file = File::open(archive_path)?;
309 let decoder = flate2::read::GzDecoder::new(file);
310 let mut archive = tar::Archive::new(decoder);
311 archive.unpack(temp_dir)?;
312
313 let rx_path = temp_dir.join("rx");
315 if rx_path.exists() {
316 return Ok(rx_path);
317 }
318
319 for entry in fs::read_dir(temp_dir)? {
321 let entry = entry?;
322 let path = entry.path();
323 if path.is_dir() {
324 let rx_in_dir = path.join("rx");
325 if rx_in_dir.exists() {
326 return Ok(rx_in_dir);
327 }
328 }
329 }
330
331 Err(Error::Io(io::Error::new(
332 io::ErrorKind::NotFound,
333 "rx binary not found in archive",
334 )))
335 }
336
337 fn extract_zip(&self, archive_path: &PathBuf, temp_dir: &PathBuf) -> Result<PathBuf> {
339 let file = File::open(archive_path)?;
340 let mut archive = zip::ZipArchive::new(file)
341 .map_err(|e| Error::Io(io::Error::new(io::ErrorKind::InvalidData, e.to_string())))?;
342
343 archive
344 .extract(temp_dir)
345 .map_err(|e| Error::Io(io::Error::new(io::ErrorKind::InvalidData, e.to_string())))?;
346
347 let rx_path = temp_dir.join("rx.exe");
349 if rx_path.exists() {
350 return Ok(rx_path);
351 }
352
353 for entry in fs::read_dir(temp_dir)? {
355 let entry = entry?;
356 let path = entry.path();
357 if path.is_dir() {
358 let rx_in_dir = path.join("rx.exe");
359 if rx_in_dir.exists() {
360 return Ok(rx_in_dir);
361 }
362 }
363 }
364
365 Err(Error::Io(io::Error::new(
366 io::ErrorKind::NotFound,
367 "rx.exe binary not found in archive",
368 )))
369 }
370
371 fn replace_executable(&self, current: &PathBuf, new: &PathBuf) -> Result<()> {
373 #[cfg(unix)]
375 {
376 use std::os::unix::fs::PermissionsExt;
377 let mut perms = fs::metadata(new)?.permissions();
378 perms.set_mode(0o755);
379 fs::set_permissions(new, perms)?;
380 }
381
382 #[cfg(windows)]
385 {
386 let backup = current.with_extension("exe.old");
387 if backup.exists() {
388 fs::remove_file(&backup)?;
389 }
390 fs::rename(current, &backup)?;
391 fs::copy(new, current)?;
392 let _ = fs::remove_file(&backup);
394 }
395
396 #[cfg(not(windows))]
397 {
398 fs::copy(new, current)?;
400 }
401
402 Ok(())
403 }
404
405 pub fn is_newer(current: &str, latest: &str) -> bool {
407 let parse = |v: &str| -> (u32, u32, u32) {
409 let parts: Vec<&str> = v.split('.').collect();
410 let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
411 let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
412 let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
413 (major, minor, patch)
414 };
415
416 parse(latest) > parse(current)
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn test_is_newer() {
426 assert!(SelfUpdater::is_newer("0.1.11", "0.1.12"));
427 assert!(SelfUpdater::is_newer("0.1.12", "0.2.0"));
428 assert!(SelfUpdater::is_newer("0.1.12", "1.0.0"));
429 assert!(!SelfUpdater::is_newer("0.1.12", "0.1.12"));
430 assert!(!SelfUpdater::is_newer("0.1.12", "0.1.11"));
431 }
432
433 #[test]
434 fn test_asset_name() {
435 use std::path::PathBuf;
436
437 let updater = SelfUpdater {
438 platform: Platform::new(Os::Linux, Arch::X86_64),
439 current_version: "0.1.0".to_string(),
440 install_method: InstallMethod::Binary,
441 exe_path: PathBuf::from("/usr/local/bin/rx"),
442 };
443 assert_eq!(updater.asset_name(), "rx-x86_64-unknown-linux-gnu.tar.gz");
444
445 let updater = SelfUpdater {
446 platform: Platform::new(Os::MacOS, Arch::Aarch64),
447 current_version: "0.1.0".to_string(),
448 install_method: InstallMethod::Binary,
449 exe_path: PathBuf::from("/usr/local/bin/rx"),
450 };
451 assert_eq!(updater.asset_name(), "rx-aarch64-apple-darwin.tar.gz");
452
453 let updater = SelfUpdater {
454 platform: Platform::new(Os::Windows, Arch::X86_64),
455 current_version: "0.1.0".to_string(),
456 install_method: InstallMethod::Binary,
457 exe_path: PathBuf::from("C:\\Program Files\\rx\\rx.exe"),
458 };
459 assert_eq!(updater.asset_name(), "rx-x86_64-pc-windows-msvc.zip");
460 }
461
462 #[test]
463 fn test_install_method_detection() {
464 use std::path::PathBuf;
465
466 assert_eq!(
468 SelfUpdater::detect_install_method(&PathBuf::from("/home/user/.cargo/bin/rx")),
469 InstallMethod::Cargo
470 );
471
472 assert_eq!(
474 SelfUpdater::detect_install_method(&PathBuf::from("/project/target/release/rx")),
475 InstallMethod::Cargo
476 );
477
478 assert_eq!(
480 SelfUpdater::detect_install_method(&PathBuf::from("/usr/local/bin/rx")),
481 InstallMethod::Binary
482 );
483 }
484}