Skip to main content

pro_core/
self_update.rs

1//! Self-update functionality for the rx CLI
2//!
3//! Detects installation method and updates appropriately:
4//! - pip: delegates to `pip install --upgrade rx-pro`
5//! - cargo: delegates to `cargo install pro-cli`
6//! - binary: downloads latest from GitHub releases
7
8use 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
19/// GitHub repository for releases
20const GITHUB_REPO: &str = "get-rx/rx-pro";
21
22/// How rx was installed
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum InstallMethod {
25    /// Installed via pip (rx-pro package)
26    Pip,
27    /// Installed via cargo (pro-cli crate)
28    Cargo,
29    /// Installed as standalone binary
30    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/// Release information from GitHub
44#[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
52/// Self-updater for the rx CLI
53pub struct SelfUpdater {
54    platform: Platform,
55    current_version: String,
56    install_method: InstallMethod,
57    exe_path: PathBuf,
58}
59
60impl SelfUpdater {
61    /// Create a new self-updater
62    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    /// Detect how rx was installed based on executable path
77    fn detect_install_method(exe_path: &Path) -> InstallMethod {
78        let path_str = exe_path.to_string_lossy().to_lowercase();
79
80        // Check for cargo install location (~/.cargo/bin/)
81        if path_str.contains(".cargo") && path_str.contains("bin") {
82            return InstallMethod::Cargo;
83        }
84
85        // Check for development/source builds (target/release or target/debug)
86        if path_str.contains("target/release") || path_str.contains("target/debug") {
87            return InstallMethod::Cargo;
88        }
89
90        // Check for pip install locations
91        // - site-packages (direct install)
92        // - venv bin directories with Python
93        // - Scripts directory (Windows pip)
94        // - Homebrew or system Python paths
95        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        // Check if pip knows about this package AND we're in a pip-managed location
105        // (not just that the package exists somewhere)
106        if Self::check_pip_owns_binary(exe_path) {
107            return InstallMethod::Pip;
108        }
109
110        // Default to binary install
111        InstallMethod::Binary
112    }
113
114    /// Check if pip installed the binary at this specific path
115    fn check_pip_owns_binary(exe_path: &Path) -> bool {
116        // Get pip show output and check if the location matches
117        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                // Check if Location is a parent of our exe_path
123                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    /// Get the detected installation method
137    pub fn install_method(&self) -> InstallMethod {
138        self.install_method
139    }
140
141    /// Get the executable path
142    pub fn exe_path(&self) -> &PathBuf {
143        &self.exe_path
144    }
145
146    /// Get the asset name for the current platform
147    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    /// Check for the latest release
156    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        // Strip 'v' prefix if present
190        let version = tag_name.strip_prefix('v').unwrap_or(tag_name);
191
192        // Check if we're already on the latest version
193        if version == self.current_version {
194            return Ok(None);
195        }
196
197        // Find the asset for our platform
198        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    /// Update via pip
226    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    /// Update via cargo
239    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    /// Download and install the update (for binary installs)
252    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        // Download the archive
259        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        // Create temp directory for extraction
278        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        // Save archive to temp
285        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        // Extract the archive
291        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        // Replace the current executable
298        self.replace_executable(&self.exe_path, &new_exe_path)?;
299
300        // Cleanup
301        let _ = fs::remove_dir_all(&temp_dir);
302
303        Ok(self.exe_path.clone())
304    }
305
306    /// Extract a tar.gz archive and return the path to the rx binary
307    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        // Find the rx binary in the extracted contents
314        let rx_path = temp_dir.join("rx");
315        if rx_path.exists() {
316            return Ok(rx_path);
317        }
318
319        // Check if it's in a subdirectory
320        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    /// Extract a zip archive and return the path to the rx binary
338    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        // Find the rx.exe binary
348        let rx_path = temp_dir.join("rx.exe");
349        if rx_path.exists() {
350            return Ok(rx_path);
351        }
352
353        // Check if it's in a subdirectory
354        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    /// Replace the current executable with the new one
372    fn replace_executable(&self, current: &PathBuf, new: &PathBuf) -> Result<()> {
373        // Make the new binary executable (Unix only)
374        #[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        // On Windows, we can't replace a running executable directly
383        // We need to rename the current one first
384        #[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            // Try to remove the backup (might fail if still in use)
393            let _ = fs::remove_file(&backup);
394        }
395
396        #[cfg(not(windows))]
397        {
398            // On Unix, we can just copy over the running binary
399            fs::copy(new, current)?;
400        }
401
402        Ok(())
403    }
404
405    /// Compare versions to determine if update is newer
406    pub fn is_newer(current: &str, latest: &str) -> bool {
407        // Simple semver comparison
408        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        // Cargo install
467        assert_eq!(
468            SelfUpdater::detect_install_method(&PathBuf::from("/home/user/.cargo/bin/rx")),
469            InstallMethod::Cargo
470        );
471
472        // Development build
473        assert_eq!(
474            SelfUpdater::detect_install_method(&PathBuf::from("/project/target/release/rx")),
475            InstallMethod::Cargo
476        );
477
478        // Binary install
479        assert_eq!(
480            SelfUpdater::detect_install_method(&PathBuf::from("/usr/local/bin/rx")),
481            InstallMethod::Binary
482        );
483    }
484}