terraphim_update/
lib.rs

1//! Shared auto-update functionality for Terraphim AI binaries
2//!
3//! This crate provides a unified interface for self-updating Terraphim AI CLI tools
4//! using GitHub Releases as a distribution channel.
5
6pub mod config;
7pub mod downloader;
8pub mod notification;
9pub mod platform;
10pub mod rollback;
11pub mod scheduler;
12pub mod signature;
13pub mod state;
14
15use anyhow::{anyhow, Context, Result};
16use base64::Engine;
17use self_update::cargo_crate_version;
18use self_update::version::bump_is_greater;
19use std::fmt;
20use std::fs;
21use std::path::{Path, PathBuf};
22use tempfile::NamedTempFile;
23use tracing::{error, info};
24
25/// Represents the status of an update operation
26#[derive(Debug, Clone)]
27pub enum UpdateStatus {
28    /// No update available - already running latest version
29    UpToDate(String),
30    /// Update available and successfully installed
31    Updated {
32        from_version: String,
33        to_version: String,
34    },
35    /// Update available but not installed
36    Available {
37        current_version: String,
38        latest_version: String,
39    },
40    /// Update failed with error
41    Failed(String),
42}
43
44/// Compare two version strings to determine if the first is newer than the second
45/// Static version that can be called from blocking contexts
46/// Uses semver crate for proper semantic versioning comparison
47fn is_newer_version_static(version1: &str, version2: &str) -> Result<bool, anyhow::Error> {
48    use semver::Version;
49
50    let v1 = Version::parse(version1.trim_start_matches('v'))
51        .map_err(|e| anyhow::anyhow!("Invalid version '{}': {}", version1, e))?;
52
53    let v2 = Version::parse(version2.trim_start_matches('v'))
54        .map_err(|e| anyhow::anyhow!("Invalid version '{}': {}", version2, e))?;
55
56    Ok(v1 > v2)
57}
58
59impl fmt::Display for UpdateStatus {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            UpdateStatus::UpToDate(version) => {
63                write!(f, "[OK] Already running latest version: {}", version)
64            }
65            UpdateStatus::Updated {
66                from_version,
67                to_version,
68            } => {
69                write!(f, "Updated: from {} to {}", from_version, to_version)
70            }
71            UpdateStatus::Available {
72                current_version,
73                latest_version,
74            } => {
75                write!(
76                    f,
77                    "Update available: {} → {}",
78                    current_version, latest_version
79                )
80            }
81            UpdateStatus::Failed(error) => {
82                write!(f, "[ERROR] Update failed: {}", error)
83            }
84        }
85    }
86}
87
88/// Configuration for the updater
89#[derive(Debug, Clone)]
90pub struct UpdaterConfig {
91    /// Name of the binary (e.g., "terraphim_server")
92    pub bin_name: String,
93    /// GitHub repository owner (e.g., "terraphim")
94    pub repo_owner: String,
95    /// GitHub repository name (e.g., "terraphim-ai")
96    pub repo_name: String,
97    /// Current version of the binary
98    pub current_version: String,
99    /// Whether to show download progress
100    pub show_progress: bool,
101}
102
103impl UpdaterConfig {
104    /// Create a new updater config for Terraphim AI binaries
105    pub fn new(bin_name: impl Into<String>) -> Self {
106        Self {
107            bin_name: bin_name.into(),
108            repo_owner: "terraphim".to_string(),
109            repo_name: "terraphim-ai".to_string(),
110            current_version: cargo_crate_version!().to_string(),
111            show_progress: true,
112        }
113    }
114
115    /// Set a custom current version (useful for testing)
116    pub fn with_version(mut self, version: impl Into<String>) -> Self {
117        self.current_version = version.into();
118        self
119    }
120
121    /// Enable or disable progress display
122    pub fn with_progress(mut self, show: bool) -> Self {
123        self.show_progress = show;
124        self
125    }
126}
127
128/// Updater client for Terraphim AI binaries
129pub struct TerraphimUpdater {
130    config: UpdaterConfig,
131}
132
133impl TerraphimUpdater {
134    /// Create a new updater instance
135    pub fn new(config: UpdaterConfig) -> Self {
136        Self { config }
137    }
138
139    /// Check if an update is available without installing
140    pub async fn check_update(&self) -> Result<UpdateStatus> {
141        info!(
142            "Checking for updates: {} v{}",
143            self.config.bin_name, self.config.current_version
144        );
145
146        // Clone data for the blocking task
147        let repo_owner = self.config.repo_owner.clone();
148        let repo_name = self.config.repo_name.clone();
149        let bin_name = self.config.bin_name.clone();
150        let current_version = self.config.current_version.clone();
151        let show_progress = self.config.show_progress;
152
153        // Move self_update operations to a blocking task to avoid runtime conflicts
154        let result = tokio::task::spawn_blocking(move || {
155            // Check if update is available
156            match self_update::backends::github::Update::configure()
157                .repo_owner(&repo_owner)
158                .repo_name(&repo_name)
159                .bin_name(&bin_name)
160                .current_version(&current_version)
161                .show_download_progress(show_progress)
162                .build()
163            {
164                Ok(updater) => {
165                    // This will check without updating
166                    match updater.get_latest_release() {
167                        Ok(release) => {
168                            let latest_version = release.version.clone();
169
170                            // Compare versions using semver
171                            match is_newer_version_static(&latest_version, &current_version) {
172                                Ok(true) => {
173                                    Ok::<UpdateStatus, anyhow::Error>(UpdateStatus::Available {
174                                        current_version,
175                                        latest_version,
176                                    })
177                                }
178                                Ok(false) => Ok::<UpdateStatus, anyhow::Error>(
179                                    UpdateStatus::UpToDate(current_version),
180                                ),
181                                Err(e) => Err(e),
182                            }
183                        }
184                        Err(e) => Ok(UpdateStatus::Failed(format!("Check failed: {}", e))),
185                    }
186                }
187                Err(e) => Ok(UpdateStatus::Failed(format!("Configuration error: {}", e))),
188            }
189        })
190        .await;
191
192        match result {
193            Ok(update_result) => {
194                match update_result {
195                    Ok(status) => {
196                        // Log the result for debugging
197                        match &status {
198                            UpdateStatus::Available {
199                                current_version,
200                                latest_version,
201                            } => {
202                                info!(
203                                    "Update available: {} -> {}",
204                                    current_version, latest_version
205                                );
206                            }
207                            UpdateStatus::UpToDate(version) => {
208                                info!("Already up to date: {}", version);
209                            }
210                            UpdateStatus::Updated {
211                                from_version,
212                                to_version,
213                            } => {
214                                info!(
215                                    "Successfully updated from {} to {}",
216                                    from_version, to_version
217                                );
218                            }
219                            UpdateStatus::Failed(error) => {
220                                error!("Update check failed: {}", error);
221                            }
222                        }
223                        Ok(status)
224                    }
225                    Err(e) => {
226                        error!("Blocking task failed: {}", e);
227                        Ok(UpdateStatus::Failed(format!("Blocking task error: {}", e)))
228                    }
229                }
230            }
231            Err(e) => {
232                error!("Failed to spawn blocking task: {}", e);
233                Ok(UpdateStatus::Failed(format!("Task spawn error: {}", e)))
234            }
235        }
236    }
237
238    /// Update the binary to the latest version
239    pub async fn update(&self) -> Result<UpdateStatus> {
240        info!(
241            "Updating {} from version {}",
242            self.config.bin_name, self.config.current_version
243        );
244
245        // Clone data for the blocking task
246        let repo_owner = self.config.repo_owner.clone();
247        let repo_name = self.config.repo_name.clone();
248        let bin_name = self.config.bin_name.clone();
249        let current_version = self.config.current_version.clone();
250        let show_progress = self.config.show_progress;
251
252        // Decode the embedded public key for signature verification
253        let key_bytes = base64::engine::general_purpose::STANDARD
254            .decode(signature::get_embedded_public_key())
255            .context("Failed to decode public key")?;
256
257        // Convert to array (must be exactly 32 bytes for Ed25519)
258        if key_bytes.len() != 32 {
259            return Err(anyhow!(
260                "Invalid public key length: {} bytes (expected 32)",
261                key_bytes.len()
262            ));
263        }
264        let mut key_array = [0u8; 32];
265        key_array.copy_from_slice(&key_bytes);
266
267        // Move self_update operations to a blocking task to avoid runtime conflicts
268        let result = tokio::task::spawn_blocking(move || {
269            // Build the updater with signature verification enabled
270            let builder_result = self_update::backends::github::Update::configure()
271                .repo_owner(&repo_owner)
272                .repo_name(&repo_name)
273                .bin_name(&bin_name)
274                .current_version(&current_version)
275                .show_download_progress(show_progress)
276                .verifying_keys(vec![key_array]) // Enable signature verification
277                .build();
278
279            match builder_result {
280                Ok(updater) => match updater.update() {
281                    Ok(status) => match status {
282                        self_update::Status::UpToDate(version) => {
283                            Ok::<UpdateStatus, anyhow::Error>(UpdateStatus::UpToDate(version))
284                        }
285                        self_update::Status::Updated(version) => {
286                            Ok::<UpdateStatus, anyhow::Error>(UpdateStatus::Updated {
287                                from_version: current_version,
288                                to_version: version,
289                            })
290                        }
291                    },
292                    Err(e) => Ok(UpdateStatus::Failed(format!("Update failed: {}", e))),
293                },
294                Err(e) => Ok(UpdateStatus::Failed(format!("Configuration error: {}", e))),
295            }
296        })
297        .await;
298
299        match result {
300            Ok(update_result) => {
301                match update_result {
302                    Ok(status) => {
303                        // Log the result for debugging
304                        match &status {
305                            UpdateStatus::Updated {
306                                from_version,
307                                to_version,
308                            } => {
309                                info!(
310                                    "Successfully updated from {} to {}",
311                                    from_version, to_version
312                                );
313                            }
314                            UpdateStatus::UpToDate(version) => {
315                                info!("Already up to date: {}", version);
316                            }
317                            UpdateStatus::Available {
318                                current_version,
319                                latest_version,
320                            } => {
321                                info!(
322                                    "Update available: {} -> {}",
323                                    current_version, latest_version
324                                );
325                            }
326                            UpdateStatus::Failed(error) => {
327                                error!("Update failed: {}", error);
328                            }
329                        }
330                        Ok(status)
331                    }
332                    Err(e) => {
333                        error!("Blocking task failed: {}", e);
334                        Ok(UpdateStatus::Failed(format!("Blocking task error: {}", e)))
335                    }
336                }
337            }
338            Err(e) => {
339                error!("Failed to spawn blocking task: {}", e);
340                Ok(UpdateStatus::Failed(format!("Task spawn error: {}", e)))
341            }
342        }
343    }
344
345    /// Update the binary with signature verification
346    ///
347    /// This method implements a manual download, verify, and install flow
348    /// to ensure that only signed and verified binaries are installed.
349    ///
350    /// # Returns
351    /// * `Ok(UpdateStatus)` - Status of the update operation
352    /// * `Err(anyhow::Error)` - Error if update fails
353    ///
354    /// # Process
355    /// 1. Get latest release info from GitHub
356    /// 2. Download the release archive to a temp location
357    /// 3. Verify the Ed25519 signature using zipsign-api
358    /// 4. Install if valid, reject if invalid/missing signature
359    ///
360    /// # Security
361    /// - Rejects updates with invalid signatures
362    /// - Rejects updates with missing signatures
363    /// - Only installs verified binaries
364    pub async fn update_with_verification(&self) -> Result<UpdateStatus> {
365        info!(
366            "Updating {} from version {} with signature verification",
367            self.config.bin_name, self.config.current_version
368        );
369
370        // Clone data for the blocking task
371        let repo_owner = self.config.repo_owner.clone();
372        let repo_name = self.config.repo_name.clone();
373        let bin_name = self.config.bin_name.clone();
374        let current_version = self.config.current_version.clone();
375        let show_progress = self.config.show_progress;
376
377        // Move self_update operations to a blocking task
378        let result = tokio::task::spawn_blocking(move || {
379            Self::update_with_verification_blocking(
380                &repo_owner,
381                &repo_name,
382                &bin_name,
383                &current_version,
384                show_progress,
385            )
386        })
387        .await;
388
389        match result {
390            Ok(Ok(update_status)) => {
391                match &update_status {
392                    UpdateStatus::Updated {
393                        from_version,
394                        to_version,
395                    } => {
396                        info!(
397                            "Successfully updated from {} to {} with verified signature",
398                            from_version, to_version
399                        );
400                    }
401                    UpdateStatus::UpToDate(version) => {
402                        info!("Already up to date: {}", version);
403                    }
404                    UpdateStatus::Failed(error) => {
405                        error!("Update with verification failed: {}", error);
406                    }
407                    _ => {}
408                }
409                Ok(update_status)
410            }
411            Ok(Err(e)) => {
412                error!("Blocking task returned error: {}", e);
413                Ok(UpdateStatus::Failed(format!("Update error: {}", e)))
414            }
415            Err(e) => {
416                error!("Blocking task failed: {}", e);
417                Ok(UpdateStatus::Failed(format!("Task spawn error: {}", e)))
418            }
419        }
420    }
421
422    /// Blocking version of update_with_verification for use in spawn_blocking
423    fn update_with_verification_blocking(
424        repo_owner: &str,
425        repo_name: &str,
426        bin_name: &str,
427        current_version: &str,
428        show_progress: bool,
429    ) -> Result<UpdateStatus> {
430        info!(
431            "Starting verified update flow for {} v{}",
432            bin_name, current_version
433        );
434
435        // Step 1: Get latest release info from GitHub
436        let release =
437            match Self::get_latest_release_info(repo_owner, repo_name, bin_name, current_version) {
438                Ok(release) => release,
439                Err(e) => {
440                    return Ok(UpdateStatus::Failed(format!(
441                        "Failed to get release info: {}",
442                        e
443                    )));
444                }
445            };
446
447        let latest_version = &release.version;
448
449        // Step 2: Download archive to temp location
450        let temp_archive = match Self::download_release_archive(
451            repo_owner,
452            repo_name,
453            bin_name,
454            latest_version,
455            show_progress,
456        ) {
457            Ok(archive) => archive,
458            Err(e) => {
459                return Ok(UpdateStatus::Failed(format!(
460                    "Failed to download archive: {}",
461                    e
462                )));
463            }
464        };
465
466        let archive_path = temp_archive.path().to_path_buf();
467
468        // Step 3: Verify signature BEFORE installation
469        info!("Verifying signature for archive {:?}", archive_path);
470        let verification_result =
471            match crate::signature::verify_archive_signature(&archive_path, None) {
472                Ok(result) => result,
473                Err(e) => return Ok(UpdateStatus::Failed(format!("Verification error: {}", e))),
474            };
475
476        match verification_result {
477            crate::signature::VerificationResult::Valid => {
478                info!("Signature verification passed - proceeding with installation");
479            }
480            crate::signature::VerificationResult::Invalid { reason } => {
481                let error_msg = format!("Signature verification failed: {}", reason);
482                error!("{}", error_msg);
483                return Ok(UpdateStatus::Failed(error_msg));
484            }
485            crate::signature::VerificationResult::MissingSignature => {
486                let error_msg = "No signature found in archive - refusing to install".to_string();
487                error!("{}", error_msg);
488                return Ok(UpdateStatus::Failed(error_msg));
489            }
490            crate::signature::VerificationResult::Error(msg) => {
491                let error_msg = format!("Verification error: {}", msg);
492                error!("{}", error_msg);
493                return Ok(UpdateStatus::Failed(error_msg));
494            }
495        }
496
497        // Step 4: Install the verified archive
498        match Self::install_verified_archive(&archive_path, bin_name) {
499            Ok(_) => {
500                info!("Successfully installed verified update");
501                Ok(UpdateStatus::Updated {
502                    from_version: current_version.to_string(),
503                    to_version: latest_version.clone(),
504                })
505            }
506            Err(e) => Ok(UpdateStatus::Failed(format!("Installation failed: {}", e))),
507        }
508    }
509
510    /// Get latest release info from GitHub
511    fn get_latest_release_info(
512        repo_owner: &str,
513        repo_name: &str,
514        bin_name: &str,
515        current_version: &str,
516    ) -> Result<self_update::update::Release> {
517        info!(
518            "Fetching latest release info for {}/{}",
519            repo_owner, repo_name
520        );
521
522        let updater = self_update::backends::github::Update::configure()
523            .repo_owner(repo_owner)
524            .repo_name(repo_name)
525            .bin_name(bin_name)
526            .current_version(current_version)
527            .build()?;
528
529        let release = updater.get_latest_release()?;
530
531        // Check if the latest version is actually newer
532        #[allow(clippy::needless_borrow)]
533        if !bump_is_greater(&current_version, &release.version)? {
534            return Err(anyhow!(
535                "Current version {} is up to date with {}",
536                current_version,
537                release.version
538            ));
539        }
540
541        info!("Latest version: {}", release.version);
542        Ok(release)
543    }
544
545    /// Download release archive to a temporary file
546    fn download_release_archive(
547        repo_owner: &str,
548        repo_name: &str,
549        bin_name: &str,
550        version: &str,
551        show_progress: bool,
552    ) -> Result<NamedTempFile> {
553        // Determine current platform
554        let target = Self::get_target_triple()?;
555        let extension = if cfg!(windows) { "zip" } else { "tar.gz" };
556
557        // Construct download URL
558        let archive_name = format!("{}-{}-{}.{}", bin_name, version, target, extension);
559        let download_url = format!(
560            "https://github.com/{}/{}/releases/download/{}/{}",
561            repo_owner, repo_name, version, archive_name
562        );
563
564        info!("Downloading from: {}", download_url);
565
566        // Create temp file for download
567        let temp_file = NamedTempFile::new()?;
568        let download_config = crate::downloader::DownloadConfig {
569            show_progress,
570            ..Default::default()
571        };
572
573        crate::downloader::download_with_retry(
574            &download_url,
575            temp_file.path(),
576            Some(download_config),
577        )?;
578
579        info!("Downloaded archive to: {:?}", temp_file.path());
580        Ok(temp_file)
581    }
582
583    /// Get the target triple for the current platform
584    fn get_target_triple() -> Result<String> {
585        use std::env::consts::{ARCH, OS};
586
587        let target = format!("{}-{}", ARCH, OS);
588
589        // Map Rust targets to common release naming conventions
590        let target = match target.as_str() {
591            "x86_64-linux" => "x86_64-unknown-linux-gnu".to_string(),
592            "aarch64-linux" => "aarch64-unknown-linux-gnu".to_string(),
593            "x86_64-windows" => "x86_64-pc-windows-msvc".to_string(),
594            "x86_64-macos" => "x86_64-apple-darwin".to_string(),
595            "aarch64-macos" => "aarch64-apple-darwin".to_string(),
596            _ => target,
597        };
598
599        Ok(target)
600    }
601
602    /// Install a verified archive to the current binary location
603    fn install_verified_archive(archive_path: &Path, bin_name: &str) -> Result<()> {
604        info!("Installing verified archive {:?}", archive_path);
605
606        // Get current executable path
607        let current_exe = std::env::current_exe()?;
608        let install_dir = current_exe
609            .parent()
610            .ok_or_else(|| anyhow!("Cannot determine install directory"))?;
611
612        info!("Installing to directory: {:?}", install_dir);
613
614        // Extract and install using self_update's extract functionality
615        // Note: This is a simplified version - we may need platform-specific extraction
616        if cfg!(windows) {
617            // Windows: extract ZIP
618            Self::extract_zip(archive_path, install_dir)?;
619        } else {
620            // Unix: extract tar.gz
621            Self::extract_tarball(archive_path, install_dir, bin_name)?;
622        }
623
624        // Make executable on Unix
625        #[cfg(unix)]
626        {
627            use std::os::unix::fs::PermissionsExt;
628            let bin_path = install_dir.join(bin_name);
629            if bin_path.exists() {
630                let mut perms = fs::metadata(&bin_path)?.permissions();
631                perms.set_mode(0o755);
632                fs::set_permissions(&bin_path, perms)?;
633            }
634        }
635
636        Ok(())
637    }
638
639    /// Extract a ZIP archive to the target directory
640    fn extract_zip(archive_path: &Path, target_dir: &Path) -> Result<()> {
641        use zip::ZipArchive;
642
643        let file = fs::File::open(archive_path)?;
644        let mut archive = ZipArchive::new(file)?;
645
646        for i in 0..archive.len() {
647            let mut file = archive.by_index(i)?;
648            #[allow(clippy::needless_borrows_for_generic_args)]
649            let outpath = target_dir.join(file.mangled_name());
650
651            if file.name().ends_with('/') {
652                fs::create_dir_all(&outpath)?;
653            } else {
654                if let Some(parent) = outpath.parent() {
655                    fs::create_dir_all(parent)?;
656                }
657                let mut outfile = fs::File::create(&outpath)?;
658                std::io::copy(&mut file, &mut outfile)?;
659            }
660        }
661
662        Ok(())
663    }
664
665    /// Extract a tar.gz archive to the target directory
666    fn extract_tarball(archive_path: &Path, target_dir: &Path, bin_name: &str) -> Result<()> {
667        use flate2::read::GzDecoder;
668        use tar::Archive;
669
670        let file = fs::File::open(archive_path)?;
671        let decoder = GzDecoder::new(file);
672        let mut archive = Archive::new(decoder);
673
674        // Extract the binary from the archive
675        // The binary should be at the root of the archive
676        for entry in archive.entries()? {
677            let mut entry = entry?;
678            let path = entry.path()?;
679
680            // Only extract the main binary, not directory structure
681            if let Some(file_name) = path.file_name() {
682                if file_name.to_str() == Some(bin_name) {
683                    let outpath = target_dir.join(bin_name);
684                    let mut outfile = fs::File::create(&outpath)?;
685                    std::io::copy(&mut entry, &mut outfile)?;
686                    info!("Extracted binary to {:?}", outpath);
687                    break;
688                }
689            }
690        }
691
692        Ok(())
693    }
694
695    /// Check for update and install if available with signature verification
696    pub async fn check_and_update(&self) -> Result<UpdateStatus> {
697        match self.check_update().await? {
698            UpdateStatus::Available {
699                current_version,
700                latest_version,
701            } => {
702                info!(
703                    "Update available: {} → {}, installing...",
704                    current_version, latest_version
705                );
706                self.update_with_verification().await
707            }
708            status => Ok(status),
709        }
710    }
711
712    /// Compare two version strings to determine if the first is newer than the second
713    #[allow(dead_code)]
714    fn is_newer_version(&self, version1: &str, version2: &str) -> Result<bool> {
715        // Simple version comparison - in production you might want to use semver crate
716        let v1_parts: Vec<u32> = version1
717            .trim_start_matches('v')
718            .split('.')
719            .take(3)
720            .map(|s| s.parse().unwrap_or(0))
721            .collect();
722
723        let v2_parts: Vec<u32> = version2
724            .trim_start_matches('v')
725            .split('.')
726            .take(3)
727            .map(|s| s.parse().unwrap_or(0))
728            .collect();
729
730        // Pad with zeros if needed
731        let v1 = [
732            v1_parts.first().copied().unwrap_or(0),
733            v1_parts.get(1).copied().unwrap_or(0),
734            v1_parts.get(2).copied().unwrap_or(0),
735        ];
736
737        let v2 = [
738            v2_parts.first().copied().unwrap_or(0),
739            v2_parts.get(1).copied().unwrap_or(0),
740            v2_parts.get(2).copied().unwrap_or(0),
741        ];
742
743        Ok(v1 > v2)
744    }
745}
746
747/// Convenience function to create an updater and check for updates
748pub async fn check_for_updates(bin_name: impl Into<String>) -> Result<UpdateStatus> {
749    let config = UpdaterConfig::new(bin_name);
750    let updater = TerraphimUpdater::new(config);
751    updater.check_update().await
752}
753
754/// Convenience function to create an updater and install updates
755pub async fn update_binary(bin_name: impl Into<String>) -> Result<UpdateStatus> {
756    let config = UpdaterConfig::new(bin_name);
757    let updater = TerraphimUpdater::new(config);
758    updater.check_and_update().await
759}
760
761/// Convenience function with progress disabled (useful for automated environments)
762pub async fn update_binary_silent(bin_name: impl Into<String>) -> Result<UpdateStatus> {
763    let config = UpdaterConfig::new(bin_name).with_progress(false);
764    let updater = TerraphimUpdater::new(config);
765    updater.check_and_update().await
766}
767
768/// Check for updates automatically using self_update backend
769///
770/// This is a simplified function that leverages self_update's GitHub backend
771/// to check for available updates without installing them.
772///
773/// # Arguments
774/// * `bin_name` - Name of the binary (e.g., "terraphim")
775/// * `current_version` - Current version of the binary (e.g., "1.0.0")
776///
777/// # Returns
778/// * `Ok(UpdateStatus)` - Status indicating if an update is available
779/// * `Err(anyhow::Error)` - Error if the check fails
780///
781/// # Example
782/// ```no_run
783/// use terraphim_update::check_for_updates_auto;
784///
785/// async {
786///     let status = check_for_updates_auto("terraphim", "1.0.0").await?;
787///     println!("Update status: {}", status);
788///     Ok::<(), anyhow::Error>(())
789/// };
790/// ```
791pub async fn check_for_updates_auto(bin_name: &str, current_version: &str) -> Result<UpdateStatus> {
792    info!("Checking for updates: {} v{}", bin_name, current_version);
793
794    let bin_name = bin_name.to_string();
795    let current_version = current_version.to_string();
796
797    let result =
798        tokio::task::spawn_blocking(
799            move || match self_update::backends::github::Update::configure()
800                .repo_owner("terraphim")
801                .repo_name("terraphim-ai")
802                .bin_name(&bin_name)
803                .current_version(&current_version)
804                .build()
805            {
806                Ok(updater) => match updater.get_latest_release() {
807                    Ok(release) => {
808                        let latest_version = release.version.clone();
809
810                        match is_newer_version_static(&latest_version, &current_version) {
811                            Ok(true) => {
812                                Ok::<UpdateStatus, anyhow::Error>(UpdateStatus::Available {
813                                    current_version,
814                                    latest_version,
815                                })
816                            }
817                            Ok(false) => Ok::<UpdateStatus, anyhow::Error>(UpdateStatus::UpToDate(
818                                current_version,
819                            )),
820                            Err(e) => Err(e),
821                        }
822                    }
823                    Err(e) => Ok(UpdateStatus::Failed(format!("Check failed: {}", e))),
824                },
825                Err(e) => Ok(UpdateStatus::Failed(format!("Configuration error: {}", e))),
826            },
827        )
828        .await;
829
830    match result {
831        Ok(update_result) => update_result,
832        Err(e) => {
833            error!("Failed to spawn blocking task: {}", e);
834            Ok(UpdateStatus::Failed(format!("Task spawn error: {}", e)))
835        }
836    }
837}
838
839/// Check for updates on application startup
840///
841/// This function performs a non-blocking update check on startup
842/// and logs a warning if the check fails (doesn't interrupt startup).
843///
844/// # Arguments
845/// * `bin_name` - Name of the binary (e.g., "terraphim-agent")
846///
847/// # Returns
848/// * `Ok(UpdateStatus)` - Status of update check
849/// * `Err(anyhow::Error)` - Error if check fails
850///
851/// # Example
852/// ```no_run
853/// use terraphim_update::check_for_updates_startup;
854///
855/// async {
856///     if let Err(e) = check_for_updates_startup("terraphim-agent").await {
857///         eprintln!("Update check failed: {}", e);
858///     }
859///     Ok::<(), anyhow::Error>(())
860/// };
861/// ```
862pub async fn check_for_updates_startup(bin_name: &str) -> Result<UpdateStatus> {
863    let current_version = env!("CARGO_PKG_VERSION");
864    check_for_updates_auto(bin_name, current_version).await
865}
866
867/// Start the update scheduler
868///
869/// This function starts a background task that periodically checks for updates
870/// and sends notifications through a callback when updates are available.
871///
872/// # Arguments
873/// * `bin_name` - Name of the binary (e.g., "terraphim-agent")
874/// * `current_version` - Current version of the binary
875/// * `callback` - Function to call when an update is available
876///
877/// # Returns
878/// * `Ok(JoinHandle<()>)` - Handle to the scheduler task (can be used to abort)
879/// * `Err(anyhow::Error)` - Error if scheduler fails to start
880///
881/// # Example
882/// ```no_run
883/// use terraphim_update::start_update_scheduler;
884///
885/// async {
886///     let handle = start_update_scheduler(
887///         "terraphim-agent",
888///         "1.0.0",
889///         Box::new(|update_info| {
890///             println!("Update available: {}", update_info.latest_version);
891///         })
892///     ).await?;
893///     # Ok::<(), anyhow::Error>(())
894/// };
895/// ```
896pub async fn start_update_scheduler(
897    bin_name: &str,
898    current_version: &str,
899    callback: Box<dyn Fn(UpdateAvailableInfo) + Send + Sync>,
900) -> Result<tokio::task::JoinHandle<()>> {
901    use crate::config::UpdateConfig;
902    use crate::scheduler::{UpdateCheckResult, UpdateScheduler};
903    use std::sync::Arc;
904
905    let config = UpdateConfig::default();
906
907    let bin_name_clone = bin_name.to_string();
908    let current_version_clone = current_version.to_string();
909
910    let check_fn = Arc::new(move || -> anyhow::Result<UpdateCheckResult> {
911        let status = {
912            let bin_name = bin_name_clone.clone();
913            let current_version = current_version_clone.clone();
914
915            tokio::task::block_in_place(|| {
916                let rt = tokio::runtime::Runtime::new()?;
917                rt.block_on(async { check_for_updates_auto(&bin_name, &current_version).await })
918            })
919        }?;
920
921        match status {
922            UpdateStatus::Available {
923                current_version,
924                latest_version,
925            } => Ok(UpdateCheckResult::UpdateAvailable {
926                current_version,
927                latest_version,
928            }),
929            UpdateStatus::UpToDate(_) => Ok(UpdateCheckResult::UpToDate),
930            UpdateStatus::Failed(error) => Ok(UpdateCheckResult::Failed { error }),
931            _ => Ok(UpdateCheckResult::UpToDate),
932        }
933    });
934
935    let mut scheduler = UpdateScheduler::new(Arc::new(config), check_fn);
936    let mut receiver = scheduler.create_notification_channel()?;
937
938    scheduler.start().await?;
939
940    let callback = Arc::new(callback);
941
942    let handle = tokio::spawn(async move {
943        while let Some(notification) = receiver.recv().await {
944            match notification {
945                crate::scheduler::UpdateNotification::UpdateAvailable {
946                    current_version,
947                    latest_version,
948                } => {
949                    callback(UpdateAvailableInfo {
950                        current_version: current_version.clone(),
951                        latest_version: latest_version.clone(),
952                    });
953                }
954                crate::scheduler::UpdateNotification::CheckFailed { error } => {
955                    tracing::warn!("Update check failed: {}", error);
956                }
957                crate::scheduler::UpdateNotification::Stopped => {
958                    break;
959                }
960            }
961        }
962    });
963
964    Ok(handle)
965}
966
967/// Information about an available update (for callback)
968#[derive(Debug, Clone)]
969pub struct UpdateAvailableInfo {
970    pub current_version: String,
971    pub latest_version: String,
972}
973
974/// Backup the current binary with a version suffix
975///
976/// Creates a backup of the binary before updating, allowing rollback
977/// if the update fails.
978///
979/// # Arguments
980/// * `binary_path` - Path to the binary to backup
981/// * `version` - Version string to use in backup filename
982///
983/// # Returns
984/// * `Ok(PathBuf)` - Path to the backup file
985/// * `Err(anyhow::Error)` - Error if backup fails
986///
987/// # Example
988/// ```no_run
989/// use terraphim_update::backup_binary;
990/// use std::path::Path;
991///
992/// let backup = backup_binary(Path::new("/usr/local/bin/terraphim"), "1.0.0")?;
993/// println!("Backup created at: {:?}", backup);
994/// # Ok::<(), anyhow::Error>(())
995/// ```
996pub fn backup_binary(binary_path: &Path, version: &str) -> Result<PathBuf> {
997    info!(
998        "Backing up binary at {:?} with version {}",
999        binary_path, version
1000    );
1001
1002    if !binary_path.exists() {
1003        anyhow::bail!("Binary not found at {:?}", binary_path);
1004    }
1005
1006    let backup_path = binary_path.with_extension(format!("bak-{}", version));
1007
1008    fs::copy(binary_path, &backup_path)?;
1009
1010    info!("Backup created at {:?}", backup_path);
1011    Ok(backup_path)
1012}
1013
1014/// Rollback to a previous version from backup
1015///
1016/// Restores a backed-up binary to the original location.
1017///
1018/// # Arguments
1019/// * `backup_path` - Path to the backup file
1020/// * `target_path` - Path where to restore the binary
1021///
1022/// # Returns
1023/// * `Ok(())` - Success
1024/// * `Err(anyhow::Error)` - Error if rollback fails
1025///
1026/// # Example
1027/// ```no_run
1028/// use terraphim_update::rollback;
1029/// use std::path::Path;
1030///
1031/// rollback(
1032///     Path::new("/usr/local/bin/terraphim.bak-1.0.0"),
1033///     Path::new("/usr/local/bin/terraphim")
1034/// )?;
1035/// # Ok::<(), anyhow::Error>(())
1036/// ```
1037pub fn rollback(backup_path: &Path, target_path: &Path) -> Result<()> {
1038    info!("Rolling back from {:?} to {:?}", backup_path, target_path);
1039
1040    if !backup_path.exists() {
1041        anyhow::bail!("Backup not found at {:?}", backup_path);
1042    }
1043
1044    fs::copy(backup_path, target_path)?;
1045
1046    info!("Rollback completed successfully");
1047    Ok(())
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053    use std::io::Write;
1054    use tempfile::NamedTempFile;
1055
1056    #[test]
1057    fn test_version_comparison() {
1058        let config = UpdaterConfig::new("test");
1059        let updater = TerraphimUpdater::new(config);
1060
1061        // Test basic version comparisons
1062        assert!(updater.is_newer_version("1.1.0", "1.0.0").unwrap());
1063        assert!(updater.is_newer_version("2.0.0", "1.9.9").unwrap());
1064        assert!(updater.is_newer_version("1.0.1", "1.0.0").unwrap());
1065
1066        // Test equal versions
1067        assert!(!updater.is_newer_version("1.0.0", "1.0.0").unwrap());
1068
1069        // Test older versions
1070        assert!(!updater.is_newer_version("1.0.0", "1.1.0").unwrap());
1071        assert!(!updater.is_newer_version("1.9.9", "2.0.0").unwrap());
1072
1073        // Test with v prefix
1074        assert!(updater.is_newer_version("v1.1.0", "v1.0.0").unwrap());
1075        assert!(updater.is_newer_version("1.1.0", "v1.0.0").unwrap());
1076        assert!(updater.is_newer_version("v1.1.0", "1.0.0").unwrap());
1077    }
1078
1079    #[tokio::test]
1080    async fn test_updater_config() {
1081        let config = UpdaterConfig::new("test-binary")
1082            .with_version("1.0.0")
1083            .with_progress(false);
1084
1085        assert_eq!(config.bin_name, "test-binary");
1086        assert_eq!(config.current_version, "1.0.0");
1087        assert!(!config.show_progress);
1088        assert_eq!(config.repo_owner, "terraphim");
1089        assert_eq!(config.repo_name, "terraphim-ai");
1090    }
1091
1092    #[test]
1093    fn test_backup_binary() {
1094        // Create a temporary file to simulate a binary
1095        let mut temp_file = NamedTempFile::new().unwrap();
1096        writeln!(temp_file, "test binary content").unwrap();
1097
1098        let binary_path = temp_file.path();
1099        let version = "1.0.0";
1100
1101        let backup_path = backup_binary(binary_path, version).unwrap();
1102
1103        // Verify backup was created
1104        assert!(backup_path.exists());
1105        assert!(backup_path.to_string_lossy().contains("bak-1.0.0"));
1106
1107        // Verify backup has same content
1108        let original_content = fs::read_to_string(binary_path).unwrap();
1109        let backup_content = fs::read_to_string(&backup_path).unwrap();
1110        assert_eq!(original_content, backup_content);
1111
1112        // Clean up backup
1113        fs::remove_file(&backup_path).unwrap();
1114    }
1115
1116    #[test]
1117    fn test_backup_binary_nonexistent() {
1118        let nonexistent_path = Path::new("/nonexistent/path/to/binary");
1119
1120        let result = backup_binary(nonexistent_path, "1.0.0");
1121        assert!(result.is_err());
1122    }
1123
1124    #[test]
1125    fn test_rollback() {
1126        // Create a temporary file to simulate a backup
1127        let mut backup_file = NamedTempFile::new().unwrap();
1128        writeln!(backup_file, "backup content").unwrap();
1129
1130        let backup_path = backup_file.path();
1131
1132        // Create target path
1133        let mut target_file = NamedTempFile::new().unwrap();
1134        writeln!(target_file, "original content").unwrap();
1135        let target_path = target_file.path();
1136
1137        // Perform rollback
1138        rollback(backup_path, target_path).unwrap();
1139
1140        // Verify target now has backup content
1141        let target_content = fs::read_to_string(target_path).unwrap();
1142        assert_eq!(target_content, "backup content\n");
1143    }
1144
1145    #[test]
1146    fn test_rollback_nonexistent() {
1147        let nonexistent_backup = Path::new("/nonexistent/backup.bak");
1148        let temp_file = NamedTempFile::new().unwrap();
1149        let target_path = temp_file.path();
1150
1151        let result = rollback(nonexistent_backup, target_path);
1152        assert!(result.is_err());
1153    }
1154
1155    #[test]
1156    fn test_backup_and_rollback_roundtrip() {
1157        // Create original binary
1158        let mut original_file = NamedTempFile::new().unwrap();
1159        writeln!(original_file, "original binary v1.0.0").unwrap();
1160        let original_path = original_file.path();
1161
1162        // Create backup
1163        let backup_path = backup_binary(original_path, "1.0.0").unwrap();
1164
1165        // Modify original (simulate update)
1166        fs::write(original_path, "updated binary v1.1.0").unwrap();
1167
1168        // Verify original changed
1169        assert_eq!(
1170            fs::read_to_string(original_path).unwrap(),
1171            "updated binary v1.1.0"
1172        );
1173
1174        // Rollback
1175        rollback(&backup_path, original_path).unwrap();
1176
1177        // Verify original restored
1178        assert_eq!(
1179            fs::read_to_string(original_path).unwrap(),
1180            "original binary v1.0.0\n"
1181        );
1182
1183        // Clean up backup
1184        fs::remove_file(&backup_path).unwrap();
1185    }
1186
1187    #[tokio::test]
1188    async fn test_check_for_updates_auto() {
1189        // This test will make actual API calls to GitHub
1190        // It's useful for manual testing but may be flaky in CI
1191        let status = check_for_updates_auto("terraphim", "0.0.1").await;
1192
1193        match status {
1194            Ok(UpdateStatus::Available {
1195                current_version,
1196                latest_version,
1197            }) => {
1198                assert_eq!(current_version, "0.0.1");
1199                assert_ne!(current_version, latest_version);
1200            }
1201            Ok(UpdateStatus::UpToDate(version)) => {
1202                assert_eq!(version, "0.0.1");
1203            }
1204            Ok(UpdateStatus::Failed(_)) => {
1205                // This is acceptable if GitHub API is unavailable
1206            }
1207            _ => {}
1208        }
1209    }
1210
1211    #[test]
1212    fn test_is_newer_version_static() {
1213        // Test basic comparisons
1214        assert!(is_newer_version_static("2.0.0", "1.0.0").unwrap());
1215        assert!(is_newer_version_static("1.1.0", "1.0.0").unwrap());
1216        assert!(is_newer_version_static("1.0.1", "1.0.0").unwrap());
1217
1218        // Test equal versions
1219        assert!(!is_newer_version_static("1.0.0", "1.0.0").unwrap());
1220
1221        // Test older versions
1222        assert!(!is_newer_version_static("1.0.0", "2.0.0").unwrap());
1223        assert!(!is_newer_version_static("1.0.0", "1.1.0").unwrap());
1224
1225        // Test with v prefix
1226        assert!(is_newer_version_static("v2.0.0", "v1.0.0").unwrap());
1227        assert!(!is_newer_version_static("v1.0.0", "v2.0.0").unwrap());
1228    }
1229
1230    #[test]
1231    fn test_update_status_display() {
1232        let up_to_date = UpdateStatus::UpToDate("1.0.0".to_string());
1233        assert!(up_to_date.to_string().contains("1.0.0"));
1234
1235        let updated = UpdateStatus::Updated {
1236            from_version: "1.0.0".to_string(),
1237            to_version: "2.0.0".to_string(),
1238        };
1239        assert!(updated.to_string().contains("1.0.0"));
1240        assert!(updated.to_string().contains("2.0.0"));
1241
1242        let available = UpdateStatus::Available {
1243            current_version: "1.0.0".to_string(),
1244            latest_version: "2.0.0".to_string(),
1245        };
1246        assert!(available.to_string().contains("1.0.0"));
1247        assert!(available.to_string().contains("2.0.0"));
1248
1249        let failed = UpdateStatus::Failed("test error".to_string());
1250        assert!(failed.to_string().contains("test error"));
1251    }
1252}