Skip to main content

rtb_update/
asset.rs

1//! Asset selection for the running host.
2//!
3//! # Grammar
4//!
5//! The default asset-name pattern is
6//! `{name}-{version}-{target}{ext}`. Tool authors override via
7//! [`rtb_app::metadata::ToolMetadata::update_asset_pattern`].
8//!
9//! Placeholders:
10//!
11//! | Placeholder | Value |
12//! | --- | --- |
13//! | `{name}` | [`rtb_app::metadata::ToolMetadata::name`] |
14//! | `{version}` | Release tag with any leading `v` stripped |
15//! | `{target}` | Rust host triple (e.g. `x86_64-unknown-linux-gnu`) |
16//! | `{os}` | `linux`, `macos`, `windows` |
17//! | `{arch}` | `x86_64`, `aarch64` |
18//! | `{ext}` | `.tar.gz` on Unix / `.zip` on Windows |
19//!
20//! All occurrences of each placeholder are replaced. Unknown
21//! placeholders pass through verbatim — useful when a tool author
22//! embeds release-pipeline-specific substitutions we haven't
23//! anticipated yet.
24
25use rtb_forge::{Release, ReleaseAsset};
26
27use crate::error::UpdateError;
28
29/// Default pattern — matches the release-pipeline conventions we
30/// recommend to tool authors.
31pub const DEFAULT_PATTERN: &str = "{name}-{version}-{target}{ext}";
32
33/// Render the asset pattern with host + release substitutions.
34#[must_use]
35#[allow(clippy::literal_string_with_formatting_args)]
36pub fn render_pattern(pattern: &str, name: &str, version: &str) -> String {
37    let (os, arch, target, ext) = host_substitutions();
38    let version_no_v = version.strip_prefix('v').unwrap_or(version);
39    pattern
40        .replace("{name}", name)
41        .replace("{version}", version_no_v)
42        .replace("{target}", target)
43        .replace("{os}", os)
44        .replace("{arch}", arch)
45        .replace("{ext}", ext)
46}
47
48/// Return `(os, arch, target_triple, ext)` for the running binary.
49///
50/// Unknown OS/arch combinations produce an empty `target` string, which
51/// surfaces as `{target}` staying literal in the rendered URL — an
52/// unmistakable signal for the tool author to set
53/// `ToolMetadata::update_asset_pattern` explicitly.
54#[must_use]
55pub const fn host_substitutions() -> (&'static str, &'static str, &'static str, &'static str) {
56    let os = std::env::consts::OS;
57    let arch = std::env::consts::ARCH;
58    let target = match (os.as_bytes(), arch.as_bytes()) {
59        (b"linux", b"x86_64") => "x86_64-unknown-linux-gnu",
60        (b"linux", b"aarch64") => "aarch64-unknown-linux-gnu",
61        (b"macos", b"x86_64") => "x86_64-apple-darwin",
62        (b"macos", b"aarch64") => "aarch64-apple-darwin",
63        (b"windows", b"x86_64") => "x86_64-pc-windows-msvc",
64        (b"windows", b"aarch64") => "aarch64-pc-windows-msvc",
65        _ => "",
66    };
67    let ext = match os.as_bytes() {
68        b"windows" => ".zip",
69        _ => ".tar.gz",
70    };
71    (os, arch, target, ext)
72}
73
74/// Pick the asset on `release` whose name matches `expected`.
75///
76/// Falls back to a case-insensitive match on `{name}-{version}` prefix
77/// if no exact-match asset is found — useful when release-pipelines
78/// emit slightly varying filenames (`.tgz` vs `.tar.gz`).
79///
80/// # Errors
81///
82/// [`UpdateError::NoMatchingAsset`] when nothing matches.
83pub fn pick_asset<'a>(
84    release: &'a Release,
85    expected: &str,
86) -> crate::error::Result<&'a ReleaseAsset> {
87    if let Some(exact) = release.assets.iter().find(|a| a.name == expected) {
88        return Ok(exact);
89    }
90    // Fuzzy: case-insensitive match on the pattern's prefix up to the
91    // first `{ext}` separator. This lets `.tgz` match a pattern ending
92    // in `.tar.gz` and vice versa.
93    let expected_lower = expected.to_ascii_lowercase();
94    if let Some(fuzzy) = release.assets.iter().find(|a| {
95        let name = a.name.to_ascii_lowercase();
96        name.starts_with(&expected_lower[..prefix_len(&expected_lower)])
97    }) {
98        return Ok(fuzzy);
99    }
100    Err(UpdateError::NoMatchingAsset { target: expected.to_string() })
101}
102
103fn prefix_len(name: &str) -> usize {
104    // Match up to the ext delimiter — `.tar.gz` / `.zip`. Fallback:
105    // whole string.
106    for sep in [".tar.gz", ".zip", ".tgz"] {
107        if let Some(idx) = name.find(sep) {
108            return idx;
109        }
110    }
111    name.len()
112}
113
114/// Find the signature file for `asset` on `release`. Preference:
115/// `.minisig` over `.sig` (minisign format carries more context when
116/// things go wrong). Returns `None` if neither is present.
117#[must_use]
118pub fn pick_signature<'a>(release: &'a Release, asset: &ReleaseAsset) -> Option<&'a ReleaseAsset> {
119    let minisig_name = format!("{}.minisig", asset.name);
120    if let Some(a) = release.assets.iter().find(|a| a.name == minisig_name) {
121        return Some(a);
122    }
123    let sig_name = format!("{}.sig", asset.name);
124    release.assets.iter().find(|a| a.name == sig_name)
125}