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 rust-tool-base spec
91/// [0027, rtb-update policy v0.1](https://gitlab.com/phpboyscout/rust-tool-base/-/wikis/specs/0027-rtb-update-policy-v0.1).
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
93#[serde(rename_all = "lowercase")]
94pub enum UpdatePolicy {
95    /// Never check automatically (default). User-initiated only.
96    #[default]
97    Disabled,
98    /// Check (throttled); on a newer version, prompt the user.
99    Prompt,
100    /// Check (throttled); on a newer version, update before running.
101    Enabled,
102}
103
104/// Default interval between automatic update checks: 24 hours.
105const DEFAULT_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
106
107const fn default_update_check_interval() -> Duration {
108    DEFAULT_UPDATE_CHECK_INTERVAL
109}
110
111/// Static tool metadata set at construction time.
112///
113/// Use the [`bon::Builder`] interface — `name` and `summary` are
114/// required at compile time; missing either is a compile error.
115#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
116#[serde(deny_unknown_fields)]
117#[builder(on(String, into))]
118pub struct ToolMetadata {
119    /// Human- and machine-facing tool name (`mytool`).
120    pub name: String,
121
122    /// One-line summary used in `--help` and the CLI banner.
123    pub summary: String,
124
125    /// Long-form description shown under `--help`.
126    #[serde(default)]
127    #[builder(default)]
128    pub description: String,
129
130    /// Optional release source — required iff `Feature::Update` is
131    /// runtime-enabled.
132    #[serde(default)]
133    pub release_source: Option<ReleaseSource>,
134
135    /// Author-set self-update policy baseline. Default
136    /// [`UpdatePolicy::Disabled`]. In v0.1 the effective policy is this
137    /// baseline (a runtime config override is deferred — see the spec).
138    /// Inert unless `Feature::Update` is enabled and `release_source` is
139    /// set.
140    #[serde(default)]
141    #[builder(default)]
142    pub update_policy: UpdatePolicy,
143
144    /// Minimum interval between automatic update checks (default 24h).
145    /// Only consulted when `update_policy` is `Prompt`/`Enabled`; a check
146    /// within this window of the last is throttled (skipped).
147    #[serde(default = "default_update_check_interval")]
148    #[builder(default = default_update_check_interval())]
149    pub update_check_interval: Duration,
150
151    /// Optional credential reference for the release source. Resolved
152    /// at update time via `rtb_credentials::Resolver`. Public repos
153    /// don't need it; private repos require this OR a fallback env
154    /// var the resolver knows about.
155    ///
156    /// Deserialised from config files but never serialised back —
157    /// `CredentialRef` contains a `SecretString` literal that should
158    /// not round-trip through `Serialize` (the `secrecy` crate
159    /// removed the `Serialize` impl on purpose, mirroring the
160    /// "secrets don't cross untyped boundaries" rule).
161    #[serde(default, skip_serializing)]
162    pub release_credential: Option<rtb_credentials::CredentialRef>,
163
164    /// Support channel advertised in error diagnostic footers.
165    #[serde(default)]
166    #[builder(default)]
167    pub help: HelpChannel,
168
169    /// minisign public keys trusted for verifying release-asset
170    /// signatures, each the base64 string a `minisign.pub` file
171    /// carries — `"RWR…"`, decoding to algorithm tag, key id and the
172    /// 32-byte Ed25519 key.
173    ///
174    /// This is deliberately the *same* string pinned as `pubkey` in a
175    /// crate's `[package.metadata.binstall.signing]` table and printed
176    /// by `sigillum keys minisign`. One canonical representation means
177    /// cargo-binstall and `rtb-update` cannot end up trusting
178    /// different things, and an operator can compare the two by eye.
179    ///
180    /// The key id matters: `rtb-update` verifies via the same
181    /// `minisign-verify` crate cargo-binstall uses, which matches a
182    /// signature's key id against the public key's before checking any
183    /// signature. A bare 32-byte key cannot satisfy that, which is why
184    /// this carries the full encoded form rather than raw key bytes.
185    ///
186    /// Multiple keys enable rotation without breaking already-deployed
187    /// binaries — any one verifying is accepted, so a binary shipped
188    /// trusting `{old, new}` spans a rotation. Empty means
189    /// `rtb-update` refuses to run (see `UpdateError::NoPublicKey`).
190    /// Not serialised — keys are compile-time constants, not
191    /// config-file values.
192    #[serde(skip)]
193    #[builder(default)]
194    pub update_public_keys: Vec<String>,
195
196    /// Optional asset name listing SHA-256 checksums for this
197    /// release. `rtb-update` downloads it alongside the binary and
198    /// cross-checks the binary's hash before swap. When `None`,
199    /// signature verification is the only integrity gate.
200    #[serde(skip)]
201    pub update_checksums_asset: Option<&'static str>,
202
203    /// Asset-name template `rtb-update` uses to select the right
204    /// artefact for the running host. Default:
205    /// `{name}-{version}-{target}{ext}`. Placeholders:
206    /// `{name}` → [`ToolMetadata::name`], `{version}` → release tag
207    /// (leading `v` stripped), `{target}` → Rust host triple,
208    /// `{ext}` → `.tar.gz` on Unix / `.zip` on Windows. Tools with
209    /// a different naming convention set this explicitly.
210    #[serde(skip)]
211    pub update_asset_pattern: Option<&'static str>,
212
213    /// Privacy notice printed by `rtb-cli`'s v0.4 `telemetry enable`
214    /// subcommand. `None` falls back to a generic message. Lives on
215    /// `ToolMetadata` rather than `rtb-telemetry` so the subcommand
216    /// can read it without depending on the telemetry crate.
217    #[serde(skip)]
218    pub telemetry_notice: Option<&'static str>,
219}
220
221/// User-support channel advertised in error output.
222///
223/// `rtb-cli::Application::run` reads this off `ToolMetadata`, formats
224/// via [`HelpChannel::footer`], and installs the result into
225/// `rtb_error::hook::install_with_footer` so every diagnostic ends
226/// with a consistent support pointer.
227#[derive(Debug, Clone, Default, Serialize, Deserialize)]
228#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
229#[non_exhaustive]
230pub enum HelpChannel {
231    /// No support footer.
232    #[default]
233    None,
234    /// Slack channel reference.
235    Slack {
236        /// Slack workspace / team name.
237        team: String,
238        /// Channel name without the `#`.
239        channel: String,
240    },
241    /// Microsoft Teams channel reference.
242    Teams {
243        /// Team name.
244        team: String,
245        /// Channel name.
246        channel: String,
247    },
248    /// Arbitrary support URL (status page, docs, contact form).
249    Url {
250        /// The URL to advertise verbatim.
251        url: String,
252    },
253}
254
255impl HelpChannel {
256    /// The one-line footer shown under error diagnostics.
257    ///
258    /// Returns `None` when the channel is [`HelpChannel::None`] —
259    /// `install_with_footer` treats `None`/empty as "no footer".
260    #[must_use]
261    pub fn footer(&self) -> Option<String> {
262        match self {
263            Self::None => None,
264            Self::Slack { team, channel } => Some(format!("support: slack #{channel} (in {team})")),
265            Self::Teams { team, channel } => Some(format!("support: Teams → {team} / {channel}")),
266            Self::Url { url } => Some(format!("support: {url}")),
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::{default_update_check_interval, ToolMetadata, UpdatePolicy};
274    use std::time::Duration;
275
276    #[test]
277    fn update_policy_defaults_to_disabled() {
278        assert_eq!(UpdatePolicy::default(), UpdatePolicy::Disabled);
279    }
280
281    #[test]
282    fn metadata_update_baseline_defaults() {
283        let m = ToolMetadata::builder().name("t").summary("s").build();
284        assert_eq!(m.update_policy, UpdatePolicy::Disabled);
285        assert_eq!(m.update_check_interval, Duration::from_secs(24 * 60 * 60));
286    }
287
288    #[test]
289    fn builder_overrides_update_baseline() {
290        let m = ToolMetadata::builder()
291            .name("t")
292            .summary("s")
293            .update_policy(UpdatePolicy::Enabled)
294            .update_check_interval(Duration::from_secs(3600))
295            .build();
296        assert_eq!(m.update_policy, UpdatePolicy::Enabled);
297        assert_eq!(m.update_check_interval, Duration::from_secs(3600));
298    }
299
300    #[test]
301    fn update_policy_serde_is_lowercase() {
302        assert_eq!(serde_json::to_string(&UpdatePolicy::Enabled).unwrap(), "\"enabled\"");
303        assert_eq!(
304            serde_json::from_str::<UpdatePolicy>("\"prompt\"").unwrap(),
305            UpdatePolicy::Prompt
306        );
307    }
308
309    #[test]
310    fn check_interval_default_fn_is_24h() {
311        assert_eq!(default_update_check_interval(), Duration::from_secs(86_400));
312    }
313}