1use 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#[derive(Debug, Deserialize)]
21pub struct GitHubRelease {
22 pub tag_name: String,
24 pub name: String,
26 pub body: String,
28 pub prerelease: bool,
30 pub assets: Vec<Asset>,
32}
33
34#[derive(Debug, Deserialize, Clone)]
36pub struct Asset {
37 pub name: String,
39 pub browser_download_url: String,
41}
42
43pub struct UpgradeMonitor {
45 repo: String,
47 channel: UpgradeChannel,
49 check_interval: Duration,
51 current_version: Version,
53 client: reqwest::Client,
55 staged_rollout: Option<StagedRollout>,
57 pending_upgrade_detected: Option<Instant>,
59 pending_upgrade_version: Option<Version>,
61}
62
63impl UpgradeMonitor {
64 #[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 #[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 #[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 #[must_use]
144 pub fn check_interval(&self) -> Duration {
145 self.check_interval
146 }
147
148 #[must_use]
150 pub fn current_version(&self) -> &Version {
151 &self.current_version
152 }
153
154 #[must_use]
156 pub fn repo(&self) -> &str {
157 &self.repo
158 }
159
160 #[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, }
170 }
171
172 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 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 self.pending_upgrade_detected = None;
231 self.pending_upgrade_version = None;
232 return Ok(None);
233 };
234
235 let Some(ref rollout) = self.staged_rollout else {
237 return Ok(Some(info));
238 };
239
240 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 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 let Some(detected_at) = self.pending_upgrade_detected else {
262 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 #[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 #[must_use]
309 pub fn has_staged_rollout(&self) -> bool {
310 self.staged_rollout.is_some()
311 }
312
313 #[must_use]
315 pub fn pending_version(&self) -> Option<&Version> {
316 self.pending_upgrade_version.as_ref()
317 }
318
319 #[allow(dead_code)]
321 fn process_release(&self, release: &GitHubRelease) -> Option<UpgradeInfo> {
322 let latest_version = version_from_tag(&release.tag_name)?;
323
324 if latest_version <= self.current_version {
326 debug!("Current version {} is up to date", self.current_version);
327 return None;
328 }
329
330 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 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
359fn 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#[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#[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 let patterns = build_platform_patterns(arch, os);
433
434 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#[allow(clippy::case_sensitive_file_extension_comparisons)]
452fn is_binary_asset(name: &str) -> bool {
453 let lower = name.to_lowercase();
454
455 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 if lower.ends_with(".tar.gz") || lower.ends_with(".zip") {
470 return true;
471 }
472
473 #[cfg(windows)]
475 if !lower.ends_with(".exe") {
476 return false;
477 }
478
479 true
480}
481
482fn build_platform_patterns(arch: &str, os: &str) -> Vec<String> {
484 let mut patterns = Vec::new();
485
486 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 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 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 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]
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]
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]
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]
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 assert!(beta > stable);
558 }
559
560 #[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]
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]
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]
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]
629 fn test_find_platform_asset() {
630 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 assert!(!asset.name.to_lowercase().ends_with(".sig"));
663 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]
673 fn test_is_binary_asset() {
674 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 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 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]
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]
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 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]
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]
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]
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]
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]
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]
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]
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 #[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, ¤t, 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 #[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, ¤t, 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}