1use crate::{Result, Error};
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._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 pub async fn install_update(&self, update_info: &UpdateInfo) -> Result<()> {
123 tracing::info!("Installing update to version {}", update_info.version);
124
125 let backup_path = self._create_backup().await?;
127 tracing::info!("Created backup at: {}", backup_path.display());
128
129 #[cfg(feature = "update-system")]
130 {
131 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 let current_exe = std::env::current_exe()
151 .map_err(|e| Error::update(format!("Failed to get current executable path: {}", e)))?;
152
153 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 #[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 tokio::fs::rename(&temp_path, ¤t_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 pub async fn update_rules(&self) -> Result<()> {
185 tracing::info!("Updating rules for channel: {}", self.channel);
186
187 Ok(())
194 }
195
196 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 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 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(¤t_exe, &backup_exe).await?;
221
222 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 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 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, ¤t_exe).await?;
248 tracing::info!("Restored binary from backup");
249 }
250
251 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 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 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); }
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 if version <= self._current_version {
313 return Ok(None);
314 }
315
316 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(), is_security_update: release.name.to_lowercase().contains("security"),
330 }))
331 }
332
333 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 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 async fn parse_nightly_build(&self, _response_body: &str) -> Result<Option<UpdateInfo>> {
358 tracing::warn!("Nightly builds not yet supported");
362 Ok(None)
363 }
364
365 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
381pub 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 updater.install_update(&update_info).await?;
398 tracing::warn!("Security update installed automatically");
399 } else {
400 tracing::info!("Update available. Run 'ferrous-forge update' to install.");
402 }
403 }
404
405 Ok(())
406}