1use crate::{Error, Result};
7use semver::Version;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub enum UpdateChannel {
14 Stable,
16 Beta,
18 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#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct UpdateInfo {
48 pub version: Version,
50 pub channel: UpdateChannel,
52 pub changelog: String,
54 pub download_url: String,
56 pub checksum: String,
58 pub is_security_update: bool,
60}
61
62pub struct UpdateManager {
64 _current_version: Version,
66 channel: UpdateChannel,
68 _client: reqwest::Client,
70}
71
72impl UpdateManager {
73 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 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 pub async fn install_update(&self, update_info: &UpdateInfo) -> Result<()> {
127 tracing::info!("Installing update to version {}", update_info.version);
128
129 let backup_path = self._create_backup().await?;
131 tracing::info!("Created backup at: {}", backup_path.display());
132
133 #[cfg(feature = "update-system")]
134 {
135 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 let current_exe = std::env::current_exe().map_err(|e| {
159 Error::update(format!("Failed to get current executable path: {}", e))
160 })?;
161
162 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 #[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 tokio::fs::rename(&temp_path, ¤t_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 pub async fn update_rules(&self) -> Result<()> {
194 tracing::info!("Updating rules for channel: {}", self.channel);
195
196 Ok(())
203 }
204
205 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 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 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(¤t_exe, &backup_exe).await?;
233
234 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 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 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, ¤t_exe).await?;
261 tracing::info!("Restored binary from backup");
262 }
263
264 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 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 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); }
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 if version <= self._current_version {
329 return Ok(None);
330 }
331
332 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(), is_security_update: release.name.to_lowercase().contains("security"),
349 }))
350 }
351
352 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 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 async fn parse_nightly_build(&self, _response_body: &str) -> Result<Option<UpdateInfo>> {
379 tracing::warn!("Nightly builds not yet supported");
383 Ok(None)
384 }
385
386 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
402pub 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 updater.install_update(&update_info).await?;
419 tracing::warn!("Security update installed automatically");
420 } else {
421 tracing::info!("Update available. Run 'ferrous-forge update' to install.");
423 }
424 }
425
426 Ok(())
427}