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