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 {} detected. Staged rollout delay: {}h {}m",
254                info.version,
255                delay.as_secs() / 3600,
256                (delay.as_secs() % 3600) / 60
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                "Staged rollout delay elapsed. Ready to upgrade to version {}",
273                info.version
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            "New version available: {} -> {}",
347            self.current_version, latest_version
348        );
349
350        Some(UpgradeInfo {
351            version: latest_version,
352            download_url: binary_asset.browser_download_url.clone(),
353            signature_url: sig_asset.browser_download_url.clone(),
354            release_notes: release.body.clone(),
355        })
356    }
357}
358
359/// Select the most appropriate upgrade from a list of releases.
360///
361/// For stable channel, pre-releases are ignored.
362/// For beta channel, pre-releases are eligible.
363///
364/// Returns the newest version that matches the channel and has platform assets.
365fn select_upgrade_from_releases(
366    releases: &[GitHubRelease],
367    current_version: &Version,
368    channel: UpgradeChannel,
369) -> Option<UpgradeInfo> {
370    let mut best: Option<UpgradeInfo> = None;
371
372    for release in releases {
373        let Some(version) = version_from_tag(&release.tag_name) else {
374            continue;
375        };
376
377        if version <= *current_version {
378            continue;
379        }
380
381        if channel == UpgradeChannel::Stable && !version.pre.is_empty() {
382            continue;
383        }
384
385        let Some(binary_asset) = find_platform_asset(&release.assets) else {
386            continue;
387        };
388
389        let sig_name = format!("{}.sig", binary_asset.name);
390        let Some(sig_asset) = release.assets.iter().find(|a| a.name == sig_name) else {
391            continue;
392        };
393
394        let candidate = UpgradeInfo {
395            version: version.clone(),
396            download_url: binary_asset.browser_download_url.clone(),
397            signature_url: sig_asset.browser_download_url.clone(),
398            release_notes: release.body.clone(),
399        };
400
401        let should_replace = best
402            .as_ref()
403            .map_or(true, |b| candidate.version > b.version);
404
405        if should_replace {
406            best = Some(candidate);
407        }
408    }
409
410    best
411}
412
413/// Parse version from git tag.
414///
415/// Handles both "v1.2.3" and "1.2.3" formats.
416#[must_use]
417pub fn version_from_tag(tag: &str) -> Option<Version> {
418    let version_str = tag.strip_prefix('v').unwrap_or(tag);
419    Version::parse(version_str).ok()
420}
421
422/// Find the appropriate binary asset for the current platform.
423///
424/// Looks for assets matching the current OS and architecture.
425/// On Windows, also looks for `.exe` suffixed binaries.
426#[must_use]
427pub fn find_platform_asset(assets: &[Asset]) -> Option<&Asset> {
428    let arch = std::env::consts::ARCH;
429    let os = std::env::consts::OS;
430
431    // Build platform-specific patterns
432    let patterns = build_platform_patterns(arch, os);
433
434    // Try each pattern in order of specificity
435    for pattern in &patterns {
436        if let Some(asset) = assets
437            .iter()
438            .find(|a| a.name.contains(pattern) && is_binary_asset(&a.name))
439        {
440            return Some(asset);
441        }
442    }
443
444    None
445}
446
447/// Check if an asset name represents a downloadable binary or archive.
448///
449/// This includes direct executables, as well as archive formats (`.tar.gz`, `.zip`)
450/// that contain binaries.
451#[allow(clippy::case_sensitive_file_extension_comparisons)]
452fn is_binary_asset(name: &str) -> bool {
453    let lower = name.to_lowercase();
454
455    // Exclude signatures and other non-binary files (already lowercased above)
456    if lower.ends_with(".sig")
457        || lower.ends_with(".sha256")
458        || lower.ends_with(".md5")
459        || lower.ends_with(".txt")
460        || lower.ends_with(".md")
461        || lower.ends_with(".deb")
462        || lower.ends_with(".rpm")
463        || lower.ends_with(".msi")
464    {
465        return false;
466    }
467
468    // Accept archive formats on all platforms
469    if lower.ends_with(".tar.gz") || lower.ends_with(".zip") {
470        return true;
471    }
472
473    // On Windows, prefer .exe files for direct binary downloads
474    #[cfg(windows)]
475    if !lower.ends_with(".exe") {
476        return false;
477    }
478
479    true
480}
481
482/// Build platform-specific search patterns.
483fn build_platform_patterns(arch: &str, os: &str) -> Vec<String> {
484    let mut patterns = Vec::new();
485
486    // Map arch to common naming conventions
487    let arch_patterns: Vec<&str> = match arch {
488        "x86_64" => vec!["x86_64", "amd64", "x64"],
489        "aarch64" => vec!["aarch64", "arm64"],
490        "x86" => vec!["i686", "i386", "x86"],
491        _ => vec![arch],
492    };
493
494    // Map OS to common naming conventions
495    let os_patterns: Vec<&str> = match os {
496        "linux" => vec!["linux", "unknown-linux-gnu", "linux-gnu"],
497        "macos" => vec!["darwin", "macos", "apple-darwin"],
498        "windows" => vec!["windows", "pc-windows-msvc", "win64"],
499        _ => vec![os],
500    };
501
502    // Generate all combinations
503    for arch_pat in &arch_patterns {
504        for os_pat in &os_patterns {
505            patterns.push(format!("{arch_pat}-{os_pat}"));
506            patterns.push(format!("{os_pat}-{arch_pat}"));
507        }
508    }
509
510    // Add individual patterns as fallback
511    for arch_pat in &arch_patterns {
512        patterns.push((*arch_pat).to_string());
513    }
514
515    patterns
516}
517
518#[cfg(test)]
519#[allow(
520    clippy::unwrap_used,
521    clippy::expect_used,
522    clippy::case_sensitive_file_extension_comparisons
523)]
524mod tests {
525    use super::*;
526
527    /// Test 1: Version comparison - newer available
528    #[test]
529    fn test_version_newer_available() {
530        let current = Version::new(1, 0, 0);
531        let latest = Version::new(1, 1, 0);
532        assert!(latest > current);
533    }
534
535    /// Test 2: Version comparison - same version
536    #[test]
537    fn test_version_same() {
538        let current = Version::new(1, 0, 0);
539        let latest = Version::new(1, 0, 0);
540        assert!(latest <= current);
541    }
542
543    /// Test 3: Version comparison - older available (downgrade prevention)
544    #[test]
545    fn test_version_older_rejected() {
546        let current = Version::new(1, 1, 0);
547        let latest = Version::new(1, 0, 0);
548        assert!(latest <= current);
549    }
550
551    /// Test 4: Pre-release handling
552    #[test]
553    fn test_prerelease_version() {
554        let stable = Version::parse("1.0.0").unwrap();
555        let beta = Version::parse("1.1.0-beta.1").unwrap();
556        // Beta 1.1.0 considered newer than stable 1.0.0
557        assert!(beta > stable);
558    }
559
560    /// Test 5: Channel filtering - stable only
561    #[test]
562    fn test_stable_channel_filters_beta() {
563        let monitor = UpgradeMonitor::new(
564            "dirvine/saorsa-node".to_string(),
565            UpgradeChannel::Stable,
566            24,
567        );
568
569        let beta_version = Version::parse("1.0.0-beta.1").unwrap();
570        assert!(!monitor.version_matches_channel(&beta_version));
571
572        let stable_version = Version::parse("1.0.0").unwrap();
573        assert!(monitor.version_matches_channel(&stable_version));
574    }
575
576    /// Test 6: Channel filtering - beta includes beta
577    #[test]
578    fn test_beta_channel_accepts_beta() {
579        let monitor =
580            UpgradeMonitor::new("dirvine/saorsa-node".to_string(), UpgradeChannel::Beta, 24);
581
582        let beta_version = Version::parse("1.0.0-beta.1").unwrap();
583        assert!(monitor.version_matches_channel(&beta_version));
584    }
585
586    /// Test 7: Parse GitHub release response
587    #[test]
588    fn test_parse_github_release() {
589        let json = r#"{
590            "tag_name": "v1.2.0",
591            "name": "Release 1.2.0",
592            "body": "Release notes here",
593            "prerelease": false,
594            "assets": [
595                {
596                    "name": "saorsa-node-x86_64-unknown-linux-gnu",
597                    "browser_download_url": "https://example.com/binary"
598                },
599                {
600                    "name": "saorsa-node-x86_64-unknown-linux-gnu.sig",
601                    "browser_download_url": "https://example.com/binary.sig"
602                }
603            ]
604        }"#;
605
606        let release: GitHubRelease = serde_json::from_str(json).unwrap();
607        assert_eq!(release.tag_name, "v1.2.0");
608        assert_eq!(release.name, "Release 1.2.0");
609        assert_eq!(release.body, "Release notes here");
610        assert!(!release.prerelease);
611        assert_eq!(release.assets.len(), 2);
612    }
613
614    /// Test 8: Extract version from tag
615    #[test]
616    fn test_version_from_tag() {
617        assert_eq!(version_from_tag("v1.2.3"), Some(Version::new(1, 2, 3)));
618        assert_eq!(version_from_tag("1.2.3"), Some(Version::new(1, 2, 3)));
619        assert_eq!(
620            version_from_tag("v1.0.0-beta.1"),
621            Some(Version::parse("1.0.0-beta.1").unwrap())
622        );
623        assert_eq!(version_from_tag("invalid"), None);
624        assert_eq!(version_from_tag(""), None);
625    }
626
627    /// Test 9: Find correct asset for platform
628    #[test]
629    fn test_find_platform_asset() {
630        // Test with archive format (CLI releases)
631        let assets = vec![
632            Asset {
633                name: "saorsa-node-cli-linux-x64.tar.gz".to_string(),
634                browser_download_url: "https://example.com/linux".to_string(),
635            },
636            Asset {
637                name: "saorsa-node-cli-linux-x64.tar.gz.sig".to_string(),
638                browser_download_url: "https://example.com/linux.sig".to_string(),
639            },
640            Asset {
641                name: "saorsa-node-cli-macos-arm64.tar.gz".to_string(),
642                browser_download_url: "https://example.com/macos".to_string(),
643            },
644            Asset {
645                name: "saorsa-node-cli-macos-arm64.tar.gz.sig".to_string(),
646                browser_download_url: "https://example.com/macos.sig".to_string(),
647            },
648            Asset {
649                name: "saorsa-node-cli-windows-x64.zip".to_string(),
650                browser_download_url: "https://example.com/windows".to_string(),
651            },
652            Asset {
653                name: "saorsa-node-cli-windows-x64.zip.sig".to_string(),
654                browser_download_url: "https://example.com/windows.sig".to_string(),
655            },
656        ];
657
658        let asset = find_platform_asset(&assets);
659        assert!(asset.is_some(), "Should find platform asset");
660        let asset = asset.unwrap();
661        // Should not be a .sig file
662        assert!(!asset.name.to_lowercase().ends_with(".sig"));
663        // Should be an archive
664        let lower = asset.name.to_lowercase();
665        assert!(
666            lower.ends_with(".tar.gz") || lower.ends_with(".zip"),
667            "Should be an archive format"
668        );
669    }
670
671    /// Test: `is_binary_asset` correctly identifies binaries and archives
672    #[test]
673    fn test_is_binary_asset() {
674        // Archive formats should be identified (CLI releases)
675        assert!(is_binary_asset("saorsa-node-cli-linux-x64.tar.gz"));
676        assert!(is_binary_asset("saorsa-node-cli-macos-arm64.tar.gz"));
677        assert!(is_binary_asset("saorsa-node-cli-windows-x64.zip"));
678
679        // Signature and metadata files should be excluded
680        assert!(!is_binary_asset("saorsa-node.sig"));
681        assert!(!is_binary_asset("saorsa-node.sha256"));
682        assert!(!is_binary_asset("saorsa-node.md5"));
683        assert!(!is_binary_asset("RELEASE_NOTES.txt"));
684        assert!(!is_binary_asset("README.md"));
685
686        // Installer packages should be excluded (handled separately)
687        assert!(!is_binary_asset("saorsa-node.deb"));
688        assert!(!is_binary_asset("saorsa-node.rpm"));
689        assert!(!is_binary_asset("saorsa-node.msi"));
690    }
691
692    /// Test 10: Monitor check interval
693    #[test]
694    fn test_check_interval() {
695        let monitor = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 24);
696        assert_eq!(monitor.check_interval(), Duration::from_secs(24 * 3600));
697
698        let monitor2 = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 6);
699        assert_eq!(monitor2.check_interval(), Duration::from_secs(6 * 3600));
700    }
701
702    /// Test 11: Process release - upgrade available
703    #[test]
704    fn test_process_release_upgrade_available() {
705        let monitor = UpgradeMonitor::with_version(
706            "test/repo".to_string(),
707            UpgradeChannel::Stable,
708            24,
709            Version::new(1, 0, 0),
710        );
711
712        // Build platform-specific archive name using friendly naming
713        let (friendly_os, archive_ext) = match std::env::consts::OS {
714            "linux" => ("linux", "tar.gz"),
715            "macos" => ("macos", "tar.gz"),
716            "windows" => ("windows", "zip"),
717            _ => ("unknown", "tar.gz"),
718        };
719        let friendly_arch = match std::env::consts::ARCH {
720            "x86_64" => "x64",
721            "aarch64" => "arm64",
722            _ => std::env::consts::ARCH,
723        };
724        let archive_name = format!("saorsa-node-cli-{friendly_os}-{friendly_arch}.{archive_ext}");
725
726        let release = GitHubRelease {
727            tag_name: "v1.1.0".to_string(),
728            name: "Release 1.1.0".to_string(),
729            body: "New features".to_string(),
730            prerelease: false,
731            assets: vec![
732                Asset {
733                    name: archive_name.clone(),
734                    browser_download_url: "https://example.com/binary".to_string(),
735                },
736                Asset {
737                    name: format!("{archive_name}.sig"),
738                    browser_download_url: "https://example.com/binary.sig".to_string(),
739                },
740            ],
741        };
742
743        let result = monitor.process_release(&release);
744        assert!(result.is_some(), "Should find upgrade");
745        let info = result.unwrap();
746        assert_eq!(info.version, Version::new(1, 1, 0));
747        assert_eq!(info.release_notes, "New features");
748    }
749
750    /// Test 12: Process release - no upgrade (same version)
751    #[test]
752    fn test_process_release_no_upgrade_same_version() {
753        let monitor = UpgradeMonitor::with_version(
754            "test/repo".to_string(),
755            UpgradeChannel::Stable,
756            24,
757            Version::new(1, 0, 0),
758        );
759
760        let release = GitHubRelease {
761            tag_name: "v1.0.0".to_string(),
762            name: "Release 1.0.0".to_string(),
763            body: "Current version".to_string(),
764            prerelease: false,
765            assets: vec![],
766        };
767
768        let result = monitor.process_release(&release);
769        assert!(result.is_none(), "Should not find upgrade for same version");
770    }
771
772    /// Test 13: Process release - no upgrade (older version)
773    #[test]
774    fn test_process_release_no_upgrade_older_version() {
775        let monitor = UpgradeMonitor::with_version(
776            "test/repo".to_string(),
777            UpgradeChannel::Stable,
778            24,
779            Version::new(1, 1, 0),
780        );
781
782        let release = GitHubRelease {
783            tag_name: "v1.0.0".to_string(),
784            name: "Release 1.0.0".to_string(),
785            body: "Old version".to_string(),
786            prerelease: false,
787            assets: vec![],
788        };
789
790        let result = monitor.process_release(&release);
791        assert!(
792            result.is_none(),
793            "Should not find upgrade for older version"
794        );
795    }
796
797    /// Test 14: Process release - beta filtered by stable channel
798    #[test]
799    fn test_process_release_beta_filtered() {
800        let monitor = UpgradeMonitor::with_version(
801            "test/repo".to_string(),
802            UpgradeChannel::Stable,
803            24,
804            Version::new(1, 0, 0),
805        );
806
807        let release = GitHubRelease {
808            tag_name: "v1.1.0-beta.1".to_string(),
809            name: "Beta Release".to_string(),
810            body: "Beta features".to_string(),
811            prerelease: true,
812            assets: vec![],
813        };
814
815        let result = monitor.process_release(&release);
816        assert!(
817            result.is_none(),
818            "Stable channel should filter beta releases"
819        );
820    }
821
822    /// Test 15: Monitor repo getter
823    #[test]
824    fn test_monitor_repo() {
825        let monitor = UpgradeMonitor::new(
826            "dirvine/saorsa-node".to_string(),
827            UpgradeChannel::Stable,
828            24,
829        );
830        assert_eq!(monitor.repo(), "dirvine/saorsa-node");
831    }
832
833    /// Test 16: Current version getter
834    #[test]
835    fn test_monitor_current_version() {
836        let monitor = UpgradeMonitor::with_version(
837            "test/repo".to_string(),
838            UpgradeChannel::Stable,
839            24,
840            Version::new(2, 3, 4),
841        );
842        assert_eq!(*monitor.current_version(), Version::new(2, 3, 4));
843    }
844
845    /// Test 17: Build platform patterns
846    #[test]
847    fn test_build_platform_patterns() {
848        let patterns = build_platform_patterns("x86_64", "linux");
849        assert!(patterns.iter().any(|p| p.contains("x86_64")));
850        assert!(patterns.iter().any(|p| p.contains("x64")));
851        assert!(patterns.iter().any(|p| p.contains("linux")));
852
853        let patterns_arm = build_platform_patterns("aarch64", "macos");
854        assert!(patterns_arm
855            .iter()
856            .any(|p| p.contains("aarch64") || p.contains("arm64")));
857        assert!(patterns_arm
858            .iter()
859            .any(|p| p.contains("darwin") || p.contains("macos")));
860    }
861
862    /// Test 18: Invalid tag handling
863    #[test]
864    fn test_process_release_invalid_tag() {
865        let monitor = UpgradeMonitor::with_version(
866            "test/repo".to_string(),
867            UpgradeChannel::Stable,
868            24,
869            Version::new(1, 0, 0),
870        );
871
872        let release = GitHubRelease {
873            tag_name: "not-a-version".to_string(),
874            name: "Invalid Release".to_string(),
875            body: "Invalid".to_string(),
876            prerelease: false,
877            assets: vec![],
878        };
879
880        let result = monitor.process_release(&release);
881        assert!(result.is_none(), "Should gracefully handle invalid tag");
882    }
883
884    #[test]
885    fn test_select_upgrade_stable_ignores_prerelease() {
886        let current = Version::new(1, 0, 0);
887        let arch = std::env::consts::ARCH;
888        let os = std::env::consts::OS;
889        // On Windows, binary assets require .exe extension
890        #[cfg(windows)]
891        let bin_name = format!("saorsa-node-{arch}-{os}.exe");
892        #[cfg(not(windows))]
893        let bin_name = format!("saorsa-node-{arch}-{os}");
894        let releases = vec![
895            GitHubRelease {
896                tag_name: "v1.1.0-beta.1".to_string(),
897                name: "beta".to_string(),
898                body: "beta".to_string(),
899                prerelease: true,
900                assets: vec![
901                    Asset {
902                        name: bin_name.clone(),
903                        browser_download_url: "https://example.com/beta".to_string(),
904                    },
905                    Asset {
906                        name: format!("{bin_name}.sig"),
907                        browser_download_url: "https://example.com/beta.sig".to_string(),
908                    },
909                ],
910            },
911            GitHubRelease {
912                tag_name: "v1.1.0".to_string(),
913                name: "stable".to_string(),
914                body: "stable".to_string(),
915                prerelease: false,
916                assets: vec![
917                    Asset {
918                        name: bin_name.clone(),
919                        browser_download_url: "https://example.com/stable".to_string(),
920                    },
921                    Asset {
922                        name: format!("{bin_name}.sig"),
923                        browser_download_url: "https://example.com/stable.sig".to_string(),
924                    },
925                ],
926            },
927        ];
928
929        let upgrade =
930            select_upgrade_from_releases(&releases, &current, UpgradeChannel::Stable).unwrap();
931        assert_eq!(upgrade.version, Version::new(1, 1, 0));
932        assert!(upgrade.download_url.contains("stable"));
933    }
934
935    #[test]
936    fn test_select_upgrade_beta_accepts_prerelease_if_newest() {
937        let current = Version::new(1, 0, 0);
938        let arch = std::env::consts::ARCH;
939        let os = std::env::consts::OS;
940        // On Windows, binary assets require .exe extension
941        #[cfg(windows)]
942        let bin_name = format!("saorsa-node-{arch}-{os}.exe");
943        #[cfg(not(windows))]
944        let bin_name = format!("saorsa-node-{arch}-{os}");
945        let releases = vec![
946            GitHubRelease {
947                tag_name: "v1.1.0".to_string(),
948                name: "stable".to_string(),
949                body: "stable".to_string(),
950                prerelease: false,
951                assets: vec![
952                    Asset {
953                        name: bin_name.clone(),
954                        browser_download_url: "https://example.com/stable".to_string(),
955                    },
956                    Asset {
957                        name: format!("{bin_name}.sig"),
958                        browser_download_url: "https://example.com/stable.sig".to_string(),
959                    },
960                ],
961            },
962            GitHubRelease {
963                tag_name: "v1.2.0-beta.1".to_string(),
964                name: "beta".to_string(),
965                body: "beta".to_string(),
966                prerelease: true,
967                assets: vec![
968                    Asset {
969                        name: bin_name.clone(),
970                        browser_download_url: "https://example.com/beta".to_string(),
971                    },
972                    Asset {
973                        name: format!("{bin_name}.sig"),
974                        browser_download_url: "https://example.com/beta.sig".to_string(),
975                    },
976                ],
977            },
978        ];
979
980        let upgrade =
981            select_upgrade_from_releases(&releases, &current, UpgradeChannel::Beta).unwrap();
982        assert_eq!(upgrade.version, Version::parse("1.2.0-beta.1").unwrap());
983        assert!(upgrade.download_url.contains("beta"));
984    }
985}