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}