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::PathBuf;
12use std::process::Command;
13
14use crate::python::{Arch, Os, Platform};
15use crate::{Error, Result};
16
17/// GitHub repository for releases
18const GITHUB_REPO: &str = "pro-rx/rx";
19
20/// How rx was installed
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum InstallMethod {
23    /// Installed via pip (rx-pro package)
24    Pip,
25    /// Installed via cargo (pro-cli crate)
26    Cargo,
27    /// Installed as standalone binary
28    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/// Release information from GitHub
42#[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
50/// Self-updater for the rx CLI
51pub struct SelfUpdater {
52    platform: Platform,
53    current_version: String,
54    install_method: InstallMethod,
55    exe_path: PathBuf,
56}
57
58impl SelfUpdater {
59    /// Create a new self-updater
60    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    /// Detect how rx was installed based on executable path
75    fn detect_install_method(exe_path: &PathBuf) -> InstallMethod {
76        let path_str = exe_path.to_string_lossy().to_lowercase();
77
78        // Check for cargo install location (~/.cargo/bin/)
79        if path_str.contains(".cargo") && path_str.contains("bin") {
80            return InstallMethod::Cargo;
81        }
82
83        // Check for development/source builds (target/release or target/debug)
84        if path_str.contains("target/release") || path_str.contains("target/debug") {
85            return InstallMethod::Cargo;
86        }
87
88        // Check for pip install locations
89        // - site-packages (direct install)
90        // - venv bin directories with Python
91        // - Scripts directory (Windows pip)
92        // - Homebrew or system Python paths
93        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        // Check if pip knows about this package AND we're in a pip-managed location
103        // (not just that the package exists somewhere)
104        if Self::check_pip_owns_binary(exe_path) {
105            return InstallMethod::Pip;
106        }
107
108        // Default to binary install
109        InstallMethod::Binary
110    }
111
112    /// Check if pip installed the binary at this specific path
113    fn check_pip_owns_binary(exe_path: &PathBuf) -> bool {
114        // Get pip show output and check if the location matches
115        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                // 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!("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        // Strip 'v' prefix if present
187        let version = tag_name.strip_prefix('v').unwrap_or(tag_name);
188
189        // Check if we're already on the latest version
190        if version == self.current_version {
191            return Ok(None);
192        }
193
194        // Find the asset for our platform
195        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    /// Update via pip
223    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    /// Update via cargo
236    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    /// Download and install the update (for binary installs)
249    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        // Download the archive
256        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        // Create temp directory for extraction
275        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        // Save archive to temp
282        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        // Extract the archive
288        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        // Replace the current executable
295        self.replace_executable(&self.exe_path, &new_exe_path)?;
296
297        // Cleanup
298        let _ = fs::remove_dir_all(&temp_dir);
299
300        Ok(self.exe_path.clone())
301    }
302
303    /// Extract a tar.gz archive and return the path to the rx binary
304    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        // Find the rx binary in the extracted contents
311        let rx_path = temp_dir.join("rx");
312        if rx_path.exists() {
313            return Ok(rx_path);
314        }
315
316        // Check if it's in a subdirectory
317        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    /// Extract a zip archive and return the path to the rx binary
335    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        // Find the rx.exe binary
345        let rx_path = temp_dir.join("rx.exe");
346        if rx_path.exists() {
347            return Ok(rx_path);
348        }
349
350        // Check if it's in a subdirectory
351        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    /// Replace the current executable with the new one
369    fn replace_executable(&self, current: &PathBuf, new: &PathBuf) -> Result<()> {
370        // Make the new binary executable (Unix only)
371        #[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        // On Windows, we can't replace a running executable directly
380        // We need to rename the current one first
381        #[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            // Try to remove the backup (might fail if still in use)
390            let _ = fs::remove_file(&backup);
391        }
392
393        #[cfg(not(windows))]
394        {
395            // On Unix, we can just copy over the running binary
396            fs::copy(new, current)?;
397        }
398
399        Ok(())
400    }
401
402    /// Compare versions to determine if update is newer
403    pub fn is_newer(current: &str, latest: &str) -> bool {
404        // Simple semver comparison
405        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}