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    /// 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}