Skip to main content

romm_api/update/
mod.rs

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/// Distribution component for GitHub releases and self-update.
20#[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/// Frontend crate version and distribution component for self-update.
76#[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(&current_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, &current_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(), &current_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, &current_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}