ferrous_forge/
updater.rs

1//! Self-update system for Ferrous Forge
2//!
3//! This module handles automatic updates of the Ferrous Forge binary and
4//! configuration rules from remote sources.
5
6use crate::{Error, Result};
7use semver::Version;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11/// Update channels available for Ferrous Forge
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub enum UpdateChannel {
14    /// Stable releases (recommended)
15    Stable,
16    /// Beta releases with new features
17    Beta,
18    /// Nightly builds with latest changes
19    Nightly,
20}
21
22impl std::str::FromStr for UpdateChannel {
23    type Err = Error;
24
25    fn from_str(s: &str) -> Result<Self> {
26        match s.to_lowercase().as_str() {
27            "stable" => Ok(Self::Stable),
28            "beta" => Ok(Self::Beta),
29            "nightly" => Ok(Self::Nightly),
30            _ => Err(Error::update("Invalid update channel")),
31        }
32    }
33}
34
35impl std::fmt::Display for UpdateChannel {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Stable => write!(f, "stable"),
39            Self::Beta => write!(f, "beta"),
40            Self::Nightly => write!(f, "nightly"),
41        }
42    }
43}
44
45/// Information about an available update
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct UpdateInfo {
48    /// Version of the update
49    pub version: Version,
50    /// Update channel
51    pub channel: UpdateChannel,
52    /// Release notes/changelog
53    pub changelog: String,
54    /// Download URL for the binary
55    pub download_url: String,
56    /// SHA256 checksum for verification
57    pub checksum: String,
58    /// Whether this is a security update
59    pub is_security_update: bool,
60}
61
62/// Update manager for Ferrous Forge
63pub struct UpdateManager {
64    /// Current version
65    _current_version: Version,
66    /// Update channel to use
67    channel: UpdateChannel,
68    /// HTTP client for downloads
69    _client: reqwest::Client,
70}
71
72impl UpdateManager {
73    /// Create a new update manager
74    pub fn new(channel: UpdateChannel) -> Result<Self> {
75        let current_version = Version::parse(crate::VERSION)
76            .map_err(|e| Error::update(format!("Invalid current version: {}", e)))?;
77
78        let client = reqwest::Client::builder()
79            .user_agent(format!("ferrous-forge/{}", crate::VERSION))
80            .timeout(std::time::Duration::from_secs(30))
81            .build()
82            .map_err(|e| Error::update(format!("Failed to create HTTP client: {}", e)))?;
83
84        Ok(Self {
85            _current_version: current_version,
86            channel,
87            _client: client,
88        })
89    }
90
91    /// Check if an update is available
92    pub async fn check_for_updates(&self) -> Result<Option<UpdateInfo>> {
93        let url = self.get_releases_url();
94
95        tracing::info!("Checking for updates from: {}", url);
96
97        let response = self
98            ._client
99            .get(&url)
100            .header("Accept", "application/vnd.github.v3+json")
101            .header("User-Agent", format!("ferrous-forge/{}", crate::VERSION))
102            .send()
103            .await
104            .map_err(|e| Error::update(format!("Failed to fetch releases: {}", e)))?;
105
106        if !response.status().is_success() {
107            return Err(Error::update(format!(
108                "GitHub API returned status: {}",
109                response.status()
110            )));
111        }
112
113        let body = response
114            .text()
115            .await
116            .map_err(|e| Error::update(format!("Failed to read response body: {}", e)))?;
117
118        match self.channel {
119            UpdateChannel::Stable => self.parse_latest_release(&body).await,
120            UpdateChannel::Beta => self.parse_prerelease(&body).await,
121            UpdateChannel::Nightly => self.parse_nightly_build(&body).await,
122        }
123    }
124
125    /// Download and install an update
126    pub async fn install_update(&self, update_info: &UpdateInfo) -> Result<()> {
127        tracing::info!("Installing update to version {}", update_info.version);
128
129        // Create backup before update
130        let backup_path = self._create_backup().await?;
131        tracing::info!("Created backup at: {}", backup_path.display());
132
133        #[cfg(feature = "update-system")]
134        {
135            // Download the binary from the GitHub release
136            tracing::info!("Downloading update from: {}", update_info.download_url);
137
138            let response = self
139                ._client
140                .get(&update_info.download_url)
141                .send()
142                .await
143                .map_err(|e| Error::update(format!("Failed to download update: {}", e)))?;
144
145            if !response.status().is_success() {
146                return Err(Error::update(format!(
147                    "Download failed with status: {}",
148                    response.status()
149                )));
150            }
151
152            let binary_data = response
153                .bytes()
154                .await
155                .map_err(|e| Error::update(format!("Failed to read binary data: {}", e)))?;
156
157            // Get current executable path
158            let current_exe = std::env::current_exe().map_err(|e| {
159                Error::update(format!("Failed to get current executable path: {}", e))
160            })?;
161
162            // Create a temporary file for the new binary
163            let temp_path = current_exe.with_extension("new");
164            tokio::fs::write(&temp_path, &binary_data)
165                .await
166                .map_err(|e| Error::update(format!("Failed to write new binary: {}", e)))?;
167
168            // Make it executable on Unix
169            #[cfg(unix)]
170            {
171                use std::os::unix::fs::PermissionsExt;
172                let mut perms = tokio::fs::metadata(&temp_path).await?.permissions();
173                perms.set_mode(0o755);
174                tokio::fs::set_permissions(&temp_path, perms).await?;
175            }
176
177            // Replace the current binary
178            tokio::fs::rename(&temp_path, &current_exe)
179                .await
180                .map_err(|e| Error::update(format!("Failed to replace binary: {}", e)))?;
181
182            tracing::info!("Successfully updated to version {}", update_info.version);
183            Ok(())
184        }
185
186        #[cfg(not(feature = "update-system"))]
187        {
188            Err(Error::update("Update system disabled at compile time"))
189        }
190    }
191
192    /// Update only the rules/configuration without updating the binary
193    pub async fn update_rules(&self) -> Result<()> {
194        tracing::info!("Updating rules for channel: {}", self.channel);
195
196        // TODO: Implement rules update
197        // 1. Fetch latest clippy.toml from repository
198        // 2. Fetch latest standards configuration
199        // 3. Update local files
200        // 4. Validate new configuration
201
202        Ok(())
203    }
204
205    /// Get the URL for checking releases based on the channel
206    fn get_releases_url(&self) -> String {
207        let base_url = "https://api.github.com/repos/yourusername/ferrous-forge";
208
209        match self.channel {
210            UpdateChannel::Stable => format!("{}/releases/latest", base_url),
211            UpdateChannel::Beta => format!("{}/releases?prerelease=true", base_url),
212            UpdateChannel::Nightly => format!("{}/actions/artifacts", base_url),
213        }
214    }
215
216    /// Create a backup of the current installation
217    async fn _create_backup(&self) -> Result<PathBuf> {
218        let config_dir = crate::config::Config::config_dir_path()?;
219        let backup_dir = config_dir.join("backups");
220        let backup_path = backup_dir.join(format!(
221            "backup-{}",
222            chrono::Utc::now().format("%Y%m%d-%H%M%S")
223        ));
224
225        tokio::fs::create_dir_all(&backup_path).await?;
226
227        // Backup current binary
228        let current_exe = std::env::current_exe()
229            .map_err(|e| Error::update(format!("Failed to get current executable path: {}", e)))?;
230
231        let backup_exe = backup_path.join("ferrous-forge");
232        tokio::fs::copy(&current_exe, &backup_exe).await?;
233
234        // Backup configuration
235        let config_file = crate::config::Config::config_file_path()?;
236        if config_file.exists() {
237            let backup_config = backup_path.join("config.toml");
238            tokio::fs::copy(&config_file, &backup_config).await?;
239        }
240
241        tracing::info!("Created backup at: {}", backup_path.display());
242        Ok(backup_path)
243    }
244
245    /// Restore from a backup
246    pub async fn restore_backup(&self, backup_path: &std::path::Path) -> Result<()> {
247        if !backup_path.exists() {
248            return Err(Error::update("Backup path does not exist"));
249        }
250
251        let backup_exe = backup_path.join("ferrous-forge");
252        let backup_config = backup_path.join("config.toml");
253
254        // Restore binary
255        if backup_exe.exists() {
256            let current_exe = std::env::current_exe().map_err(|e| {
257                Error::update(format!("Failed to get current executable path: {}", e))
258            })?;
259
260            tokio::fs::copy(&backup_exe, &current_exe).await?;
261            tracing::info!("Restored binary from backup");
262        }
263
264        // Restore configuration
265        if backup_config.exists() {
266            let config_file = crate::config::Config::config_file_path()?;
267            tokio::fs::copy(&backup_config, &config_file).await?;
268            tracing::info!("Restored configuration from backup");
269        }
270
271        Ok(())
272    }
273
274    /// List available backups
275    pub async fn list_backups(&self) -> Result<Vec<PathBuf>> {
276        let config_dir = crate::config::Config::config_dir_path()?;
277        let backup_dir = config_dir.join("backups");
278
279        if !backup_dir.exists() {
280            return Ok(vec![]);
281        }
282
283        let mut backups = Vec::new();
284        let mut entries = tokio::fs::read_dir(&backup_dir).await?;
285
286        while let Some(entry) = entries.next_entry().await? {
287            if entry.path().is_dir() {
288                backups.push(entry.path());
289            }
290        }
291
292        backups.sort();
293        Ok(backups)
294    }
295
296    /// Parse latest stable release from GitHub API response
297    async fn parse_latest_release(&self, response_body: &str) -> Result<Option<UpdateInfo>> {
298        #[derive(Deserialize)]
299        struct GitHubRelease {
300            tag_name: String,
301            name: String,
302            body: String,
303            prerelease: bool,
304            assets: Vec<GitHubAsset>,
305        }
306
307        #[derive(Deserialize)]
308        struct GitHubAsset {
309            name: String,
310            browser_download_url: String,
311        }
312
313        let release: GitHubRelease = serde_json::from_str(response_body)
314            .map_err(|e| Error::update(format!("Failed to parse GitHub release: {}", e)))?;
315
316        if release.prerelease {
317            return Ok(None); // Skip prereleases for stable channel
318        }
319
320        let version_str = release
321            .tag_name
322            .strip_prefix('v')
323            .unwrap_or(&release.tag_name);
324        let version = Version::parse(version_str)
325            .map_err(|e| Error::update(format!("Invalid version format: {}", e)))?;
326
327        // Only suggest update if newer than current version
328        if version <= self._current_version {
329            return Ok(None);
330        }
331
332        // Find the binary asset for current platform
333        let platform_suffix = self.get_platform_suffix();
334        let asset = release
335            .assets
336            .iter()
337            .find(|asset| asset.name.contains(&platform_suffix))
338            .ok_or_else(|| {
339                Error::update(format!("No binary found for platform: {}", platform_suffix))
340            })?;
341
342        Ok(Some(UpdateInfo {
343            version,
344            channel: self.channel.clone(),
345            changelog: release.body,
346            download_url: asset.browser_download_url.clone(),
347            checksum: String::new(), // GitHub doesn't provide checksums in API
348            is_security_update: release.name.to_lowercase().contains("security"),
349        }))
350    }
351
352    /// Parse prerelease from GitHub API response
353    async fn parse_prerelease(&self, response_body: &str) -> Result<Option<UpdateInfo>> {
354        #[derive(Deserialize)]
355        struct GitHubReleases {
356            #[serde(flatten)]
357            _releases: Vec<serde_json::Value>,
358        }
359
360        let releases: Vec<serde_json::Value> = serde_json::from_str(response_body)
361            .map_err(|e| Error::update(format!("Failed to parse GitHub releases: {}", e)))?;
362
363        // Find the latest prerelease
364        for release_value in releases {
365            if let Ok(release) = serde_json::from_value::<serde_json::Value>(release_value) {
366                if release["prerelease"].as_bool().unwrap_or(false) {
367                    return self
368                        .parse_latest_release(&serde_json::to_string(&release)?)
369                        .await;
370                }
371            }
372        }
373
374        Ok(None)
375    }
376
377    /// Parse nightly build from GitHub Actions artifacts
378    async fn parse_nightly_build(&self, _response_body: &str) -> Result<Option<UpdateInfo>> {
379        // Nightly builds would come from GitHub Actions artifacts
380        // This is more complex and would require authentication
381        // For now, return None as nightly builds aren't implemented
382        tracing::warn!("Nightly builds not yet supported");
383        Ok(None)
384    }
385
386    /// Get platform-specific binary suffix
387    fn get_platform_suffix(&self) -> String {
388        let arch = std::env::consts::ARCH;
389        let os = std::env::consts::OS;
390
391        match (os, arch) {
392            ("linux", "x86_64") => "x86_64-unknown-linux-gnu".to_string(),
393            ("linux", "aarch64") => "aarch64-unknown-linux-gnu".to_string(),
394            ("macos", "x86_64") => "x86_64-apple-darwin".to_string(),
395            ("macos", "aarch64") => "aarch64-apple-darwin".to_string(),
396            ("windows", "x86_64") => "x86_64-pc-windows-msvc.exe".to_string(),
397            _ => format!("{}-{}", arch, os),
398        }
399    }
400}
401
402/// Check if automatic updates are enabled and perform update check
403pub async fn check_auto_update() -> Result<()> {
404    let config = crate::config::Config::load_or_default().await?;
405
406    if !config.auto_update {
407        return Ok(());
408    }
409
410    let channel = config.update_channel.parse::<UpdateChannel>()?;
411    let updater = UpdateManager::new(channel)?;
412
413    if let Some(update_info) = updater.check_for_updates().await? {
414        tracing::info!("Update available: {}", update_info.version);
415
416        if update_info.is_security_update {
417            // Auto-install security updates
418            updater.install_update(&update_info).await?;
419            tracing::warn!("Security update installed automatically");
420        } else {
421            // Just notify about regular updates
422            tracing::info!("Update available. Run 'ferrous-forge update' to install.");
423        }
424    }
425
426    Ok(())
427}