Skip to main content

rtb_app/
metadata.rs

1//! Static, build-time tool metadata.
2
3use serde::{Deserialize, Serialize};
4
5/// Release-source descriptor. Drives the `version` and `update`
6/// subcommands — `rtb-vcs` resolves this into a concrete
7/// `ReleaseProvider`.
8///
9/// `host` fields default (to `github.com` / `gitlab.com`) so minimal
10/// configs round-trip cleanly.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
13#[non_exhaustive]
14pub enum ReleaseSource {
15    /// A GitHub or GitHub-Enterprise-hosted release source.
16    Github {
17        /// Repository owner (user or organisation).
18        owner: String,
19        /// Repository name.
20        repo: String,
21        /// API host; `github.com` for public GitHub, otherwise the
22        /// Enterprise host (`github.example.com`).
23        #[serde(default = "default_github_host")]
24        host: String,
25    },
26    /// A GitLab or self-hosted GitLab release source.
27    Gitlab {
28        /// Fully-qualified project path, e.g. `myorg/group/subgroup/project`.
29        project: String,
30        /// API host; `gitlab.com` for public GitLab.
31        #[serde(default = "default_gitlab_host")]
32        host: String,
33    },
34    /// A Bitbucket Cloud or Bitbucket Data Center release source.
35    Bitbucket {
36        /// Workspace (Cloud) or project key (Data Center).
37        workspace: String,
38        /// Repository slug.
39        repo_slug: String,
40        /// API host; defaults to `api.bitbucket.org/2.0` for Cloud.
41        #[serde(default = "default_bitbucket_host")]
42        host: String,
43    },
44    /// A self-hosted Gitea release source.
45    Gitea {
46        /// Repository owner.
47        owner: String,
48        /// Repository name.
49        repo: String,
50        /// API host — required (no public default).
51        host: String,
52    },
53    /// Codeberg — a hosted Gitea instance at `codeberg.org`. Distinct
54    /// variant rather than a Gitea alias for config-layer clarity.
55    Codeberg {
56        /// Repository owner.
57        owner: String,
58        /// Repository name.
59        repo: String,
60    },
61    /// Direct HTTP release source (e.g. S3 bucket, CDN).
62    Direct {
63        /// URL template, e.g. `https://dist.example.com/{tool}/{version}/{asset}`.
64        url_template: String,
65    },
66}
67
68fn default_github_host() -> String {
69    "github.com".into()
70}
71
72fn default_gitlab_host() -> String {
73    "gitlab.com".into()
74}
75
76fn default_bitbucket_host() -> String {
77    "api.bitbucket.org/2.0".into()
78}
79
80/// Static tool metadata set at construction time.
81///
82/// Use the [`bon::Builder`] interface — `name` and `summary` are
83/// required at compile time; missing either is a compile error.
84#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
85#[serde(deny_unknown_fields)]
86#[builder(on(String, into))]
87pub struct ToolMetadata {
88    /// Human- and machine-facing tool name (`mytool`).
89    pub name: String,
90
91    /// One-line summary used in `--help` and the CLI banner.
92    pub summary: String,
93
94    /// Long-form description shown under `--help`.
95    #[serde(default)]
96    #[builder(default)]
97    pub description: String,
98
99    /// Optional release source — required iff `Feature::Update` is
100    /// runtime-enabled.
101    #[serde(default)]
102    pub release_source: Option<ReleaseSource>,
103
104    /// Optional credential reference for the release source. Resolved
105    /// at update time via `rtb_credentials::Resolver`. Public repos
106    /// don't need it; private repos require this OR a fallback env
107    /// var the resolver knows about.
108    ///
109    /// Deserialised from config files but never serialised back —
110    /// `CredentialRef` contains a `SecretString` literal that should
111    /// not round-trip through `Serialize` (the `secrecy` crate
112    /// removed the `Serialize` impl on purpose, mirroring the
113    /// "secrets don't cross untyped boundaries" rule).
114    #[serde(default, skip_serializing)]
115    pub release_credential: Option<rtb_credentials::CredentialRef>,
116
117    /// Support channel advertised in error diagnostic footers.
118    #[serde(default)]
119    #[builder(default)]
120    pub help: HelpChannel,
121
122    /// Ed25519 public keys trusted for verifying release-asset
123    /// signatures. Multiple keys enable rotation without breaking
124    /// already-deployed binaries — a release signed by a rotated-in
125    /// key verifies against any binary whose trusted list includes
126    /// that key. Empty means `rtb-update` refuses to run (see
127    /// `UpdateError::NoPublicKey`). Not serialised — keys are
128    /// compile-time constants, not config-file values.
129    #[serde(skip)]
130    #[builder(default)]
131    pub update_public_keys: Vec<[u8; 32]>,
132
133    /// Optional asset name listing SHA-256 checksums for this
134    /// release. `rtb-update` downloads it alongside the binary and
135    /// cross-checks the binary's hash before swap. When `None`,
136    /// signature verification is the only integrity gate.
137    #[serde(skip)]
138    pub update_checksums_asset: Option<&'static str>,
139
140    /// Asset-name template `rtb-update` uses to select the right
141    /// artefact for the running host. Default:
142    /// `{name}-{version}-{target}{ext}`. Placeholders:
143    /// `{name}` → [`ToolMetadata::name`], `{version}` → release tag
144    /// (leading `v` stripped), `{target}` → Rust host triple,
145    /// `{ext}` → `.tar.gz` on Unix / `.zip` on Windows. Tools with
146    /// a different naming convention set this explicitly.
147    #[serde(skip)]
148    pub update_asset_pattern: Option<&'static str>,
149
150    /// Privacy notice printed by `rtb-cli`'s v0.4 `telemetry enable`
151    /// subcommand. `None` falls back to a generic message. Lives on
152    /// `ToolMetadata` rather than `rtb-telemetry` so the subcommand
153    /// can read it without depending on the telemetry crate.
154    #[serde(skip)]
155    pub telemetry_notice: Option<&'static str>,
156}
157
158/// User-support channel advertised in error output.
159///
160/// `rtb-cli::Application::run` reads this off `ToolMetadata`, formats
161/// via [`HelpChannel::footer`], and installs the result into
162/// `rtb_error::hook::install_with_footer` so every diagnostic ends
163/// with a consistent support pointer.
164#[derive(Debug, Clone, Default, Serialize, Deserialize)]
165#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
166#[non_exhaustive]
167pub enum HelpChannel {
168    /// No support footer.
169    #[default]
170    None,
171    /// Slack channel reference.
172    Slack {
173        /// Slack workspace / team name.
174        team: String,
175        /// Channel name without the `#`.
176        channel: String,
177    },
178    /// Microsoft Teams channel reference.
179    Teams {
180        /// Team name.
181        team: String,
182        /// Channel name.
183        channel: String,
184    },
185    /// Arbitrary support URL (status page, docs, contact form).
186    Url {
187        /// The URL to advertise verbatim.
188        url: String,
189    },
190}
191
192impl HelpChannel {
193    /// The one-line footer shown under error diagnostics.
194    ///
195    /// Returns `None` when the channel is [`HelpChannel::None`] —
196    /// `install_with_footer` treats `None`/empty as "no footer".
197    #[must_use]
198    pub fn footer(&self) -> Option<String> {
199        match self {
200            Self::None => None,
201            Self::Slack { team, channel } => Some(format!("support: slack #{channel} (in {team})")),
202            Self::Teams { team, channel } => Some(format!("support: Teams → {team} / {channel}")),
203            Self::Url { url } => Some(format!("support: {url}")),
204        }
205    }
206}