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 /// Ed25519 public keys trusted for verifying release-asset
169 /// signatures. Multiple keys enable rotation without breaking
170 /// already-deployed binaries — a release signed by a rotated-in
171 /// key verifies against any binary whose trusted list includes
172 /// that key. Empty means `rtb-update` refuses to run (see
173 /// `UpdateError::NoPublicKey`). Not serialised — keys are
174 /// compile-time constants, not config-file values.
175 #[serde(skip)]
176 #[builder(default)]
177 pub update_public_keys: Vec<[u8; 32]>,
178
179 /// Optional asset name listing SHA-256 checksums for this
180 /// release. `rtb-update` downloads it alongside the binary and
181 /// cross-checks the binary's hash before swap. When `None`,
182 /// signature verification is the only integrity gate.
183 #[serde(skip)]
184 pub update_checksums_asset: Option<&'static str>,
185
186 /// Asset-name template `rtb-update` uses to select the right
187 /// artefact for the running host. Default:
188 /// `{name}-{version}-{target}{ext}`. Placeholders:
189 /// `{name}` → [`ToolMetadata::name`], `{version}` → release tag
190 /// (leading `v` stripped), `{target}` → Rust host triple,
191 /// `{ext}` → `.tar.gz` on Unix / `.zip` on Windows. Tools with
192 /// a different naming convention set this explicitly.
193 #[serde(skip)]
194 pub update_asset_pattern: Option<&'static str>,
195
196 /// Privacy notice printed by `rtb-cli`'s v0.4 `telemetry enable`
197 /// subcommand. `None` falls back to a generic message. Lives on
198 /// `ToolMetadata` rather than `rtb-telemetry` so the subcommand
199 /// can read it without depending on the telemetry crate.
200 #[serde(skip)]
201 pub telemetry_notice: Option<&'static str>,
202}
203
204/// User-support channel advertised in error output.
205///
206/// `rtb-cli::Application::run` reads this off `ToolMetadata`, formats
207/// via [`HelpChannel::footer`], and installs the result into
208/// `rtb_error::hook::install_with_footer` so every diagnostic ends
209/// with a consistent support pointer.
210#[derive(Debug, Clone, Default, Serialize, Deserialize)]
211#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
212#[non_exhaustive]
213pub enum HelpChannel {
214 /// No support footer.
215 #[default]
216 None,
217 /// Slack channel reference.
218 Slack {
219 /// Slack workspace / team name.
220 team: String,
221 /// Channel name without the `#`.
222 channel: String,
223 },
224 /// Microsoft Teams channel reference.
225 Teams {
226 /// Team name.
227 team: String,
228 /// Channel name.
229 channel: String,
230 },
231 /// Arbitrary support URL (status page, docs, contact form).
232 Url {
233 /// The URL to advertise verbatim.
234 url: String,
235 },
236}
237
238impl HelpChannel {
239 /// The one-line footer shown under error diagnostics.
240 ///
241 /// Returns `None` when the channel is [`HelpChannel::None`] —
242 /// `install_with_footer` treats `None`/empty as "no footer".
243 #[must_use]
244 pub fn footer(&self) -> Option<String> {
245 match self {
246 Self::None => None,
247 Self::Slack { team, channel } => Some(format!("support: slack #{channel} (in {team})")),
248 Self::Teams { team, channel } => Some(format!("support: Teams → {team} / {channel}")),
249 Self::Url { url } => Some(format!("support: {url}")),
250 }
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::{default_update_check_interval, ToolMetadata, UpdatePolicy};
257 use std::time::Duration;
258
259 #[test]
260 fn update_policy_defaults_to_disabled() {
261 assert_eq!(UpdatePolicy::default(), UpdatePolicy::Disabled);
262 }
263
264 #[test]
265 fn metadata_update_baseline_defaults() {
266 let m = ToolMetadata::builder().name("t").summary("s").build();
267 assert_eq!(m.update_policy, UpdatePolicy::Disabled);
268 assert_eq!(m.update_check_interval, Duration::from_secs(24 * 60 * 60));
269 }
270
271 #[test]
272 fn builder_overrides_update_baseline() {
273 let m = ToolMetadata::builder()
274 .name("t")
275 .summary("s")
276 .update_policy(UpdatePolicy::Enabled)
277 .update_check_interval(Duration::from_secs(3600))
278 .build();
279 assert_eq!(m.update_policy, UpdatePolicy::Enabled);
280 assert_eq!(m.update_check_interval, Duration::from_secs(3600));
281 }
282
283 #[test]
284 fn update_policy_serde_is_lowercase() {
285 assert_eq!(serde_json::to_string(&UpdatePolicy::Enabled).unwrap(), "\"enabled\"");
286 assert_eq!(
287 serde_json::from_str::<UpdatePolicy>("\"prompt\"").unwrap(),
288 UpdatePolicy::Prompt
289 );
290 }
291
292 #[test]
293 fn check_interval_default_fn_is_24h() {
294 assert_eq!(default_update_check_interval(), Duration::from_secs(86_400));
295 }
296}