Skip to main content

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}