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}