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 LEGACY_TAG_PREFIX: &str = "v";
18const CHECKSUMS_ASSET_NAME: &str = "checksums.txt";
19
20/// Distribution component for GitHub releases and self-update.
21#[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/// Frontend crate version and distribution component for self-update.
77#[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(&current_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, &current_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(), &current_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, &current_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}