Skip to main content

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