1use 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#[derive(Debug, Deserialize)]
22pub struct GitHubRelease {
23 pub tag_name: String,
25 pub name: String,
27 pub body: String,
29 pub prerelease: bool,
31 pub assets: Vec<Asset>,
33}
34
35#[derive(Debug, Deserialize, Clone)]
37pub struct Asset {
38 pub name: String,
40 pub browser_download_url: String,
42}
43
44pub struct UpgradeMonitor {
46 repo: String,
48 channel: UpgradeChannel,
50 check_interval: Duration,
52 current_version: Version,
54 client: reqwest::Client,
56 staged_rollout: Option<StagedRollout>,
58 release_cache: Option<ReleaseCache>,
60 pending_upgrade_detected: Option<Instant>,
62 pending_upgrade_version: Option<Version>,
64}
65
66impl UpgradeMonitor {
67 #[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 #[must_use]
107 pub fn with_release_cache(mut self, cache: ReleaseCache) -> Self {
108 self.release_cache = Some(cache);
109 self
110 }
111
112 #[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 #[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 #[must_use]
160 pub fn check_interval(&self) -> Duration {
161 self.check_interval
162 }
163
164 #[must_use]
166 pub fn current_version(&self) -> &Version {
167 &self.current_version
168 }
169
170 #[must_use]
172 pub fn repo(&self) -> &str {
173 &self.repo
174 }
175
176 #[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, }
186 }
187
188 pub async fn check_for_updates(&self) -> Result<Option<UpgradeInfo>> {
198 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 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 let releases = self.fetch_releases_from_api().await?;
237
238 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 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 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 self.pending_upgrade_detected = None;
277 self.pending_upgrade_version = None;
278 return Ok(None);
279 };
280
281 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 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 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 let Some(detected_at) = self.pending_upgrade_detected else {
319 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 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 #[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 #[must_use]
392 pub fn has_staged_rollout(&self) -> bool {
393 self.staged_rollout.is_some()
394 }
395
396 #[must_use]
398 pub fn pending_version(&self) -> Option<&Version> {
399 self.pending_upgrade_version.as_ref()
400 }
401
402 #[allow(dead_code)]
404 fn process_release(&self, release: &GitHubRelease) -> Option<UpgradeInfo> {
405 let latest_version = version_from_tag(&release.tag_name)?;
406
407 if latest_version <= self.current_version {
409 debug!("Current version {} is up to date", self.current_version);
410 return None;
411 }
412
413 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 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
443fn 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#[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#[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 let patterns = build_platform_patterns(arch, os);
517
518 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#[allow(clippy::case_sensitive_file_extension_comparisons)]
536fn is_binary_asset(name: &str) -> bool {
537 let lower = name.to_lowercase();
538
539 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 if lower.ends_with(".tar.gz") || lower.ends_with(".zip") {
554 return true;
555 }
556
557 #[cfg(windows)]
559 if !lower.ends_with(".exe") {
560 return false;
561 }
562
563 true
564}
565
566fn build_platform_patterns(arch: &str, os: &str) -> Vec<String> {
568 let mut patterns = Vec::new();
569
570 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 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 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 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]
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]
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]
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]
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 assert!(beta > stable);
642 }
643
644 #[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]
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]
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]
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]
716 fn test_find_platform_asset() {
717 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 assert!(!asset.name.to_lowercase().ends_with(".sig"));
750 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]
760 fn test_is_binary_asset() {
761 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 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 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]
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]
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 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]
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]
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]
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]
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]
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]
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]
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 #[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, ¤t, 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 #[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, ¤t, 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}