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