1use anyhow::{anyhow, bail, Context, Result};
2use self_update::update::ReleaseUpdate;
3use self_update::Extract;
4use serde::Deserialize;
5use sha2::{Digest, Sha256};
6use std::collections::HashMap;
7use std::env::consts::EXE_SUFFIX;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use tokio::io::AsyncWriteExt;
11
12use crate::core::interrupt::{cancelled_error, InterruptContext};
13
14const REPO_OWNER: &str = "patricksmill";
15const REPO_NAME: &str = "romm-cli";
16const DEFAULT_BIN_NAME: &str = "romm-cli";
17const CHECKSUMS_ASSET_NAME: &str = "checksums.txt";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ReleaseComponent {
22 RommCli,
23 RommTui,
24}
25
26impl ReleaseComponent {
27 pub fn from_binary_stem(stem: &str) -> Self {
28 if stem == "romm-tui" {
29 Self::RommTui
30 } else {
31 Self::RommCli
32 }
33 }
34
35 pub fn tag_prefix(self) -> &'static str {
36 match self {
37 Self::RommCli => "romm-cli-v",
38 Self::RommTui => "romm-tui-v",
39 }
40 }
41
42 pub fn archive_prefix(self) -> &'static str {
43 match self {
44 Self::RommCli => "romm-cli",
45 Self::RommTui => "romm-tui",
46 }
47 }
48
49 pub fn shipped_binaries(self) -> &'static [&'static str] {
50 match self {
51 Self::RommCli => &["romm-cli"],
52 Self::RommTui => &["romm-tui"],
53 }
54 }
55
56 pub fn changelog_url(self) -> &'static str {
57 match self {
58 Self::RommCli => {
59 "https://github.com/patricksmill/romm-cli/blob/main/romm-cli/CHANGELOG.md"
60 }
61 Self::RommTui => {
62 "https://github.com/patricksmill/romm-cli/blob/main/romm-tui/CHANGELOG.md"
63 }
64 }
65 }
66
67 pub fn user_agent_prefix(self) -> &'static str {
68 match self {
69 Self::RommCli => "romm-cli",
70 Self::RommTui => "romm-tui",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy)]
77pub struct UpdateContext {
78 pub component: ReleaseComponent,
79 pub package_version: &'static str,
80}
81
82impl UpdateContext {
83 pub fn for_running_binary(package_version: &'static str) -> Self {
84 Self {
85 component: ReleaseComponent::from_binary_stem(¤t_binary_name()),
86 package_version,
87 }
88 }
89}
90
91#[derive(Debug, Clone)]
92pub struct UpdateStatus {
93 pub current_version: String,
94 pub latest_version: String,
95 pub release_tag: String,
96 pub should_update: bool,
97 pub release_url: String,
98 pub changelog_url: String,
99}
100
101#[derive(Debug, Clone)]
102pub struct ApplyUpdateOptions {
103 pub show_progress: bool,
104 pub show_output: bool,
105 pub no_confirm: bool,
106 pub target_version_tag: Option<String>,
107}
108
109impl Default for ApplyUpdateOptions {
110 fn default() -> Self {
111 Self {
112 show_progress: false,
113 show_output: false,
114 no_confirm: true,
115 target_version_tag: None,
116 }
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ApplyUpdateOutcome {
122 Updated(String),
123 UpToDate(String),
124}
125
126#[derive(Debug, Deserialize)]
127struct GithubRelease {
128 tag_name: String,
129 html_url: String,
130}
131
132#[derive(Debug, Clone)]
133struct ResolvedRelease {
134 version: String,
135 archive_name: String,
136 archive_download_url: String,
137 checksums_download_url: String,
138}
139
140pub fn github_api_base_url() -> String {
141 std::env::var("ROMM_GITHUB_API_BASE").unwrap_or_else(|_| "https://api.github.com".to_string())
142}
143
144fn github_releases_list_api_url() -> String {
145 format!(
146 "{}/repos/{}/{}/releases?per_page=100",
147 github_api_base_url(),
148 REPO_OWNER,
149 REPO_NAME
150 )
151}
152
153pub fn github_release_asset_key() -> Result<&'static str> {
154 match (std::env::consts::OS, std::env::consts::ARCH) {
155 ("macos", "x86_64") => Ok("macos-x86_64"),
156 ("macos", "aarch64") => Ok("macos-aarch64"),
157 ("linux", "x86_64") => Ok("linux-x86_64"),
158 ("linux", "aarch64") => Ok("linux-aarch64"),
159 ("windows", "x86_64") => Ok("windows-x86_64"),
160 (os, arch) => Err(anyhow!("unsupported platform for self-update: {os}-{arch}")),
161 }
162}
163
164fn normalize_version_tag(version: &str) -> &str {
165 version.trim().trim_start_matches('v')
166}
167
168fn version_from_tag(tag: &str, component: ReleaseComponent) -> String {
169 let prefix = component.tag_prefix();
170 if let Some(rest) = tag.strip_prefix(prefix) {
171 return rest.to_string();
172 }
173 tag.to_string()
174}
175
176fn is_latest_newer(latest: &str, current: &str) -> bool {
177 self_update::version::bump_is_greater(
178 normalize_version_tag(current),
179 normalize_version_tag(latest),
180 )
181 .unwrap_or(false)
182}
183
184pub fn changelog_url_for(component: ReleaseComponent) -> &'static str {
185 component.changelog_url()
186}
187
188pub fn open_url_in_browser(url: &str) -> Result<()> {
189 #[cfg(target_os = "windows")]
190 {
191 Command::new("cmd")
192 .args(["/C", "start", "", url])
193 .spawn()
194 .context("failed to launch browser via start")?;
195 return Ok(());
196 }
197
198 #[cfg(target_os = "macos")]
199 {
200 Command::new("open")
201 .arg(url)
202 .spawn()
203 .context("failed to launch browser via open")?;
204 return Ok(());
205 }
206
207 #[cfg(all(unix, not(target_os = "macos")))]
208 {
209 Command::new("xdg-open")
210 .arg(url)
211 .spawn()
212 .context("failed to launch browser via xdg-open")?;
213 return Ok(());
214 }
215
216 #[allow(unreachable_code)]
217 Err(anyhow!("unsupported OS for opening browser"))
218}
219
220pub fn open_changelog_in_browser(component: ReleaseComponent) -> Result<()> {
221 open_url_in_browser(changelog_url_for(component))
222}
223
224fn binary_name_from_path(path: &Path) -> Option<String> {
225 let raw = path.as_os_str().to_string_lossy();
226 raw.rsplit(['/', '\\'])
227 .next()
228 .map(|name| {
229 name.strip_suffix(".exe")
230 .or_else(|| name.strip_suffix(".EXE"))
231 .unwrap_or(name)
232 .to_string()
233 })
234 .filter(|name| !name.is_empty())
235}
236
237fn current_binary_name() -> String {
238 std::env::current_exe()
239 .ok()
240 .and_then(|path| binary_name_from_path(&path))
241 .unwrap_or_else(|| DEFAULT_BIN_NAME.to_string())
242}
243
244fn shipped_binary_file_name(stem: &str) -> String {
245 format!("{stem}{EXE_SUFFIX}")
246}
247
248fn expected_archive_name(component: ReleaseComponent, target: &str) -> String {
249 let ext = if std::env::consts::OS == "windows" {
250 "zip"
251 } else {
252 "tar.gz"
253 };
254 format!("{}-{}.{}", component.archive_prefix(), target, ext)
255}
256
257fn tag_matches_component(tag: &str, component: ReleaseComponent) -> bool {
258 tag.starts_with(component.tag_prefix())
259}
260
261pub fn select_latest_release_tag<'a>(
262 component: ReleaseComponent,
263 tags: impl IntoIterator<Item = &'a str>,
264) -> Option<String> {
265 let mut best: Option<(String, String)> = None;
266 for tag in tags {
267 if !tag_matches_component(tag, component) {
268 continue;
269 }
270 let version = version_from_tag(tag, component);
271 let replace = match &best {
272 None => true,
273 Some((_, current_best)) => is_latest_newer(&version, current_best),
274 };
275 if replace {
276 best = Some((tag.to_string(), version));
277 }
278 }
279 best.map(|(tag, _)| tag)
280}
281
282fn build_release_updater(
283 ctx: UpdateContext,
284 options: &ApplyUpdateOptions,
285) -> Result<Box<dyn ReleaseUpdate>> {
286 let target = github_release_asset_key()?;
287 let bin_name = current_binary_name();
288 let mut builder = self_update::backends::github::Update::configure();
289 builder
290 .repo_owner(REPO_OWNER)
291 .repo_name(REPO_NAME)
292 .bin_name(&bin_name)
293 .target(target)
294 .identifier(ctx.component.archive_prefix())
295 .current_version(ctx.package_version)
296 .with_url(&github_api_base_url())
297 .show_download_progress(false)
298 .show_output(options.show_output)
299 .no_confirm(options.no_confirm);
300
301 if let Some(ref tag) = options.target_version_tag {
302 builder.target_version_tag(tag);
303 }
304
305 builder
306 .build()
307 .map_err(|e| anyhow!("build self_update config: {e}"))
308}
309
310async fn fetch_github_releases(user_agent: &str) -> Result<Vec<GithubRelease>> {
311 let api_url = std::env::var("ROMM_GITHUB_RELEASES_API").unwrap_or_else(|_| {
312 if let Ok(single) = std::env::var("ROMM_GITHUB_LATEST_RELEASE_API") {
313 if single.contains("/releases/latest") {
314 return github_releases_list_api_url();
315 }
316 }
317 github_releases_list_api_url()
318 });
319
320 let response = reqwest::Client::new()
321 .get(api_url)
322 .header(reqwest::header::USER_AGENT, user_agent)
323 .send()
324 .await
325 .context("failed to query GitHub releases")?
326 .error_for_status()
327 .context("GitHub releases endpoint returned an error status")?;
328
329 response
330 .json()
331 .await
332 .context("failed to parse GitHub releases response")
333}
334
335async fn resolve_latest_component_release(ctx: UpdateContext) -> Result<Option<GithubRelease>> {
336 let user_agent = format!(
337 "{}/{}",
338 ctx.component.user_agent_prefix(),
339 ctx.package_version
340 );
341 let releases = fetch_github_releases(&user_agent).await?;
342 let tag = select_latest_release_tag(
343 ctx.component,
344 releases.iter().map(|release| release.tag_name.as_str()),
345 );
346 Ok(tag.and_then(|tag_name| {
347 releases
348 .into_iter()
349 .find(|release| release.tag_name == tag_name)
350 }))
351}
352
353fn resolve_release(
354 ctx: UpdateContext,
355 options: &ApplyUpdateOptions,
356) -> Result<Option<ResolvedRelease>> {
357 let current_version = ctx.package_version.to_string();
358 let target = github_release_asset_key()?;
359 let updater = build_release_updater(ctx, options)?;
360
361 let release = if let Some(ref tag) = options.target_version_tag {
362 updater.get_release_version(tag)?
363 } else {
364 let rt = tokio::runtime::Handle::try_current()
365 .map_err(|_| anyhow!("resolve_release requires a Tokio runtime"))?;
366 let latest = rt.block_on(resolve_latest_component_release(ctx))?;
367 let Some(latest) = latest else {
368 return Ok(None);
369 };
370 let version = version_from_tag(&latest.tag_name, ctx.component);
371 if !is_latest_newer(&version, ¤t_version) {
372 return Ok(None);
373 }
374 updater.get_release_version(&latest.tag_name)?
375 };
376
377 let expected_name = expected_archive_name(ctx.component, target);
378 let archive_prefix = format!("{}-", ctx.component.archive_prefix());
379 let archive = release
380 .assets
381 .iter()
382 .find(|asset| asset.name == expected_name)
383 .or_else(|| {
384 release
385 .assets
386 .iter()
387 .find(|asset| asset.name.starts_with(&archive_prefix))
388 })
389 .ok_or_else(|| {
390 anyhow!("no release asset found for target `{target}` (expected `{expected_name}`)")
391 })?;
392
393 let checksums_download_url = release
394 .assets
395 .iter()
396 .find(|asset| asset.name == CHECKSUMS_ASSET_NAME)
397 .ok_or_else(|| anyhow!("release is missing `{CHECKSUMS_ASSET_NAME}` asset"))?
398 .download_url
399 .clone();
400
401 Ok(Some(ResolvedRelease {
402 version: release.version,
403 archive_name: archive.name.clone(),
404 archive_download_url: archive.download_url.clone(),
405 checksums_download_url,
406 }))
407}
408
409fn parse_checksums(content: &str) -> HashMap<String, String> {
410 let mut out = HashMap::new();
411 for line in content.lines() {
412 let line = line.trim();
413 if line.is_empty() {
414 continue;
415 }
416 let Some((hash, name)) = line.split_once(" ") else {
417 continue;
418 };
419 let name = name.trim_start_matches('*').trim();
420 out.insert(name.to_string(), hash.to_lowercase());
421 }
422 out
423}
424
425fn sha256_hex_file(path: &Path) -> Result<String> {
426 use std::io::Read;
427 let mut file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
428 let mut hasher = Sha256::new();
429 let mut buffer = [0u8; 8192];
430 loop {
431 let read = file.read(&mut buffer).context("read file for sha256")?;
432 if read == 0 {
433 break;
434 }
435 hasher.update(&buffer[..read]);
436 }
437 Ok(hasher
438 .finalize()
439 .iter()
440 .map(|byte| format!("{byte:02x}"))
441 .collect())
442}
443
444fn verify_archive_checksum(
445 archive_path: &Path,
446 archive_name: &str,
447 checksums_content: &str,
448) -> Result<()> {
449 let checksums = parse_checksums(checksums_content);
450 let expected = checksums
451 .get(archive_name)
452 .ok_or_else(|| anyhow!("checksums.txt has no entry for `{archive_name}`"))?;
453 let actual = sha256_hex_file(archive_path)?;
454 if &actual != expected {
455 bail!("checksum mismatch for `{archive_name}`: expected {expected}, got {actual}");
456 }
457 Ok(())
458}
459
460fn github_asset_headers(user_agent: &str) -> reqwest::header::HeaderMap {
461 let mut headers = reqwest::header::HeaderMap::new();
462 headers.insert(
463 reqwest::header::USER_AGENT,
464 reqwest::header::HeaderValue::from_str(user_agent)
465 .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("romm-cli")),
466 );
467 headers.insert(
468 reqwest::header::ACCEPT,
469 reqwest::header::HeaderValue::from_static("application/octet-stream"),
470 );
471 headers
472}
473
474async fn download_url_to_file(
475 client: &reqwest::Client,
476 url: &str,
477 dest: &Path,
478 user_agent: &str,
479 interrupt: &InterruptContext,
480 show_progress: bool,
481) -> Result<()> {
482 if interrupt.is_cancelled() {
483 return Err(cancelled_error().into());
484 }
485
486 let response = client
487 .get(url)
488 .headers(github_asset_headers(user_agent))
489 .send()
490 .await
491 .with_context(|| format!("download request failed for {url}"))?
492 .error_for_status()
493 .with_context(|| format!("download returned error status for {url}"))?;
494
495 let total = response.content_length();
496 let mut file = tokio::fs::File::create(dest)
497 .await
498 .with_context(|| format!("create {}", dest.display()))?;
499
500 let progress = if show_progress {
501 total.map(|len| {
502 let pb = indicatif::ProgressBar::new(len);
503 pb.set_style(
504 indicatif::ProgressStyle::default_bar()
505 .template("{wide_bar} {bytes}/{total_bytes}")
506 .expect("progress template"),
507 );
508 pb
509 })
510 } else {
511 None
512 };
513
514 let mut downloaded = 0u64;
515 let mut response = response;
516 while let Some(chunk) = response.chunk().await.context("read download chunk")? {
517 if interrupt.is_cancelled() {
518 return Err(cancelled_error().into());
519 }
520 file.write_all(&chunk)
521 .await
522 .context("write download chunk")?;
523 downloaded += chunk.len() as u64;
524 if let Some(ref pb) = progress {
525 pb.set_position(downloaded);
526 }
527 }
528
529 if let Some(pb) = progress {
530 pb.finish_and_clear();
531 }
532
533 Ok(())
534}
535
536fn install_extracted_binaries(
537 extract_dir: &Path,
538 running_bin_stem: &str,
539 component: ReleaseComponent,
540) -> Result<()> {
541 let current_exe = std::env::current_exe().context("resolve current executable path")?;
542 let install_dir = current_exe
543 .parent()
544 .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
545
546 let mut running_source = None;
547
548 for stem in component.shipped_binaries() {
549 let file_name = shipped_binary_file_name(stem);
550 let source = extract_dir.join(&file_name);
551 if !source.is_file() {
552 continue;
553 }
554
555 let dest = install_dir.join(&file_name);
556 if stem == &running_bin_stem {
557 running_source = Some(source);
558 continue;
559 }
560
561 std::fs::copy(&source, &dest).with_context(|| {
562 format!(
563 "copy sibling binary `{}` to `{}`",
564 source.display(),
565 dest.display()
566 )
567 })?;
568 if let Ok(meta) = std::fs::metadata(&source) {
569 let _ = std::fs::set_permissions(&dest, meta.permissions());
570 }
571 }
572
573 let Some(new_running) = running_source else {
574 bail!("extracted archive did not contain `{running_bin_stem}`");
575 };
576
577 self_update::self_replace::self_replace(new_running).context("replace running executable")?;
578
579 Ok(())
580}
581
582fn install_from_archive(
583 archive_path: &Path,
584 archive_name: &str,
585 checksums_content: &str,
586 component: ReleaseComponent,
587) -> Result<()> {
588 verify_archive_checksum(archive_path, archive_name, checksums_content)?;
589
590 let extract_dir = self_update::TempDir::new().context("create temp extract dir")?;
591 Extract::from_source(archive_path)
592 .extract_into(extract_dir.path())
593 .with_context(|| format!("extract `{archive_name}`"))?;
594
595 install_extracted_binaries(extract_dir.path(), ¤t_binary_name(), component)?;
596 Ok(())
597}
598
599pub async fn check_for_update(ctx: UpdateContext) -> Result<UpdateStatus> {
600 let current_version = ctx.package_version.to_string();
601
602 let latest_release = resolve_latest_component_release(ctx)
603 .await
604 .context("failed to query component releases")?;
605
606 let Some(latest_release) = latest_release else {
607 return Ok(UpdateStatus {
608 should_update: false,
609 current_version: current_version.clone(),
610 latest_version: current_version,
611 release_tag: String::new(),
612 release_url: String::new(),
613 changelog_url: changelog_url_for(ctx.component).to_string(),
614 });
615 };
616
617 let release_tag = latest_release.tag_name.clone();
618 let latest_version = version_from_tag(&release_tag, ctx.component);
619 Ok(UpdateStatus {
620 should_update: is_latest_newer(&latest_version, ¤t_version),
621 current_version,
622 latest_version,
623 release_tag,
624 release_url: latest_release.html_url,
625 changelog_url: changelog_url_for(ctx.component).to_string(),
626 })
627}
628
629pub async fn apply_update(
630 interrupt: Option<InterruptContext>,
631 options: ApplyUpdateOptions,
632 ctx: UpdateContext,
633) -> Result<ApplyUpdateOutcome> {
634 let interrupt = interrupt.unwrap_or_default();
635 let current_version = ctx.package_version.to_string();
636 let user_agent = format!("{}/{}", ctx.component.user_agent_prefix(), current_version);
637
638 let resolved = tokio::task::spawn_blocking({
639 let options = options.clone();
640 move || resolve_release(ctx, &options)
641 })
642 .await
643 .map_err(|e| anyhow!("update resolve task failed: {e}"))??;
644
645 let Some(resolved) = resolved else {
646 return Ok(ApplyUpdateOutcome::UpToDate(current_version));
647 };
648
649 let archive_dir = self_update::TempDir::new().context("create temp download dir")?;
650 let archive_path: PathBuf = archive_dir.path().join(&resolved.archive_name);
651
652 let client = reqwest::Client::new();
653
654 if interrupt.is_cancelled() {
655 return Err(cancelled_error().into());
656 }
657 let checksums_content = client
658 .get(&resolved.checksums_download_url)
659 .headers(github_asset_headers(&user_agent))
660 .send()
661 .await
662 .context("download checksums.txt")?
663 .error_for_status()
664 .context("checksums.txt request failed")?
665 .text()
666 .await
667 .context("read checksums.txt")?;
668
669 download_url_to_file(
670 &client,
671 &resolved.archive_download_url,
672 &archive_path,
673 &user_agent,
674 &interrupt,
675 options.show_progress,
676 )
677 .await?;
678
679 let version = resolved.version.clone();
680 let archive_name = resolved.archive_name.clone();
681 let component = ctx.component;
682 let install_task = tokio::task::spawn_blocking(move || {
683 install_from_archive(&archive_path, &archive_name, &checksums_content, component)
684 .map(|_| version)
685 });
686
687 let installed_version = tokio::select! {
688 out = install_task => out
689 .map_err(|e| anyhow!("update install task failed: {e}"))??,
690 _ = interrupt.cancelled() => return Err(cancelled_error().into()),
691 };
692
693 Ok(ApplyUpdateOutcome::Updated(installed_version))
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn version_compare_handles_patch_and_minor() {
702 assert!(is_latest_newer("0.25.1", "0.25.0"));
703 assert!(is_latest_newer("0.26.0", "0.25.9"));
704 assert!(!is_latest_newer("0.25.0", "0.25.0"));
705 assert!(!is_latest_newer("0.24.9", "0.25.0"));
706 }
707
708 #[test]
709 fn version_compare_handles_v_prefix() {
710 assert!(is_latest_newer("v1.2.4", "1.2.3"));
711 }
712
713 #[test]
714 fn version_compare_handles_prerelease_to_stable() {
715 assert!(is_latest_newer("0.25.0", "0.25.0-alpha"));
716 }
717
718 #[test]
719 fn parse_checksums_reads_sha256sum_format() {
720 let parsed = parse_checksums("abc123 romm-cli-linux-x86_64.tar.gz\n");
721 assert_eq!(
722 parsed.get("romm-cli-linux-x86_64.tar.gz"),
723 Some(&"abc123".to_string())
724 );
725 }
726
727 #[test]
728 fn verify_archive_checksum_matches() {
729 let dir = self_update::TempDir::new().expect("tempdir");
730 let path = dir.path().join("sample.tar.gz");
731 std::fs::write(&path, b"hello").expect("write sample");
732 let digest = sha256_hex_file(&path).expect("hash");
733 let checksums = format!("{digest} sample.tar.gz\n");
734 verify_archive_checksum(&path, "sample.tar.gz", &checksums).expect("verify");
735 }
736
737 #[test]
738 fn verify_archive_checksum_rejects_mismatch() {
739 let dir = self_update::TempDir::new().expect("tempdir");
740 let path = dir.path().join("sample.tar.gz");
741 std::fs::write(&path, b"hello").expect("write sample");
742 let checksums = "deadbeef sample.tar.gz\n";
743 assert!(verify_archive_checksum(&path, "sample.tar.gz", checksums).is_err());
744 }
745
746 #[test]
747 fn binary_name_from_path_strips_windows_exe_extension() {
748 assert_eq!(
749 binary_name_from_path(Path::new(r"C:\tools\romm-tui.exe")).as_deref(),
750 Some("romm-tui")
751 );
752 }
753
754 #[test]
755 fn current_binary_name_is_available() {
756 assert!(!current_binary_name().is_empty());
757 }
758
759 #[test]
760 fn github_release_asset_key_supports_windows() {
761 if std::env::consts::OS == "windows" && std::env::consts::ARCH == "x86_64" {
762 assert_eq!(
763 github_release_asset_key().expect("target"),
764 "windows-x86_64"
765 );
766 }
767 }
768
769 #[test]
770 fn select_latest_component_tag_prefers_component_prefix() {
771 let tags = ["romm-cli-v0.40.0", "romm-cli-v0.41.0", "romm-tui-v0.99.0"];
772 assert_eq!(
773 select_latest_release_tag(ReleaseComponent::RommCli, tags.iter().copied()),
774 Some("romm-cli-v0.41.0".to_string())
775 );
776 }
777
778 #[test]
779 fn select_latest_component_tag_for_tui_ignores_cli_tags() {
780 let tags = ["romm-cli-v0.50.0", "romm-tui-v0.40.0", "romm-tui-v0.41.0"];
781 assert_eq!(
782 select_latest_release_tag(ReleaseComponent::RommTui, tags.iter().copied()),
783 Some("romm-tui-v0.41.0".to_string())
784 );
785 }
786
787 #[test]
788 fn version_from_component_tag_strips_prefix() {
789 assert_eq!(
790 version_from_tag("romm-cli-v1.2.3", ReleaseComponent::RommCli),
791 "1.2.3"
792 );
793 assert_eq!(
794 version_from_tag("romm-tui-v2.0.0", ReleaseComponent::RommTui),
795 "2.0.0"
796 );
797 }
798
799 #[test]
800 fn expected_archive_name_matches_release_workflow() {
801 let (target, ext) = if std::env::consts::OS == "windows" {
802 ("windows-x86_64", "zip")
803 } else {
804 ("linux-x86_64", "tar.gz")
805 };
806 assert_eq!(
807 expected_archive_name(ReleaseComponent::RommCli, target),
808 format!("romm-cli-{target}.{ext}")
809 );
810 assert_eq!(
811 expected_archive_name(ReleaseComponent::RommTui, target),
812 format!("romm-tui-{target}.{ext}")
813 );
814 }
815}