rtb_vcs/release.rs
1//! The `ReleaseProvider` trait, value types, error enum, and
2//! factory-registration primitives.
3//!
4//! Backends register a factory via `linkme::distributed_slice`, which
5//! is resolved at link time — no runtime `init()` ceremony. Downstream
6//! tools that want a custom backend declare their own
7//! `RegisteredProvider` and link against this crate.
8
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use secrecy::SecretString;
13use tokio::io::AsyncRead;
14
15use crate::config::ReleaseSourceConfig;
16
17// ---------------------------------------------------------------------
18// Value types
19// ---------------------------------------------------------------------
20
21/// A single release, as observed from a source.
22///
23/// `tag` is preserved verbatim — callers who need a `semver::Version`
24/// parse it themselves via [`semver::Version::parse`]. The crate does
25/// not reject non-semver tags, because GitHub / GitLab / Gitea repos
26/// regularly mix semver with dated tags like `2026-04-23`.
27#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
28#[non_exhaustive]
29pub struct Release {
30 /// Human-readable name as the host displayed it (`GitHub "Release v1.2.3"`).
31 pub name: String,
32
33 /// The Git tag this release targets.
34 pub tag: String,
35
36 /// Markdown release-notes body. May be empty.
37 #[serde(default)]
38 pub body: String,
39
40 /// `true` when the release has not yet been published (GitHub / Gitea).
41 #[serde(default)]
42 pub draft: bool,
43
44 /// `true` when the tag carries a pre-release marker (`-alpha`, `-rc.1`).
45 #[serde(default)]
46 pub prerelease: bool,
47
48 /// When the host created the release entry.
49 #[serde(with = "time::serde::rfc3339")]
50 pub created_at: time::OffsetDateTime,
51
52 /// When the host made the release publicly visible. May be `None`
53 /// for drafts and for hosts that don't distinguish the two events.
54 #[serde(default, with = "time::serde::rfc3339::option")]
55 pub published_at: Option<time::OffsetDateTime>,
56
57 /// Downloadable assets attached to this release.
58 #[serde(default)]
59 pub assets: Vec<ReleaseAsset>,
60}
61
62impl Release {
63 /// Construct a minimal `Release`. `#[non_exhaustive]` prevents
64 /// struct-literal construction from outside the crate; this
65 /// constructor keeps the contract explicit for mock backends and
66 /// downstream tests. Optional fields default to "empty".
67 #[must_use]
68 pub fn new(
69 name: impl Into<String>,
70 tag: impl Into<String>,
71 created_at: time::OffsetDateTime,
72 ) -> Self {
73 Self {
74 name: name.into(),
75 tag: tag.into(),
76 body: String::new(),
77 draft: false,
78 prerelease: false,
79 created_at,
80 published_at: None,
81 assets: Vec::new(),
82 }
83 }
84}
85
86/// A single downloadable artefact attached to a [`Release`].
87///
88/// `id` is a string rather than `u64` because backends vary:
89/// GitHub/GitLab surface numeric IDs, Bitbucket and Direct use path-
90/// shaped identifiers. One allocation per asset is negligible against
91/// the flexibility gain.
92#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
93#[non_exhaustive]
94pub struct ReleaseAsset {
95 /// Provider-native identifier. Stable for the lifetime of the asset.
96 pub id: String,
97
98 /// Filename (not the full URL). Includes extension: `rtb-0.2.0-x86_64-unknown-linux-gnu.tar.gz`.
99 pub name: String,
100
101 /// Size in bytes. `0` when the host doesn't expose it.
102 #[serde(default)]
103 pub size: u64,
104
105 /// MIME type, when reported by the host.
106 #[serde(default)]
107 pub content_type: Option<String>,
108
109 /// Fully-qualified HTTPS URL the asset is downloadable from.
110 pub download_url: String,
111}
112
113impl ReleaseAsset {
114 /// Construct a minimal `ReleaseAsset`. Mirrors [`Release::new`]
115 /// in providing an explicit constructor around the
116 /// `#[non_exhaustive]` type.
117 #[must_use]
118 pub fn new(
119 id: impl Into<String>,
120 name: impl Into<String>,
121 download_url: impl Into<String>,
122 ) -> Self {
123 Self {
124 id: id.into(),
125 name: name.into(),
126 size: 0,
127 content_type: None,
128 download_url: download_url.into(),
129 }
130 }
131}
132
133// ---------------------------------------------------------------------
134// Error type
135// ---------------------------------------------------------------------
136
137/// Every failure mode a [`ReleaseProvider`] can surface.
138///
139/// `Clone` is derived so callers can route error values through retry
140/// policies or stash them in rendered diagnostics without losing the
141/// underlying `io::Error`. The `Io` variant wraps in `Arc` for that
142/// reason — the same pattern used in `rtb-credentials::CredentialError`.
143#[derive(Debug, thiserror::Error, miette::Diagnostic, Clone)]
144#[non_exhaustive]
145pub enum ProviderError {
146 /// Release or asset does not exist, or the caller lacks permission
147 /// to see it (drafts for unauthenticated callers map here).
148 #[error("release or asset not found: {what}")]
149 #[diagnostic(code(rtb::vcs::not_found))]
150 NotFound {
151 /// Describes what was missing: a tag name, an asset filename,
152 /// or a repo coordinate.
153 what: String,
154 },
155
156 /// The host rejected our credentials.
157 #[error("authentication failed for {host}")]
158 #[diagnostic(
159 code(rtb::vcs::unauthorized),
160 help("check the credential registered for this release source")
161 )]
162 Unauthorized {
163 /// The host that issued the 401.
164 host: String,
165 },
166
167 /// The host rate-limited us. `retry_after` is populated when the
168 /// response carried a `Retry-After` header or equivalent.
169 #[error("rate limited by {host}; retry after {retry_after:?}")]
170 #[diagnostic(code(rtb::vcs::rate_limited))]
171 RateLimited {
172 /// The host that issued the rate-limit response.
173 host: String,
174 /// Duration the host asked us to wait before retrying.
175 /// `None` when the response didn't carry a `Retry-After`.
176 retry_after: Option<std::time::Duration>,
177 },
178
179 /// The request didn't reach the server, or the response never
180 /// completed.
181 #[error("network transport error: {0}")]
182 #[diagnostic(code(rtb::vcs::transport))]
183 Transport(String),
184
185 /// The server replied with a body we couldn't parse into the
186 /// expected shape.
187 #[error("response body could not be parsed: {0}")]
188 #[diagnostic(code(rtb::vcs::malformed_response))]
189 MalformedResponse(String),
190
191 /// The call is not applicable to this backend. Bitbucket Cloud
192 /// surfaces this for `list_releases` — there is no native listing
193 /// endpoint and synthesising one by walking tags defeats the point
194 /// of the abstraction.
195 #[error("operation is not supported by this provider")]
196 #[diagnostic(
197 code(rtb::vcs::unsupported),
198 help("Bitbucket Cloud lacks a native list-releases endpoint; use latest_release or release_by_tag"),
199 )]
200 Unsupported,
201
202 /// Factory-time validation failed — invalid host, missing required
203 /// params, HTTP-only URL where HTTPS is mandatory, etc.
204 #[error("provider configuration is invalid: {0}")]
205 #[diagnostic(code(rtb::vcs::invalid_config))]
206 InvalidConfig(String),
207
208 /// Wrapped `std::io::Error` — `Arc` keeps the enum `Clone` without
209 /// losing the underlying kind.
210 #[error("I/O error: {0}")]
211 #[diagnostic(code(rtb::vcs::io))]
212 Io(#[from] Arc<std::io::Error>),
213}
214
215impl From<std::io::Error> for ProviderError {
216 fn from(err: std::io::Error) -> Self {
217 Self::Io(Arc::new(err))
218 }
219}
220
221// ---------------------------------------------------------------------
222// The trait
223// ---------------------------------------------------------------------
224
225/// Read-only release-source abstraction.
226///
227/// Consumers hold `Arc<dyn ReleaseProvider>`. Implementations are
228/// registered at link time via [`RELEASE_PROVIDERS`] and resolved via
229/// [`lookup`].
230///
231/// Every method is `async` so network I/O doesn't block the caller's
232/// runtime. The trait is dyn-safe via `async-trait`; consider native
233/// `async fn in trait` again in v0.3 once the ecosystem settles on a
234/// dyn-safety story (see the v0.1 spec § 8 O2).
235#[async_trait]
236pub trait ReleaseProvider: Send + Sync + 'static {
237 /// Fetch metadata for the repository's latest non-draft, non-
238 /// prerelease release.
239 async fn latest_release(&self) -> Result<Release, ProviderError>;
240
241 /// Fetch metadata for a specific tag. Returns
242 /// [`ProviderError::NotFound`] when the tag does not exist or is a
243 /// draft release the caller lacks permission to see.
244 async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError>;
245
246 /// List up to `limit` most-recent releases, newest first. Includes
247 /// prereleases (caller filters) but excludes drafts for
248 /// unauthenticated callers.
249 async fn list_releases(&self, limit: usize) -> Result<Vec<Release>, ProviderError>;
250
251 /// Stream an asset's bytes.
252 ///
253 /// Returns an `AsyncRead` reader plus the content-length the host
254 /// reported (when available, otherwise `0` — the caller should not
255 /// rely on this value to size allocations).
256 async fn download_asset(
257 &self,
258 asset: &ReleaseAsset,
259 ) -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError>;
260}
261
262// ---------------------------------------------------------------------
263// Registry
264// ---------------------------------------------------------------------
265
266/// Signature of a provider factory. Accepts a parsed
267/// [`ReleaseSourceConfig`] plus an optional bearer token and returns
268/// an `Arc<dyn ReleaseProvider>` (or a validation error).
269pub type ProviderFactory = fn(
270 cfg: &ReleaseSourceConfig,
271 token: Option<SecretString>,
272) -> Result<Arc<dyn ReleaseProvider>, ProviderError>;
273
274/// A single registered backend. Each built-in and downstream-custom
275/// backend contributes one of these to [`RELEASE_PROVIDERS`].
276#[derive(Clone, Copy)]
277pub struct RegisteredProvider {
278 /// The `source_type` discriminator used in YAML / TOML config.
279 /// Lowercase, no spaces. Core built-ins are `github`, `gitlab`,
280 /// `bitbucket`, `gitea`, `codeberg`, `direct`.
281 pub source_type: &'static str,
282
283 /// Factory that builds an instance of this backend.
284 pub factory: ProviderFactory,
285}
286
287impl std::fmt::Debug for RegisteredProvider {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 f.debug_struct("RegisteredProvider")
290 .field("source_type", &self.source_type)
291 .finish_non_exhaustive()
292 }
293}
294
295impl ProviderRegistration for RegisteredProvider {
296 fn source_type(&self) -> &'static str {
297 self.source_type
298 }
299 fn factory(&self) -> ProviderFactory {
300 self.factory
301 }
302}
303
304/// Trait implemented by each registered provider's registration hook.
305///
306/// The indirection exists so [`RELEASE_PROVIDERS`] can be a slice of
307/// `fn() -> Box<dyn ProviderRegistration>` — matching the
308/// `rtb_app::BUILTIN_COMMANDS` convention (`fn() -> Box<dyn Command>`).
309/// Struct-valued distributed slices trip the `link_section` lint
310/// under crate-level `#![forbid(unsafe_code)]` on Rust 1.95+;
311/// boxed trait objects don't.
312pub trait ProviderRegistration: Send + Sync + 'static {
313 /// The discriminator (`github`, `gitlab`, `custom`, …) the
314 /// factory was registered under.
315 fn source_type(&self) -> &'static str;
316 /// The factory that constructs a provider for this backend.
317 fn factory(&self) -> ProviderFactory;
318}
319
320/// Distributed slice of built-in and downstream-custom providers.
321///
322/// Entries are `fn() -> Box<dyn ProviderRegistration>`. A new backend
323/// registers by writing a small type that implements
324/// [`ProviderRegistration`] and a `fn() -> Box<dyn _>` annotated with
325/// `#[distributed_slice(RELEASE_PROVIDERS)]`. See
326/// `crates/rtb-vcs/src/github.rs` for an example.
327#[linkme::distributed_slice]
328pub static RELEASE_PROVIDERS: [fn() -> Box<dyn ProviderRegistration>];
329
330/// Return the [`ProviderFactory`] for `source_type`, or `None` if no
331/// backend has registered for that discriminator.
332///
333/// This function walks [`RELEASE_PROVIDERS`] linearly. The slice is
334/// small (≤ 10 entries in practice); the cost is negligible compared
335/// to the network round-trip that follows.
336#[must_use]
337pub fn lookup(source_type: &str) -> Option<ProviderFactory> {
338 RELEASE_PROVIDERS
339 .iter()
340 .map(|make| make())
341 .find(|r| r.source_type() == source_type)
342 .map(|r| r.factory())
343}
344
345/// Return a sorted, de-duplicated list of registered `source_type`
346/// discriminators. Used to generate user-facing diagnostics when the
347/// caller specified an unknown backend.
348#[must_use]
349pub fn registered_types() -> Vec<&'static str> {
350 let mut out: Vec<&'static str> =
351 RELEASE_PROVIDERS.iter().map(|make| make().source_type()).collect();
352 out.sort_unstable();
353 out.dedup();
354 out
355}