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 = %info.version,
254 delay_hours = delay.as_secs() / 3600,
255 delay_minutes = (delay.as_secs() % 3600) / 60,
256 "New version detected, staged rollout delay calculated"
257 );
258 }
259
260 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 version = %info.version,
273 "Staged rollout delay elapsed, ready to upgrade"
274 );
275 Ok(Some(info))
276 } else {
277 let remaining = delay.saturating_sub(elapsed);
278 debug!(
279 "Staged rollout: {}h {}m remaining before upgrade to {}",
280 remaining.as_secs() / 3600,
281 (remaining.as_secs() % 3600) / 60,
282 info.version
283 );
284 Ok(None)
285 }
286 }
287
288 #[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 current_version = %self.current_version,
347 new_version = %latest_version,
348 "New version available"
349 );
350
351 Some(UpgradeInfo {
352 version: latest_version,
353 download_url: binary_asset.browser_download_url.clone(),
354 signature_url: sig_asset.browser_download_url.clone(),
355 release_notes: release.body.clone(),
356 })
357 }
358}
359
360fn select_upgrade_from_releases(
367 releases: &[GitHubRelease],
368 current_version: &Version,
369 channel: UpgradeChannel,
370) -> Option<UpgradeInfo> {
371 let mut best: Option<UpgradeInfo> = None;
372
373 for release in releases {
374 let Some(version) = version_from_tag(&release.tag_name) else {
375 continue;
376 };
377
378 if version <= *current_version {
379 continue;
380 }
381
382 if channel == UpgradeChannel::Stable && !version.pre.is_empty() {
383 continue;
384 }
385
386 let Some(binary_asset) = find_platform_asset(&release.assets) else {
387 continue;
388 };
389
390 let sig_name = format!("{}.sig", binary_asset.name);
391 let Some(sig_asset) = release.assets.iter().find(|a| a.name == sig_name) else {
392 continue;
393 };
394
395 let candidate = UpgradeInfo {
396 version: version.clone(),
397 download_url: binary_asset.browser_download_url.clone(),
398 signature_url: sig_asset.browser_download_url.clone(),
399 release_notes: release.body.clone(),
400 };
401
402 let should_replace = best
403 .as_ref()
404 .map_or(true, |b| candidate.version > b.version);
405
406 if should_replace {
407 best = Some(candidate);
408 }
409 }
410
411 best
412}
413
414#[must_use]
418pub fn version_from_tag(tag: &str) -> Option<Version> {
419 let version_str = tag.strip_prefix('v').unwrap_or(tag);
420 Version::parse(version_str).ok()
421}
422
423#[must_use]
428pub fn find_platform_asset(assets: &[Asset]) -> Option<&Asset> {
429 let arch = std::env::consts::ARCH;
430 let os = std::env::consts::OS;
431
432 let patterns = build_platform_patterns(arch, os);
434
435 for pattern in &patterns {
437 if let Some(asset) = assets
438 .iter()
439 .find(|a| a.name.contains(pattern) && is_binary_asset(&a.name))
440 {
441 return Some(asset);
442 }
443 }
444
445 None
446}
447
448#[allow(clippy::case_sensitive_file_extension_comparisons)]
453fn is_binary_asset(name: &str) -> bool {
454 let lower = name.to_lowercase();
455
456 if lower.ends_with(".sig")
458 || lower.ends_with(".sha256")
459 || lower.ends_with(".md5")
460 || lower.ends_with(".txt")
461 || lower.ends_with(".md")
462 || lower.ends_with(".deb")
463 || lower.ends_with(".rpm")
464 || lower.ends_with(".msi")
465 {
466 return false;
467 }
468
469 if lower.ends_with(".tar.gz") || lower.ends_with(".zip") {
471 return true;
472 }
473
474 #[cfg(windows)]
476 if !lower.ends_with(".exe") {
477 return false;
478 }
479
480 true
481}
482
483fn build_platform_patterns(arch: &str, os: &str) -> Vec<String> {
485 let mut patterns = Vec::new();
486
487 let arch_patterns: Vec<&str> = match arch {
489 "x86_64" => vec!["x86_64", "amd64", "x64"],
490 "aarch64" => vec!["aarch64", "arm64"],
491 "x86" => vec!["i686", "i386", "x86"],
492 _ => vec![arch],
493 };
494
495 let os_patterns: Vec<&str> = match os {
497 "linux" => vec!["linux", "unknown-linux-gnu", "linux-gnu"],
498 "macos" => vec!["darwin", "macos", "apple-darwin"],
499 "windows" => vec!["windows", "pc-windows-msvc", "win64"],
500 _ => vec![os],
501 };
502
503 for arch_pat in &arch_patterns {
505 for os_pat in &os_patterns {
506 patterns.push(format!("{arch_pat}-{os_pat}"));
507 patterns.push(format!("{os_pat}-{arch_pat}"));
508 }
509 }
510
511 for arch_pat in &arch_patterns {
513 patterns.push((*arch_pat).to_string());
514 }
515
516 patterns
517}
518
519#[cfg(test)]
520#[allow(
521 clippy::unwrap_used,
522 clippy::expect_used,
523 clippy::case_sensitive_file_extension_comparisons
524)]
525mod tests {
526 use super::*;
527
528 #[test]
530 fn test_version_newer_available() {
531 let current = Version::new(1, 0, 0);
532 let latest = Version::new(1, 1, 0);
533 assert!(latest > current);
534 }
535
536 #[test]
538 fn test_version_same() {
539 let current = Version::new(1, 0, 0);
540 let latest = Version::new(1, 0, 0);
541 assert!(latest <= current);
542 }
543
544 #[test]
546 fn test_version_older_rejected() {
547 let current = Version::new(1, 1, 0);
548 let latest = Version::new(1, 0, 0);
549 assert!(latest <= current);
550 }
551
552 #[test]
554 fn test_prerelease_version() {
555 let stable = Version::parse("1.0.0").unwrap();
556 let beta = Version::parse("1.1.0-beta.1").unwrap();
557 assert!(beta > stable);
559 }
560
561 #[test]
563 fn test_stable_channel_filters_beta() {
564 let monitor = UpgradeMonitor::new(
565 "dirvine/saorsa-node".to_string(),
566 UpgradeChannel::Stable,
567 24,
568 );
569
570 let beta_version = Version::parse("1.0.0-beta.1").unwrap();
571 assert!(!monitor.version_matches_channel(&beta_version));
572
573 let stable_version = Version::parse("1.0.0").unwrap();
574 assert!(monitor.version_matches_channel(&stable_version));
575 }
576
577 #[test]
579 fn test_beta_channel_accepts_beta() {
580 let monitor =
581 UpgradeMonitor::new("dirvine/saorsa-node".to_string(), UpgradeChannel::Beta, 24);
582
583 let beta_version = Version::parse("1.0.0-beta.1").unwrap();
584 assert!(monitor.version_matches_channel(&beta_version));
585 }
586
587 #[test]
589 fn test_parse_github_release() {
590 let json = r#"{
591 "tag_name": "v1.2.0",
592 "name": "Release 1.2.0",
593 "body": "Release notes here",
594 "prerelease": false,
595 "assets": [
596 {
597 "name": "saorsa-node-x86_64-unknown-linux-gnu",
598 "browser_download_url": "https://example.com/binary"
599 },
600 {
601 "name": "saorsa-node-x86_64-unknown-linux-gnu.sig",
602 "browser_download_url": "https://example.com/binary.sig"
603 }
604 ]
605 }"#;
606
607 let release: GitHubRelease = serde_json::from_str(json).unwrap();
608 assert_eq!(release.tag_name, "v1.2.0");
609 assert_eq!(release.name, "Release 1.2.0");
610 assert_eq!(release.body, "Release notes here");
611 assert!(!release.prerelease);
612 assert_eq!(release.assets.len(), 2);
613 }
614
615 #[test]
617 fn test_version_from_tag() {
618 assert_eq!(version_from_tag("v1.2.3"), Some(Version::new(1, 2, 3)));
619 assert_eq!(version_from_tag("1.2.3"), Some(Version::new(1, 2, 3)));
620 assert_eq!(
621 version_from_tag("v1.0.0-beta.1"),
622 Some(Version::parse("1.0.0-beta.1").unwrap())
623 );
624 assert_eq!(version_from_tag("invalid"), None);
625 assert_eq!(version_from_tag(""), None);
626 }
627
628 #[test]
630 fn test_find_platform_asset() {
631 let assets = vec![
633 Asset {
634 name: "saorsa-node-cli-linux-x64.tar.gz".to_string(),
635 browser_download_url: "https://example.com/linux".to_string(),
636 },
637 Asset {
638 name: "saorsa-node-cli-linux-x64.tar.gz.sig".to_string(),
639 browser_download_url: "https://example.com/linux.sig".to_string(),
640 },
641 Asset {
642 name: "saorsa-node-cli-macos-arm64.tar.gz".to_string(),
643 browser_download_url: "https://example.com/macos".to_string(),
644 },
645 Asset {
646 name: "saorsa-node-cli-macos-arm64.tar.gz.sig".to_string(),
647 browser_download_url: "https://example.com/macos.sig".to_string(),
648 },
649 Asset {
650 name: "saorsa-node-cli-windows-x64.zip".to_string(),
651 browser_download_url: "https://example.com/windows".to_string(),
652 },
653 Asset {
654 name: "saorsa-node-cli-windows-x64.zip.sig".to_string(),
655 browser_download_url: "https://example.com/windows.sig".to_string(),
656 },
657 ];
658
659 let asset = find_platform_asset(&assets);
660 assert!(asset.is_some(), "Should find platform asset");
661 let asset = asset.unwrap();
662 assert!(!asset.name.to_lowercase().ends_with(".sig"));
664 let lower = asset.name.to_lowercase();
666 assert!(
667 lower.ends_with(".tar.gz") || lower.ends_with(".zip"),
668 "Should be an archive format"
669 );
670 }
671
672 #[test]
674 fn test_is_binary_asset() {
675 assert!(is_binary_asset("saorsa-node-cli-linux-x64.tar.gz"));
677 assert!(is_binary_asset("saorsa-node-cli-macos-arm64.tar.gz"));
678 assert!(is_binary_asset("saorsa-node-cli-windows-x64.zip"));
679
680 assert!(!is_binary_asset("saorsa-node.sig"));
682 assert!(!is_binary_asset("saorsa-node.sha256"));
683 assert!(!is_binary_asset("saorsa-node.md5"));
684 assert!(!is_binary_asset("RELEASE_NOTES.txt"));
685 assert!(!is_binary_asset("README.md"));
686
687 assert!(!is_binary_asset("saorsa-node.deb"));
689 assert!(!is_binary_asset("saorsa-node.rpm"));
690 assert!(!is_binary_asset("saorsa-node.msi"));
691 }
692
693 #[test]
695 fn test_check_interval() {
696 let monitor = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 24);
697 assert_eq!(monitor.check_interval(), Duration::from_secs(24 * 3600));
698
699 let monitor2 = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 6);
700 assert_eq!(monitor2.check_interval(), Duration::from_secs(6 * 3600));
701 }
702
703 #[test]
705 fn test_process_release_upgrade_available() {
706 let monitor = UpgradeMonitor::with_version(
707 "test/repo".to_string(),
708 UpgradeChannel::Stable,
709 24,
710 Version::new(1, 0, 0),
711 );
712
713 let (friendly_os, archive_ext) = match std::env::consts::OS {
715 "linux" => ("linux", "tar.gz"),
716 "macos" => ("macos", "tar.gz"),
717 "windows" => ("windows", "zip"),
718 _ => ("unknown", "tar.gz"),
719 };
720 let friendly_arch = match std::env::consts::ARCH {
721 "x86_64" => "x64",
722 "aarch64" => "arm64",
723 _ => std::env::consts::ARCH,
724 };
725 let archive_name = format!("saorsa-node-cli-{friendly_os}-{friendly_arch}.{archive_ext}");
726
727 let release = GitHubRelease {
728 tag_name: "v1.1.0".to_string(),
729 name: "Release 1.1.0".to_string(),
730 body: "New features".to_string(),
731 prerelease: false,
732 assets: vec![
733 Asset {
734 name: archive_name.clone(),
735 browser_download_url: "https://example.com/binary".to_string(),
736 },
737 Asset {
738 name: format!("{archive_name}.sig"),
739 browser_download_url: "https://example.com/binary.sig".to_string(),
740 },
741 ],
742 };
743
744 let result = monitor.process_release(&release);
745 assert!(result.is_some(), "Should find upgrade");
746 let info = result.unwrap();
747 assert_eq!(info.version, Version::new(1, 1, 0));
748 assert_eq!(info.release_notes, "New features");
749 }
750
751 #[test]
753 fn test_process_release_no_upgrade_same_version() {
754 let monitor = UpgradeMonitor::with_version(
755 "test/repo".to_string(),
756 UpgradeChannel::Stable,
757 24,
758 Version::new(1, 0, 0),
759 );
760
761 let release = GitHubRelease {
762 tag_name: "v1.0.0".to_string(),
763 name: "Release 1.0.0".to_string(),
764 body: "Current version".to_string(),
765 prerelease: false,
766 assets: vec![],
767 };
768
769 let result = monitor.process_release(&release);
770 assert!(result.is_none(), "Should not find upgrade for same version");
771 }
772
773 #[test]
775 fn test_process_release_no_upgrade_older_version() {
776 let monitor = UpgradeMonitor::with_version(
777 "test/repo".to_string(),
778 UpgradeChannel::Stable,
779 24,
780 Version::new(1, 1, 0),
781 );
782
783 let release = GitHubRelease {
784 tag_name: "v1.0.0".to_string(),
785 name: "Release 1.0.0".to_string(),
786 body: "Old version".to_string(),
787 prerelease: false,
788 assets: vec![],
789 };
790
791 let result = monitor.process_release(&release);
792 assert!(
793 result.is_none(),
794 "Should not find upgrade for older version"
795 );
796 }
797
798 #[test]
800 fn test_process_release_beta_filtered() {
801 let monitor = UpgradeMonitor::with_version(
802 "test/repo".to_string(),
803 UpgradeChannel::Stable,
804 24,
805 Version::new(1, 0, 0),
806 );
807
808 let release = GitHubRelease {
809 tag_name: "v1.1.0-beta.1".to_string(),
810 name: "Beta Release".to_string(),
811 body: "Beta features".to_string(),
812 prerelease: true,
813 assets: vec![],
814 };
815
816 let result = monitor.process_release(&release);
817 assert!(
818 result.is_none(),
819 "Stable channel should filter beta releases"
820 );
821 }
822
823 #[test]
825 fn test_monitor_repo() {
826 let monitor = UpgradeMonitor::new(
827 "dirvine/saorsa-node".to_string(),
828 UpgradeChannel::Stable,
829 24,
830 );
831 assert_eq!(monitor.repo(), "dirvine/saorsa-node");
832 }
833
834 #[test]
836 fn test_monitor_current_version() {
837 let monitor = UpgradeMonitor::with_version(
838 "test/repo".to_string(),
839 UpgradeChannel::Stable,
840 24,
841 Version::new(2, 3, 4),
842 );
843 assert_eq!(*monitor.current_version(), Version::new(2, 3, 4));
844 }
845
846 #[test]
848 fn test_build_platform_patterns() {
849 let patterns = build_platform_patterns("x86_64", "linux");
850 assert!(patterns.iter().any(|p| p.contains("x86_64")));
851 assert!(patterns.iter().any(|p| p.contains("x64")));
852 assert!(patterns.iter().any(|p| p.contains("linux")));
853
854 let patterns_arm = build_platform_patterns("aarch64", "macos");
855 assert!(patterns_arm
856 .iter()
857 .any(|p| p.contains("aarch64") || p.contains("arm64")));
858 assert!(patterns_arm
859 .iter()
860 .any(|p| p.contains("darwin") || p.contains("macos")));
861 }
862
863 #[test]
865 fn test_process_release_invalid_tag() {
866 let monitor = UpgradeMonitor::with_version(
867 "test/repo".to_string(),
868 UpgradeChannel::Stable,
869 24,
870 Version::new(1, 0, 0),
871 );
872
873 let release = GitHubRelease {
874 tag_name: "not-a-version".to_string(),
875 name: "Invalid Release".to_string(),
876 body: "Invalid".to_string(),
877 prerelease: false,
878 assets: vec![],
879 };
880
881 let result = monitor.process_release(&release);
882 assert!(result.is_none(), "Should gracefully handle invalid tag");
883 }
884
885 #[test]
886 fn test_select_upgrade_stable_ignores_prerelease() {
887 let current = Version::new(1, 0, 0);
888 let arch = std::env::consts::ARCH;
889 let os = std::env::consts::OS;
890 #[cfg(windows)]
892 let bin_name = format!("saorsa-node-{arch}-{os}.exe");
893 #[cfg(not(windows))]
894 let bin_name = format!("saorsa-node-{arch}-{os}");
895 let releases = vec![
896 GitHubRelease {
897 tag_name: "v1.1.0-beta.1".to_string(),
898 name: "beta".to_string(),
899 body: "beta".to_string(),
900 prerelease: true,
901 assets: vec![
902 Asset {
903 name: bin_name.clone(),
904 browser_download_url: "https://example.com/beta".to_string(),
905 },
906 Asset {
907 name: format!("{bin_name}.sig"),
908 browser_download_url: "https://example.com/beta.sig".to_string(),
909 },
910 ],
911 },
912 GitHubRelease {
913 tag_name: "v1.1.0".to_string(),
914 name: "stable".to_string(),
915 body: "stable".to_string(),
916 prerelease: false,
917 assets: vec![
918 Asset {
919 name: bin_name.clone(),
920 browser_download_url: "https://example.com/stable".to_string(),
921 },
922 Asset {
923 name: format!("{bin_name}.sig"),
924 browser_download_url: "https://example.com/stable.sig".to_string(),
925 },
926 ],
927 },
928 ];
929
930 let upgrade =
931 select_upgrade_from_releases(&releases, ¤t, UpgradeChannel::Stable).unwrap();
932 assert_eq!(upgrade.version, Version::new(1, 1, 0));
933 assert!(upgrade.download_url.contains("stable"));
934 }
935
936 #[test]
937 fn test_select_upgrade_beta_accepts_prerelease_if_newest() {
938 let current = Version::new(1, 0, 0);
939 let arch = std::env::consts::ARCH;
940 let os = std::env::consts::OS;
941 #[cfg(windows)]
943 let bin_name = format!("saorsa-node-{arch}-{os}.exe");
944 #[cfg(not(windows))]
945 let bin_name = format!("saorsa-node-{arch}-{os}");
946 let releases = vec![
947 GitHubRelease {
948 tag_name: "v1.1.0".to_string(),
949 name: "stable".to_string(),
950 body: "stable".to_string(),
951 prerelease: false,
952 assets: vec![
953 Asset {
954 name: bin_name.clone(),
955 browser_download_url: "https://example.com/stable".to_string(),
956 },
957 Asset {
958 name: format!("{bin_name}.sig"),
959 browser_download_url: "https://example.com/stable.sig".to_string(),
960 },
961 ],
962 },
963 GitHubRelease {
964 tag_name: "v1.2.0-beta.1".to_string(),
965 name: "beta".to_string(),
966 body: "beta".to_string(),
967 prerelease: true,
968 assets: vec![
969 Asset {
970 name: bin_name.clone(),
971 browser_download_url: "https://example.com/beta".to_string(),
972 },
973 Asset {
974 name: format!("{bin_name}.sig"),
975 browser_download_url: "https://example.com/beta.sig".to_string(),
976 },
977 ],
978 },
979 ];
980
981 let upgrade =
982 select_upgrade_from_releases(&releases, ¤t, UpgradeChannel::Beta).unwrap();
983 assert_eq!(upgrade.version, Version::parse("1.2.0-beta.1").unwrap());
984 assert!(upgrade.download_url.contains("beta"));
985 }
986}