rtb_app/metadata.rs
1//! Static, build-time tool metadata.
2
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7/// Release-source descriptor. Drives the `version` and `update`
8/// subcommands — `rtb-vcs` resolves this into a concrete
9/// `ReleaseProvider`.
10///
11/// `host` fields default (to `github.com` / `gitlab.com`) so minimal
12/// configs round-trip cleanly.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
15#[non_exhaustive]
16pub enum ReleaseSource {
17 /// A GitHub or GitHub-Enterprise-hosted release source.
18 Github {
19 /// Repository owner (user or organisation).
20 owner: String,
21 /// Repository name.
22 repo: String,
23 /// API host; `github.com` for public GitHub, otherwise the
24 /// Enterprise host (`github.example.com`).
25 #[serde(default = "default_github_host")]
26 host: String,
27 },
28 /// A GitLab or self-hosted GitLab release source.
29 Gitlab {
30 /// Fully-qualified project path, e.g. `myorg/group/subgroup/project`.
31 project: String,
32 /// API host; `gitlab.com` for public GitLab.
33 #[serde(default = "default_gitlab_host")]
34 host: String,
35 },
36 /// A Bitbucket Cloud or Bitbucket Data Center release source.
37 Bitbucket {
38 /// Workspace (Cloud) or project key (Data Center).
39 workspace: String,
40 /// Repository slug.
41 repo_slug: String,
42 /// API host; defaults to `api.bitbucket.org/2.0` for Cloud.
43 #[serde(default = "default_bitbucket_host")]
44 host: String,
45 },
46 /// A self-hosted Gitea release source.
47 Gitea {
48 /// Repository owner.
49 owner: String,
50 /// Repository name.
51 repo: String,
52 /// API host — required (no public default).
53 host: String,
54 },
55 /// Codeberg — a hosted Gitea instance at `codeberg.org`. Distinct
56 /// variant rather than a Gitea alias for config-layer clarity.
57 Codeberg {
58 /// Repository owner.
59 owner: String,
60 /// Repository name.
61 repo: String,
62 },
63 /// Direct HTTP release source (e.g. S3 bucket, CDN).
64 Direct {
65 /// URL template, e.g. `https://dist.example.com/{tool}/{version}/{asset}`.
66 url_template: String,
67 },
68}
69
70fn default_github_host() -> String {
71 "github.com".into()
72}
73
74fn default_gitlab_host() -> String {
75 "gitlab.com".into()
76}
77
78fn default_bitbucket_host() -> String {
79 "api.bitbucket.org/2.0".into()
80}
81
82/// Author-set baseline for **synchronous, pre-run** self-update checks.
83///
84/// User-initiated `update` (the subcommand) is always available regardless
85/// of this policy; this only governs automatic checking at the start of a
86/// run. Default [`UpdatePolicy::Disabled`] — tools opt in to automation
87/// explicitly, so there are no unsolicited network calls or pre-run
88/// overhead by default.
89///
90/// See `docs/development/specs/2026-06-23-rtb-update-policy-v0.1.md`.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
92#[serde(rename_all = "lowercase")]
93pub enum UpdatePolicy {
94 /// Never check automatically (default). User-initiated only.
95 #[default]
96 Disabled,
97 /// Check (throttled); on a newer version, prompt the user.
98 Prompt,
99 /// Check (throttled); on a newer version, update before running.
100 Enabled,
101}
102
103/// Default interval between automatic update checks: 24 hours.
104const DEFAULT_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
105
106const fn default_update_check_interval() -> Duration {
107 DEFAULT_UPDATE_CHECK_INTERVAL
108}
109
110/// Static tool metadata set at construction time.
111///
112/// Use the [`bon::Builder`] interface — `name` and `summary` are
113/// required at compile time; missing either is a compile error.
114#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
115#[serde(deny_unknown_fields)]
116#[builder(on(String, into))]
117pub struct ToolMetadata {
118 /// Human- and machine-facing tool name (`mytool`).
119 pub name: String,
120
121 /// One-line summary used in `--help` and the CLI banner.
122 pub summary: String,
123
124 /// Long-form description shown under `--help`.
125 #[serde(default)]
126 #[builder(default)]
127 pub description: String,
128
129 /// Optional release source — required iff `Feature::Update` is
130 /// runtime-enabled.
131 #[serde(default)]
132 pub release_source: Option<ReleaseSource>,
133
134 /// Author-set self-update policy baseline. Default
135 /// [`UpdatePolicy::Disabled`]. In v0.1 the effective policy is this
136 /// baseline (a runtime config override is deferred — see the spec).
137 /// Inert unless `Feature::Update` is enabled and `release_source` is
138 /// set.
139 #[serde(default)]
140 #[builder(default)]
141 pub update_policy: UpdatePolicy,
142
143 /// Minimum interval between automatic update checks (default 24h).
144 /// Only consulted when `update_policy` is `Prompt`/`Enabled`; a check
145 /// within this window of the last is throttled (skipped).
146 #[serde(default = "default_update_check_interval")]
147 #[builder(default = default_update_check_interval())]
148 pub update_check_interval: Duration,
149
150 /// Optional credential reference for the release source. Resolved
151 /// at update time via `rtb_credentials::Resolver`. Public repos
152 /// don't need it; private repos require this OR a fallback env
153 /// var the resolver knows about.
154 ///
155 /// Deserialised from config files but never serialised back —
156 /// `CredentialRef` contains a `SecretString` literal that should
157 /// not round-trip through `Serialize` (the `secrecy` crate
158 /// removed the `Serialize` impl on purpose, mirroring the
159 /// "secrets don't cross untyped boundaries" rule).
160 #[serde(default, skip_serializing)]
161 pub release_credential: Option<rtb_credentials::CredentialRef>,
162
163 /// Support channel advertised in error diagnostic footers.
164 #[serde(default)]
165 #[builder(default)]
166 pub help: HelpChannel,
167
168 /// minisign public keys trusted for verifying release-asset
169 /// signatures, each the base64 string a `minisign.pub` file
170 /// carries — `"RWR…"`, decoding to algorithm tag, key id and the
171 /// 32-byte Ed25519 key.
172 ///
173 /// This is deliberately the *same* string pinned as `pubkey` in a
174 /// crate's `[package.metadata.binstall.signing]` table and printed
175 /// by `sigillum keys minisign`. One canonical representation means
176 /// cargo-binstall and `rtb-update` cannot end up trusting
177 /// different things, and an operator can compare the two by eye.
178 ///
179 /// The key id matters: `rtb-update` verifies via the same
180 /// `minisign-verify` crate cargo-binstall uses, which matches a
181 /// signature's key id against the public key's before checking any
182 /// signature. A bare 32-byte key cannot satisfy that, which is why
183 /// this carries the full encoded form rather than raw key bytes.
184 ///
185 /// Multiple keys enable rotation without breaking already-deployed
186 /// binaries — any one verifying is accepted, so a binary shipped
187 /// trusting `{old, new}` spans a rotation. Empty means
188 /// `rtb-update` refuses to run (see `UpdateError::NoPublicKey`).
189 /// Not serialised — keys are compile-time constants, not
190 /// config-file values.
191 #[serde(skip)]
192 #[builder(default)]
193 pub update_public_keys: Vec<String>,
194
195 /// Optional asset name listing SHA-256 checksums for this
196 /// release. `rtb-update` downloads it alongside the binary and
197 /// cross-checks the binary's hash before swap. When `None`,
198 /// signature verification is the only integrity gate.
199 #[serde(skip)]
200 pub update_checksums_asset: Option<&'static str>,
201
202 /// Asset-name template `rtb-update` uses to select the right
203 /// artefact for the running host. Default:
204 /// `{name}-{version}-{target}{ext}`. Placeholders:
205 /// `{name}` → [`ToolMetadata::name`], `{version}` → release tag
206 /// (leading `v` stripped), `{target}` → Rust host triple,
207 /// `{ext}` → `.tar.gz` on Unix / `.zip` on Windows. Tools with
208 /// a different naming convention set this explicitly.
209 #[serde(skip)]
210 pub update_asset_pattern: Option<&'static str>,
211
212 /// Privacy notice printed by `rtb-cli`'s v0.4 `telemetry enable`
213 /// subcommand. `None` falls back to a generic message. Lives on
214 /// `ToolMetadata` rather than `rtb-telemetry` so the subcommand
215 /// can read it without depending on the telemetry crate.
216 #[serde(skip)]
217 pub telemetry_notice: Option<&'static str>,
218}
219
220/// User-support channel advertised in error output.
221///
222/// `rtb-cli::Application::run` reads this off `ToolMetadata`, formats
223/// via [`HelpChannel::footer`], and installs the result into
224/// `rtb_error::hook::install_with_footer` so every diagnostic ends
225/// with a consistent support pointer.
226#[derive(Debug, Clone, Default, Serialize, Deserialize)]
227#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
228#[non_exhaustive]
229pub enum HelpChannel {
230 /// No support footer.
231 #[default]
232 None,
233 /// Slack channel reference.
234 Slack {
235 /// Slack workspace / team name.
236 team: String,
237 /// Channel name without the `#`.
238 channel: String,
239 },
240 /// Microsoft Teams channel reference.
241 Teams {
242 /// Team name.
243 team: String,
244 /// Channel name.
245 channel: String,
246 },
247 /// Arbitrary support URL (status page, docs, contact form).
248 Url {
249 /// The URL to advertise verbatim.
250 url: String,
251 },
252}
253
254impl HelpChannel {
255 /// The one-line footer shown under error diagnostics.
256 ///
257 /// Returns `None` when the channel is [`HelpChannel::None`] —
258 /// `install_with_footer` treats `None`/empty as "no footer".
259 #[must_use]
260 pub fn footer(&self) -> Option<String> {
261 match self {
262 Self::None => None,
263 Self::Slack { team, channel } => Some(format!("support: slack #{channel} (in {team})")),
264 Self::Teams { team, channel } => Some(format!("support: Teams → {team} / {channel}")),
265 Self::Url { url } => Some(format!("support: {url}")),
266 }
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::{default_update_check_interval, ToolMetadata, UpdatePolicy};
273 use std::time::Duration;
274
275 #[test]
276 fn update_policy_defaults_to_disabled() {
277 assert_eq!(UpdatePolicy::default(), UpdatePolicy::Disabled);
278 }
279
280 #[test]
281 fn metadata_update_baseline_defaults() {
282 let m = ToolMetadata::builder().name("t").summary("s").build();
283 assert_eq!(m.update_policy, UpdatePolicy::Disabled);
284 assert_eq!(m.update_check_interval, Duration::from_secs(24 * 60 * 60));
285 }
286
287 #[test]
288 fn builder_overrides_update_baseline() {
289 let m = ToolMetadata::builder()
290 .name("t")
291 .summary("s")
292 .update_policy(UpdatePolicy::Enabled)
293 .update_check_interval(Duration::from_secs(3600))
294 .build();
295 assert_eq!(m.update_policy, UpdatePolicy::Enabled);
296 assert_eq!(m.update_check_interval, Duration::from_secs(3600));
297 }
298
299 #[test]
300 fn update_policy_serde_is_lowercase() {
301 assert_eq!(serde_json::to_string(&UpdatePolicy::Enabled).unwrap(), "\"enabled\"");
302 assert_eq!(
303 serde_json::from_str::<UpdatePolicy>("\"prompt\"").unwrap(),
304 UpdatePolicy::Prompt
305 );
306 }
307
308 #[test]
309 fn check_interval_default_fn_is_24h() {
310 assert_eq!(default_update_check_interval(), Duration::from_secs(86_400));
311 }
312}