Skip to main content

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}