Skip to content

Tool metadata

ToolMetadata is the static description of a tool: what it is called, what it does, where its releases come from, and where users go for help. It is built once at startup and never mutated.

use rtb_app::metadata::{HelpChannel, ReleaseSource, ToolMetadata, UpdatePolicy};

ToolMetadata fields

Built with a bon typestate builder. name and summary are required at compile time — leaving either out is a type error, not a runtime panic, and the crate carries a trybuild fixture proving it stays that way. String fields accept &str directly because the builder is declared with #[builder(on(String, into))].

Field Type Default Set it when
name String required Always. Used in --help, the banner, and the update asset pattern.
summary String required Always. The one-line description in --help.
description String "" You want long-form text under --help.
release_source Option<ReleaseSource> None The tool can update itself. Required in practice whenever Feature::Update is enabled.
update_policy UpdatePolicy UpdatePolicy::Disabled You want automatic pre-run update checks.
update_check_interval Duration 24 hours The default throttle is wrong for your release cadence.
release_credential Option<CredentialRef> None Releases live in a private repository.
help HelpChannel HelpChannel::None You want a support line under every error diagnostic.
update_public_keys Vec<String> vec![] The tool self-updates — an empty list makes rtb-update refuse to run.
update_checksums_asset Option<&'static str> None Your release publishes a SHA-256 checksums asset.
update_asset_pattern Option<&'static str> None Your release assets are not named {name}-{version}-{target}{ext}.
telemetry_notice Option<&'static str> None You want a tool-specific privacy notice instead of the generic one.
let metadata = ToolMetadata::builder()
    .name("mytool")
    .summary("does the thing")
    .help(HelpChannel::Url { url: "https://example.com/support".into() })
    .build();

Which metadata fields survive a config file

ToolMetadata derives Serialize and Deserialize, but four fields carry #[serde(skip)] and are therefore compile-time-only. They cannot be set from a config file, and they do not appear when the struct is serialised:

update_public_keys, update_checksums_asset, update_asset_pattern, telemetry_notice.

That is deliberate for the keys — trusted signing keys pinned in a file a user can edit would not be trusted keys. It has a sharp edge, though, because the struct is also #[serde(deny_unknown_fields)]. Putting a skipped field in a YAML file does not silently do nothing; it fails the whole parse:

unknown field `update_public_keys`, expected one of `name`, `summary`,
`description`, `release_source`, `update_policy`, `update_check_interval`,
`release_credential`, `help`

Those eight names are the complete set of keys a ToolMetadata document may carry.

release_credential is a ninth special case: it deserialises but carries #[serde(skip_serializing)], so it can be read from a file and never written back. CredentialRef wraps a secret, and secrets do not round-trip out through Serialize.

How update_check_interval is written in a config file

Duration has no bespoke serde format here — it uses serde's own representation, which is a two-field struct. A bare number is rejected:

# rejected: invalid type: integer `3600`, expected struct Duration
update_check_interval: 3600

# accepted
update_check_interval:
  secs: 3600
  nanos: 0

Both secs and nanos are required; omitting either fails with missing field "nanos". A serialised ToolMetadata with default settings looks like this in full:

name: mytool
summary: does things
description: ''
release_source: null
update_policy: disabled
update_check_interval:
  secs: 86400
  nanos: 0
help:
  kind: none

ReleaseSource — the six variants

Where the tool's releases live. Serialised with a type: discriminator in lowercase, #[non_exhaustive], and deny_unknown_fields on every variant.

Variant Required fields host default
Github owner, repo github.com
Gitlab project (full path, e.g. group/subgroup/project) gitlab.com
Bitbucket workspace, repo_slug api.bitbucket.org/2.0
Gitea owner, repo, host none — omitting it fails with missing field "host"
Codeberg owner, repo not applicable — the variant has no host field at all
Direct url_template not applicable

Gitea is the one to watch: it is the only variant with a mandatory host, because there is no public Gitea instance to default to. Codeberg exists as its own variant rather than as a Gitea preset precisely so nobody has to remember host: codeberg.org.

release_source:
  type: github
  owner: acme
  repo: widget
  # host omitted → github.com

Direct takes a template rather than a URL. The placeholders it accepts are resolved by rtb-update, not by this crate:

release_source:
  type: direct
  url_template: https://dist.example.com/{tool}/{version}/{asset}

Because ReleaseSource is #[non_exhaustive], a match over it in your own code needs a _ => arm. That is what lets a new hosting provider be added in a minor release without breaking you.

UpdatePolicy — automatic update checking

pub enum UpdatePolicy { Disabled, Prompt, Enabled }
Value YAML Behaviour
Disabled disabled Default. Never checks automatically.
Prompt prompt Checks (throttled); prompts the user when a newer version exists.
Enabled enabled Checks (throttled); updates before running when a newer version exists.

Three things this policy does not govern, each of which trips people up:

  • The update subcommand is always available regardless of the policy. The policy governs only the automatic pre-run check.
  • The policy is inert unless Feature::Update is enabled and release_source is set. Setting it to Enabled on a tool with no release source changes nothing.
  • update_check_interval is consulted only for Prompt and Enabled. A check that falls within that window of the previous one is skipped.

Default is Disabled so that a tool built on the toolkit makes no unsolicited network call and pays no pre-run cost unless its author opted in.

rtb-cli reads this off ToolMetadata, formats it with footer(), and installs the result so every error diagnostic ends with the same support pointer.

Variant Fields footer() output
None (default) None — no footer is printed
Slack team, channel (no leading #) support: slack #cli-tools (in platform)
Teams team, channel support: Teams → SRE / oncall
Url url support: https://support.example.com

Serialised with a kind: discriminator in lowercase, #[non_exhaustive], and deny_unknown_fields:

help:
  kind: slack
  team: platform
  channel: cli-tools

footer() returns Option<String>; HelpChannel::None yields None, which the error hook treats as "no footer".