Skip to main content

saorsa_node/upgrade/
monitor.rs

1//! GitHub release monitor for auto-upgrades.
2//!
3//! This module provides functionality to:
4//! - Poll GitHub releases API for new versions
5//! - Filter releases by channel (stable/beta)
6//! - Find platform-specific binary assets
7//! - Detect available upgrades
8//! - Staged rollout with deterministic delays
9
10use crate::config::UpgradeChannel;
11use crate::error::{Error, Result};
12use crate::upgrade::rollout::StagedRollout;
13use crate::upgrade::UpgradeInfo;
14use semver::Version;
15use serde::Deserialize;
16use std::time::{Duration, Instant};
17use tracing::{debug, info, warn};
18
19/// GitHub release API response.
20#[derive(Debug, Deserialize)]
21pub struct GitHubRelease {
22    /// Git tag name (e.g., "v1.2.0").
23    pub tag_name: String,
24    /// Release title.
25    pub name: String,
26    /// Release description/notes.
27    pub body: String,
28    /// Whether this is a pre-release.
29    pub prerelease: bool,
30    /// Attached binary assets.
31    pub assets: Vec<Asset>,
32}
33
34/// GitHub release asset (attached file).
35#[derive(Debug, Deserialize, Clone)]
36pub struct Asset {
37    /// Filename of the asset.
38    pub name: String,
39    /// Direct download URL.
40    pub browser_download_url: String,
41}
42
43/// Monitors GitHub releases for new versions.
44pub struct UpgradeMonitor {
45    /// GitHub repository (owner/repo format).
46    repo: String,
47    /// Release channel to track.
48    channel: UpgradeChannel,
49    /// How often to check for updates.
50    check_interval: Duration,
51    /// Current version.
52    current_version: Version,
53    /// HTTP client for GitHub API requests.
54    client: reqwest::Client,
55    /// Staged rollout calculator (optional).
56    staged_rollout: Option<StagedRollout>,
57    /// When the current pending upgrade was first detected.
58    pending_upgrade_detected: Option<Instant>,
59    /// The version of the pending upgrade (for tracking rollout state).
60    pending_upgrade_version: Option<Version>,
61}
62
63impl UpgradeMonitor {
64    /// Create a new upgrade monitor.
65    ///
66    /// # Arguments
67    ///
68    /// * `repo` - GitHub repository in "owner/repo" format
69    /// * `channel` - Release channel to track (Stable or Beta)
70    /// * `check_interval_hours` - How often to check for updates
71    #[must_use]
72    pub fn new(repo: String, channel: UpgradeChannel, check_interval_hours: u64) -> Self {
73        let current_version =
74            Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or_else(|_| Version::new(0, 0, 0));
75
76        let client = reqwest::Client::builder()
77            .user_agent(concat!("saorsa-node/", env!("CARGO_PKG_VERSION")))
78            .timeout(Duration::from_secs(30))
79            .build()
80            .unwrap_or_else(|e| {
81                warn!("Failed to build reqwest client for upgrades: {e}");
82                reqwest::Client::new()
83            });
84
85        Self {
86            repo,
87            channel,
88            check_interval: Duration::from_secs(check_interval_hours * 3600),
89            current_version,
90            client,
91            staged_rollout: None,
92            pending_upgrade_detected: None,
93            pending_upgrade_version: None,
94        }
95    }
96
97    /// Configure staged rollout for this monitor.
98    ///
99    /// # Arguments
100    ///
101    /// * `node_id` - The node's unique identifier for deterministic delay calculation
102    /// * `max_delay_hours` - Maximum rollout window (0 to disable)
103    #[must_use]
104    pub fn with_staged_rollout(mut self, node_id: &[u8], max_delay_hours: u64) -> Self {
105        if max_delay_hours > 0 {
106            self.staged_rollout = Some(StagedRollout::new(node_id, max_delay_hours));
107            info!("Staged rollout enabled: {} hour window", max_delay_hours);
108        }
109        self
110    }
111
112    /// Create a monitor with a custom current version (for testing).
113    #[cfg(test)]
114    #[must_use]
115    pub fn with_version(
116        repo: String,
117        channel: UpgradeChannel,
118        check_interval_hours: u64,
119        current_version: Version,
120    ) -> Self {
121        let client = reqwest::Client::builder()
122            .user_agent(concat!("saorsa-node/", env!("CARGO_PKG_VERSION")))
123            .timeout(Duration::from_secs(30))
124            .build()
125            .unwrap_or_else(|e| {
126                warn!("Failed to build reqwest client for upgrades: {e}");
127                reqwest::Client::new()
128            });
129
130        Self {
131            repo,
132            channel,
133            check_interval: Duration::from_secs(check_interval_hours * 3600),
134            current_version,
135            client,
136            staged_rollout: None,
137            pending_upgrade_detected: None,
138            pending_upgrade_version: None,
139        }
140    }
141
142    /// Get the check interval.
143    #[must_use]
144    pub fn check_interval(&self) -> Duration {
145        self.check_interval
146    }
147
148    /// Get the current version.
149    #[must_use]
150    pub fn current_version(&self) -> &Version {
151        &self.current_version
152    }
153
154    /// Get the tracked repository.
155    #[must_use]
156    pub fn repo(&self) -> &str {
157        &self.repo
158    }
159
160    /// Check if version matches the configured channel.
161    ///
162    /// - Stable channel: Only accepts versions without pre-release suffixes
163    /// - Beta channel: Accepts all versions (stable and pre-release)
164    #[must_use]
165    pub fn version_matches_channel(&self, version: &Version) -> bool {
166        match self.channel {
167            UpgradeChannel::Stable => version.pre.is_empty(),
168            UpgradeChannel::Beta => true, // Beta accepts all
169        }
170    }
171
172    /// Check GitHub for available updates.
173    ///
174    /// This method only checks for available updates, it does not respect
175    /// staged rollout delays. Use [`Self::check_for_ready_upgrade`] for staged rollout
176    /// aware upgrade checking.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if the GitHub API request fails.
181    pub async fn check_for_updates(&self) -> Result<Option<UpgradeInfo>> {
182        let api_url = format!("https://api.github.com/repos/{}/releases", self.repo);
183
184        debug!("Checking for updates from: {}", api_url);
185
186        let response = self
187            .client
188            .get(&api_url)
189            .header("Accept", "application/vnd.github+json")
190            .send()
191            .await
192            .map_err(|e| Error::Network(format!("GitHub API request failed: {e}")))?;
193
194        if !response.status().is_success() {
195            return Err(Error::Network(format!(
196                "GitHub API returned status: {}",
197                response.status()
198            )));
199        }
200
201        let releases: Vec<GitHubRelease> = response
202            .json()
203            .await
204            .map_err(|e| Error::Network(format!("Failed to parse releases: {e}")))?;
205
206        Ok(select_upgrade_from_releases(
207            &releases,
208            &self.current_version,
209            self.channel,
210        ))
211    }
212
213    /// Check for available updates with staged rollout awareness.
214    ///
215    /// This method:
216    /// 1. Checks GitHub for available updates
217    /// 2. If staged rollout is enabled and an upgrade is found:
218    ///    - Starts tracking the upgrade detection time
219    ///    - Returns `None` until the calculated delay has passed
220    ///    - Returns the upgrade info once the node is ready to apply it
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the GitHub API request fails.
225    pub async fn check_for_ready_upgrade(&mut self) -> Result<Option<UpgradeInfo>> {
226        let upgrade_info = self.check_for_updates().await?;
227
228        let Some(info) = upgrade_info else {
229            // No upgrade available - reset tracking state
230            self.pending_upgrade_detected = None;
231            self.pending_upgrade_version = None;
232            return Ok(None);
233        };
234
235        // If staged rollout is not enabled, return immediately
236        let Some(ref rollout) = self.staged_rollout else {
237            return Ok(Some(info));
238        };
239
240        // Check if this is a new version or we're still tracking the same one
241        let is_new_version = self
242            .pending_upgrade_version
243            .as_ref()
244            .map_or(true, |v| *v != info.version);
245
246        if is_new_version {
247            // New version detected - start rollout timer
248            self.pending_upgrade_detected = Some(Instant::now());
249            self.pending_upgrade_version = Some(info.version.clone());
250
251            let delay = rollout.calculate_delay_for_version(&info.version);
252            info!(
253                new_version = %info.version,
254                delay_hours = delay.as_secs() / 3600,
255                delay_minutes = (delay.as_secs() % 3600) / 60,
256                "New version detected, staged rollout delay calculated"
257            );
258        }
259
260        // Calculate if we're past the rollout delay
261        let Some(detected_at) = self.pending_upgrade_detected else {
262            // Should not happen, but handle gracefully
263            warn!("Pending upgrade detected but no timestamp recorded");
264            return Ok(Some(info));
265        };
266
267        let delay = rollout.calculate_delay_for_version(&info.version);
268        let elapsed = detected_at.elapsed();
269
270        if elapsed >= delay {
271            info!(
272                version = %info.version,
273                "Staged rollout delay elapsed, ready to upgrade"
274            );
275            Ok(Some(info))
276        } else {
277            let remaining = delay.saturating_sub(elapsed);
278            debug!(
279                "Staged rollout: {}h {}m remaining before upgrade to {}",
280                remaining.as_secs() / 3600,
281                (remaining.as_secs() % 3600) / 60,
282                info.version
283            );
284            Ok(None)
285        }
286    }
287
288    /// Get the remaining time until this node should upgrade.
289    ///
290    /// Returns `None` if no upgrade is pending or staged rollout is disabled.
291    #[must_use]
292    pub fn time_until_upgrade(&self) -> Option<Duration> {
293        let rollout = self.staged_rollout.as_ref()?;
294        let version = self.pending_upgrade_version.as_ref()?;
295        let detected_at = self.pending_upgrade_detected?;
296
297        let delay = rollout.calculate_delay_for_version(version);
298        let elapsed = detected_at.elapsed();
299
300        if elapsed >= delay {
301            Some(Duration::ZERO)
302        } else {
303            Some(delay.saturating_sub(elapsed))
304        }
305    }
306
307    /// Check if staged rollout is enabled.
308    #[must_use]
309    pub fn has_staged_rollout(&self) -> bool {
310        self.staged_rollout.is_some()
311    }
312
313    /// Get the pending upgrade version, if any.
314    #[must_use]
315    pub fn pending_version(&self) -> Option<&Version> {
316        self.pending_upgrade_version.as_ref()
317    }
318
319    /// Process a GitHub release and determine if an upgrade is available.
320    #[allow(dead_code)]
321    fn process_release(&self, release: &GitHubRelease) -> Option<UpgradeInfo> {
322        let latest_version = version_from_tag(&release.tag_name)?;
323
324        // Check if newer
325        if latest_version <= self.current_version {
326            debug!("Current version {} is up to date", self.current_version);
327            return None;
328        }
329
330        // Check channel filter
331        if !self.version_matches_channel(&latest_version) {
332            debug!(
333                "Version {} doesn't match channel {:?}",
334                latest_version, self.channel
335            );
336            return None;
337        }
338
339        // Find platform assets
340        let binary_asset = find_platform_asset(&release.assets)?;
341
342        let sig_name = format!("{}.sig", binary_asset.name);
343        let sig_asset = release.assets.iter().find(|a| a.name == sig_name)?;
344
345        info!(
346            current_version = %self.current_version,
347            new_version = %latest_version,
348            "New version available"
349        );
350
351        Some(UpgradeInfo {
352            version: latest_version,
353            download_url: binary_asset.browser_download_url.clone(),
354            signature_url: sig_asset.browser_download_url.clone(),
355            release_notes: release.body.clone(),
356        })
357    }
358}
359
360/// Select the most appropriate upgrade from a list of releases.
361///
362/// For stable channel, pre-releases are ignored.
363/// For beta channel, pre-releases are eligible.
364///
365/// Returns the newest version that matches the channel and has platform assets.
366fn select_upgrade_from_releases(
367    releases: &[GitHubRelease],
368    current_version: &Version,
369    channel: UpgradeChannel,
370) -> Option<UpgradeInfo> {
371    let mut best: Option<UpgradeInfo> = None;
372
373    for release in releases {
374        let Some(version) = version_from_tag(&release.tag_name) else {
375            continue;
376        };
377
378        if version <= *current_version {
379            continue;
380        }
381
382        if channel == UpgradeChannel::Stable && !version.pre.is_empty() {
383            continue;
384        }
385
386        let Some(binary_asset) = find_platform_asset(&release.assets) else {
387            continue;
388        };
389
390        let sig_name = format!("{}.sig", binary_asset.name);
391        let Some(sig_asset) = release.assets.iter().find(|a| a.name == sig_name) else {
392            continue;
393        };
394
395        let candidate = UpgradeInfo {
396            version: version.clone(),
397            download_url: binary_asset.browser_download_url.clone(),
398            signature_url: sig_asset.browser_download_url.clone(),
399            release_notes: release.body.clone(),
400        };
401
402        let should_replace = best
403            .as_ref()
404            .map_or(true, |b| candidate.version > b.version);
405
406        if should_replace {
407            best = Some(candidate);
408        }
409    }
410
411    best
412}
413
414/// Parse version from git tag.
415///
416/// Handles both "v1.2.3" and "1.2.3" formats.
417#[must_use]
418pub fn version_from_tag(tag: &str) -> Option<Version> {
419    let version_str = tag.strip_prefix('v').unwrap_or(tag);
420    Version::parse(version_str).ok()
421}
422
423/// Find the appropriate binary asset for the current platform.
424///
425/// Looks for assets matching the current OS and architecture.
426/// On Windows, also looks for `.exe` suffixed binaries.
427#[must_use]
428pub fn find_platform_asset(assets: &[Asset]) -> Option<&Asset> {
429    let arch = std::env::consts::ARCH;
430    let os = std::env::consts::OS;
431
432    // Build platform-specific patterns
433    let patterns = build_platform_patterns(arch, os);
434
435    // Try each pattern in order of specificity
436    for pattern in &patterns {
437        if let Some(asset) = assets
438            .iter()
439            .find(|a| a.name.contains(pattern) && is_binary_asset(&a.name))
440        {
441            return Some(asset);
442        }
443    }
444
445    None
446}
447
448/// Check if an asset name represents a downloadable binary or archive.
449///
450/// This includes direct executables, as well as archive formats (`.tar.gz`, `.zip`)
451/// that contain binaries.
452#[allow(clippy::case_sensitive_file_extension_comparisons)]
453fn is_binary_asset(name: &str) -> bool {
454    let lower = name.to_lowercase();
455
456    // Exclude signatures and other non-binary files (already lowercased above)
457    if lower.ends_with(".sig")
458        || lower.ends_with(".sha256")
459        || lower.ends_with(".md5")
460        || lower.ends_with(".txt")
461        || lower.ends_with(".md")
462        || lower.ends_with(".deb")
463        || lower.ends_with(".rpm")
464        || lower.ends_with(".msi")
465    {
466        return false;
467    }
468
469    // Accept archive formats on all platforms
470    if lower.ends_with(".tar.gz") || lower.ends_with(".zip") {
471        return true;
472    }
473
474    // On Windows, prefer .exe files for direct binary downloads
475    #[cfg(windows)]
476    if !lower.ends_with(".exe") {
477        return false;
478    }
479
480    true
481}
482
483/// Build platform-specific search patterns.
484fn build_platform_patterns(arch: &str, os: &str) -> Vec<String> {
485    let mut patterns = Vec::new();
486
487    // Map arch to common naming conventions
488    let arch_patterns: Vec<&str> = match arch {
489        "x86_64" => vec!["x86_64", "amd64", "x64"],
490        "aarch64" => vec!["aarch64", "arm64"],
491        "x86" => vec!["i686", "i386", "x86"],
492        _ => vec![arch],
493    };
494
495    // Map OS to common naming conventions
496    let os_patterns: Vec<&str> = match os {
497        "linux" => vec!["linux", "unknown-linux-gnu", "linux-gnu"],
498        "macos" => vec!["darwin", "macos", "apple-darwin"],
499        "windows" => vec!["windows", "pc-windows-msvc", "win64"],
500        _ => vec![os],
501    };
502
503    // Generate all combinations
504    for arch_pat in &arch_patterns {
505        for os_pat in &os_patterns {
506            patterns.push(format!("{arch_pat}-{os_pat}"));
507            patterns.push(format!("{os_pat}-{arch_pat}"));
508        }
509    }
510
511    // Add individual patterns as fallback
512    for arch_pat in &arch_patterns {
513        patterns.push((*arch_pat).to_string());
514    }
515
516    patterns
517}
518
519#[cfg(test)]
520#[allow(
521    clippy::unwrap_used,
522    clippy::expect_used,
523    clippy::case_sensitive_file_extension_comparisons
524)]
525mod tests {
526    use super::*;
527
528    /// Test 1: Version comparison - newer available
529    #[test]
530    fn test_version_newer_available() {
531        let current = Version::new(1, 0, 0);
532        let latest = Version::new(1, 1, 0);
533        assert!(latest > current);
534    }
535
536    /// Test 2: Version comparison - same version
537    #[test]
538    fn test_version_same() {
539        let current = Version::new(1, 0, 0);
540        let latest = Version::new(1, 0, 0);
541        assert!(latest <= current);
542    }
543
544    /// Test 3: Version comparison - older available (downgrade prevention)
545    #[test]
546    fn test_version_older_rejected() {
547        let current = Version::new(1, 1, 0);
548        let latest = Version::new(1, 0, 0);
549        assert!(latest <= current);
550    }
551
552    /// Test 4: Pre-release handling
553    #[test]
554    fn test_prerelease_version() {
555        let stable = Version::parse("1.0.0").unwrap();
556        let beta = Version::parse("1.1.0-beta.1").unwrap();
557        // Beta 1.1.0 considered newer than stable 1.0.0
558        assert!(beta > stable);
559    }
560
561    /// Test 5: Channel filtering - stable only
562    #[test]
563    fn test_stable_channel_filters_beta() {
564        let monitor = UpgradeMonitor::new(
565            "dirvine/saorsa-node".to_string(),
566            UpgradeChannel::Stable,
567            24,
568        );
569
570        let beta_version = Version::parse("1.0.0-beta.1").unwrap();
571        assert!(!monitor.version_matches_channel(&beta_version));
572
573        let stable_version = Version::parse("1.0.0").unwrap();
574        assert!(monitor.version_matches_channel(&stable_version));
575    }
576
577    /// Test 6: Channel filtering - beta includes beta
578    #[test]
579    fn test_beta_channel_accepts_beta() {
580        let monitor =
581            UpgradeMonitor::new("dirvine/saorsa-node".to_string(), UpgradeChannel::Beta, 24);
582
583        let beta_version = Version::parse("1.0.0-beta.1").unwrap();
584        assert!(monitor.version_matches_channel(&beta_version));
585    }
586
587    /// Test 7: Parse GitHub release response
588    #[test]
589    fn test_parse_github_release() {
590        let json = r#"{
591            "tag_name": "v1.2.0",
592            "name": "Release 1.2.0",
593            "body": "Release notes here",
594            "prerelease": false,
595            "assets": [
596                {
597                    "name": "saorsa-node-x86_64-unknown-linux-gnu",
598                    "browser_download_url": "https://example.com/binary"
599                },
600                {
601                    "name": "saorsa-node-x86_64-unknown-linux-gnu.sig",
602                    "browser_download_url": "https://example.com/binary.sig"
603                }
604            ]
605        }"#;
606
607        let release: GitHubRelease = serde_json::from_str(json).unwrap();
608        assert_eq!(release.tag_name, "v1.2.0");
609        assert_eq!(release.name, "Release 1.2.0");
610        assert_eq!(release.body, "Release notes here");
611        assert!(!release.prerelease);
612        assert_eq!(release.assets.len(), 2);
613    }
614
615    /// Test 8: Extract version from tag
616    #[test]
617    fn test_version_from_tag() {
618        assert_eq!(version_from_tag("v1.2.3"), Some(Version::new(1, 2, 3)));
619        assert_eq!(version_from_tag("1.2.3"), Some(Version::new(1, 2, 3)));
620        assert_eq!(
621            version_from_tag("v1.0.0-beta.1"),
622            Some(Version::parse("1.0.0-beta.1").unwrap())
623        );
624        assert_eq!(version_from_tag("invalid"), None);
625        assert_eq!(version_from_tag(""), None);
626    }
627
628    /// Test 9: Find correct asset for platform
629    #[test]
630    fn test_find_platform_asset() {
631        // Test with archive format (CLI releases)
632        let assets = vec![
633            Asset {
634                name: "saorsa-node-cli-linux-x64.tar.gz".to_string(),
635                browser_download_url: "https://example.com/linux".to_string(),
636            },
637            Asset {
638                name: "saorsa-node-cli-linux-x64.tar.gz.sig".to_string(),
639                browser_download_url: "https://example.com/linux.sig".to_string(),
640            },
641            Asset {
642                name: "saorsa-node-cli-macos-arm64.tar.gz".to_string(),
643                browser_download_url: "https://example.com/macos".to_string(),
644            },
645            Asset {
646                name: "saorsa-node-cli-macos-arm64.tar.gz.sig".to_string(),
647                browser_download_url: "https://example.com/macos.sig".to_string(),
648            },
649            Asset {
650                name: "saorsa-node-cli-windows-x64.zip".to_string(),
651                browser_download_url: "https://example.com/windows".to_string(),
652            },
653            Asset {
654                name: "saorsa-node-cli-windows-x64.zip.sig".to_string(),
655                browser_download_url: "https://example.com/windows.sig".to_string(),
656            },
657        ];
658
659        let asset = find_platform_asset(&assets);
660        assert!(asset.is_some(), "Should find platform asset");
661        let asset = asset.unwrap();
662        // Should not be a .sig file
663        assert!(!asset.name.to_lowercase().ends_with(".sig"));
664        // Should be an archive
665        let lower = asset.name.to_lowercase();
666        assert!(
667            lower.ends_with(".tar.gz") || lower.ends_with(".zip"),
668            "Should be an archive format"
669        );
670    }
671
672    /// Test: `is_binary_asset` correctly identifies binaries and archives
673    #[test]
674    fn test_is_binary_asset() {
675        // Archive formats should be identified (CLI releases)
676        assert!(is_binary_asset("saorsa-node-cli-linux-x64.tar.gz"));
677        assert!(is_binary_asset("saorsa-node-cli-macos-arm64.tar.gz"));
678        assert!(is_binary_asset("saorsa-node-cli-windows-x64.zip"));
679
680        // Signature and metadata files should be excluded
681        assert!(!is_binary_asset("saorsa-node.sig"));
682        assert!(!is_binary_asset("saorsa-node.sha256"));
683        assert!(!is_binary_asset("saorsa-node.md5"));
684        assert!(!is_binary_asset("RELEASE_NOTES.txt"));
685        assert!(!is_binary_asset("README.md"));
686
687        // Installer packages should be excluded (handled separately)
688        assert!(!is_binary_asset("saorsa-node.deb"));
689        assert!(!is_binary_asset("saorsa-node.rpm"));
690        assert!(!is_binary_asset("saorsa-node.msi"));
691    }
692
693    /// Test 10: Monitor check interval
694    #[test]
695    fn test_check_interval() {
696        let monitor = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 24);
697        assert_eq!(monitor.check_interval(), Duration::from_secs(24 * 3600));
698
699        let monitor2 = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 6);
700        assert_eq!(monitor2.check_interval(), Duration::from_secs(6 * 3600));
701    }
702
703    /// Test 11: Process release - upgrade available
704    #[test]
705    fn test_process_release_upgrade_available() {
706        let monitor = UpgradeMonitor::with_version(
707            "test/repo".to_string(),
708            UpgradeChannel::Stable,
709            24,
710            Version::new(1, 0, 0),
711        );
712
713        // Build platform-specific archive name using friendly naming
714        let (friendly_os, archive_ext) = match std::env::consts::OS {
715            "linux" => ("linux", "tar.gz"),
716            "macos" => ("macos", "tar.gz"),
717            "windows" => ("windows", "zip"),
718            _ => ("unknown", "tar.gz"),
719        };
720        let friendly_arch = match std::env::consts::ARCH {
721            "x86_64" => "x64",
722            "aarch64" => "arm64",
723            _ => std::env::consts::ARCH,
724        };
725        let archive_name = format!("saorsa-node-cli-{friendly_os}-{friendly_arch}.{archive_ext}");
726
727        let release = GitHubRelease {
728            tag_name: "v1.1.0".to_string(),
729            name: "Release 1.1.0".to_string(),
730            body: "New features".to_string(),
731            prerelease: false,
732            assets: vec![
733                Asset {
734                    name: archive_name.clone(),
735                    browser_download_url: "https://example.com/binary".to_string(),
736                },
737                Asset {
738                    name: format!("{archive_name}.sig"),
739                    browser_download_url: "https://example.com/binary.sig".to_string(),
740                },
741            ],
742        };
743
744        let result = monitor.process_release(&release);
745        assert!(result.is_some(), "Should find upgrade");
746        let info = result.unwrap();
747        assert_eq!(info.version, Version::new(1, 1, 0));
748        assert_eq!(info.release_notes, "New features");
749    }
750
751    /// Test 12: Process release - no upgrade (same version)
752    #[test]
753    fn test_process_release_no_upgrade_same_version() {
754        let monitor = UpgradeMonitor::with_version(
755            "test/repo".to_string(),
756            UpgradeChannel::Stable,
757            24,
758            Version::new(1, 0, 0),
759        );
760
761        let release = GitHubRelease {
762            tag_name: "v1.0.0".to_string(),
763            name: "Release 1.0.0".to_string(),
764            body: "Current version".to_string(),
765            prerelease: false,
766            assets: vec![],
767        };
768
769        let result = monitor.process_release(&release);
770        assert!(result.is_none(), "Should not find upgrade for same version");
771    }
772
773    /// Test 13: Process release - no upgrade (older version)
774    #[test]
775    fn test_process_release_no_upgrade_older_version() {
776        let monitor = UpgradeMonitor::with_version(
777            "test/repo".to_string(),
778            UpgradeChannel::Stable,
779            24,
780            Version::new(1, 1, 0),
781        );
782
783        let release = GitHubRelease {
784            tag_name: "v1.0.0".to_string(),
785            name: "Release 1.0.0".to_string(),
786            body: "Old version".to_string(),
787            prerelease: false,
788            assets: vec![],
789        };
790
791        let result = monitor.process_release(&release);
792        assert!(
793            result.is_none(),
794            "Should not find upgrade for older version"
795        );
796    }
797
798    /// Test 14: Process release - beta filtered by stable channel
799    #[test]
800    fn test_process_release_beta_filtered() {
801        let monitor = UpgradeMonitor::with_version(
802            "test/repo".to_string(),
803            UpgradeChannel::Stable,
804            24,
805            Version::new(1, 0, 0),
806        );
807
808        let release = GitHubRelease {
809            tag_name: "v1.1.0-beta.1".to_string(),
810            name: "Beta Release".to_string(),
811            body: "Beta features".to_string(),
812            prerelease: true,
813            assets: vec![],
814        };
815
816        let result = monitor.process_release(&release);
817        assert!(
818            result.is_none(),
819            "Stable channel should filter beta releases"
820        );
821    }
822
823    /// Test 15: Monitor repo getter
824    #[test]
825    fn test_monitor_repo() {
826        let monitor = UpgradeMonitor::new(
827            "dirvine/saorsa-node".to_string(),
828            UpgradeChannel::Stable,
829            24,
830        );
831        assert_eq!(monitor.repo(), "dirvine/saorsa-node");
832    }
833
834    /// Test 16: Current version getter
835    #[test]
836    fn test_monitor_current_version() {
837        let monitor = UpgradeMonitor::with_version(
838            "test/repo".to_string(),
839            UpgradeChannel::Stable,
840            24,
841            Version::new(2, 3, 4),
842        );
843        assert_eq!(*monitor.current_version(), Version::new(2, 3, 4));
844    }
845
846    /// Test 17: Build platform patterns
847    #[test]
848    fn test_build_platform_patterns() {
849        let patterns = build_platform_patterns("x86_64", "linux");
850        assert!(patterns.iter().any(|p| p.contains("x86_64")));
851        assert!(patterns.iter().any(|p| p.contains("x64")));
852        assert!(patterns.iter().any(|p| p.contains("linux")));
853
854        let patterns_arm = build_platform_patterns("aarch64", "macos");
855        assert!(patterns_arm
856            .iter()
857            .any(|p| p.contains("aarch64") || p.contains("arm64")));
858        assert!(patterns_arm
859            .iter()
860            .any(|p| p.contains("darwin") || p.contains("macos")));
861    }
862
863    /// Test 18: Invalid tag handling
864    #[test]
865    fn test_process_release_invalid_tag() {
866        let monitor = UpgradeMonitor::with_version(
867            "test/repo".to_string(),
868            UpgradeChannel::Stable,
869            24,
870            Version::new(1, 0, 0),
871        );
872
873        let release = GitHubRelease {
874            tag_name: "not-a-version".to_string(),
875            name: "Invalid Release".to_string(),
876            body: "Invalid".to_string(),
877            prerelease: false,
878            assets: vec![],
879        };
880
881        let result = monitor.process_release(&release);
882        assert!(result.is_none(), "Should gracefully handle invalid tag");
883    }
884
885    #[test]
886    fn test_select_upgrade_stable_ignores_prerelease() {
887        let current = Version::new(1, 0, 0);
888        let arch = std::env::consts::ARCH;
889        let os = std::env::consts::OS;
890        // On Windows, binary assets require .exe extension
891        #[cfg(windows)]
892        let bin_name = format!("saorsa-node-{arch}-{os}.exe");
893        #[cfg(not(windows))]
894        let bin_name = format!("saorsa-node-{arch}-{os}");
895        let releases = vec![
896            GitHubRelease {
897                tag_name: "v1.1.0-beta.1".to_string(),
898                name: "beta".to_string(),
899                body: "beta".to_string(),
900                prerelease: true,
901                assets: vec![
902                    Asset {
903                        name: bin_name.clone(),
904                        browser_download_url: "https://example.com/beta".to_string(),
905                    },
906                    Asset {
907                        name: format!("{bin_name}.sig"),
908                        browser_download_url: "https://example.com/beta.sig".to_string(),
909                    },
910                ],
911            },
912            GitHubRelease {
913                tag_name: "v1.1.0".to_string(),
914                name: "stable".to_string(),
915                body: "stable".to_string(),
916                prerelease: false,
917                assets: vec![
918                    Asset {
919                        name: bin_name.clone(),
920                        browser_download_url: "https://example.com/stable".to_string(),
921                    },
922                    Asset {
923                        name: format!("{bin_name}.sig"),
924                        browser_download_url: "https://example.com/stable.sig".to_string(),
925                    },
926                ],
927            },
928        ];
929
930        let upgrade =
931            select_upgrade_from_releases(&releases, &current, UpgradeChannel::Stable).unwrap();
932        assert_eq!(upgrade.version, Version::new(1, 1, 0));
933        assert!(upgrade.download_url.contains("stable"));
934    }
935
936    #[test]
937    fn test_select_upgrade_beta_accepts_prerelease_if_newest() {
938        let current = Version::new(1, 0, 0);
939        let arch = std::env::consts::ARCH;
940        let os = std::env::consts::OS;
941        // On Windows, binary assets require .exe extension
942        #[cfg(windows)]
943        let bin_name = format!("saorsa-node-{arch}-{os}.exe");
944        #[cfg(not(windows))]
945        let bin_name = format!("saorsa-node-{arch}-{os}");
946        let releases = vec![
947            GitHubRelease {
948                tag_name: "v1.1.0".to_string(),
949                name: "stable".to_string(),
950                body: "stable".to_string(),
951                prerelease: false,
952                assets: vec![
953                    Asset {
954                        name: bin_name.clone(),
955                        browser_download_url: "https://example.com/stable".to_string(),
956                    },
957                    Asset {
958                        name: format!("{bin_name}.sig"),
959                        browser_download_url: "https://example.com/stable.sig".to_string(),
960                    },
961                ],
962            },
963            GitHubRelease {
964                tag_name: "v1.2.0-beta.1".to_string(),
965                name: "beta".to_string(),
966                body: "beta".to_string(),
967                prerelease: true,
968                assets: vec![
969                    Asset {
970                        name: bin_name.clone(),
971                        browser_download_url: "https://example.com/beta".to_string(),
972                    },
973                    Asset {
974                        name: format!("{bin_name}.sig"),
975                        browser_download_url: "https://example.com/beta.sig".to_string(),
976                    },
977                ],
978            },
979        ];
980
981        let upgrade =
982            select_upgrade_from_releases(&releases, &current, UpgradeChannel::Beta).unwrap();
983        assert_eq!(upgrade.version, Version::parse("1.2.0-beta.1").unwrap());
984        assert!(upgrade.download_url.contains("beta"));
985    }
986}