cli: `rad config schema` emits JSON Schema

Leverage `schemars` to generate a JSON Schema from our structs for
configurations and those occurring within them.

The output of `rad config schema` can be used by editors to provide a
smoother editing experience for the configuration file. Discovery
attributes (both keys and values) is greatly improved with the schema
helping in the background.

Refer to:
 - https://json-schema.org
 - https://code.visualstudio.com/docs/languages/json#_json-schemas-and-settings
 - https://schemastore.org
This commit is contained in:
n4ch7 2025-04-28 18:35:45 +02:00 committed by Fintan Halpenny
parent 5a2f26eaa7
commit b608a78806
14 changed files with 1066 additions and 63 deletions

857
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -46,11 +46,12 @@ tree-sitter-bash = { version = "0.23.3" }
tree-sitter-go = { version = "0.23.4" }
tree-sitter-md = { version = "0.3.2" }
zeroize = { version = "1.1" }
schemars = { version = "1.0.0-alpha.17" }
[dependencies.radicle]
version = "0"
path = "../radicle"
features = ["logger"]
features = ["logger", "schemars"]
[dependencies.radicle-cli-test]
version = "0"

View File

@ -23,6 +23,7 @@ Usage
rad config init --alias <alias> [<option>...]
rad config edit [<option>...]
rad config get <key> [<option>...]
rad config schema [<option>...]
rad config set <key> <value> [<option>...]
rad config unset <key> [<option>...]
rad config push <key> <value> [<option>...]
@ -43,6 +44,7 @@ enum Operation {
#[default]
Show,
Get(String),
Schema,
Set(String, String),
Push(String, String),
Remove(String, String),
@ -79,6 +81,7 @@ impl Args for Options {
}
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
"show" => op = Some(Operation::Show),
"schema" => op = Some(Operation::Schema),
"edit" => op = Some(Operation::Edit),
"init" => op = Some(Operation::Init),
"get" => {
@ -140,6 +143,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
term::json::to_pretty(&profile.config, path.as_path())?.print();
}
Operation::Schema => {
term::json::to_pretty(&schemars::schema_for!(Config), path.as_path())?.print();
}
Operation::Get(key) => {
let mut temp_config = RawConfig::from_file(&path)?;
let key: ConfigPath = key.into();

View File

@ -35,6 +35,7 @@ sqlite = { version = "0.32.0", features = ["bundled"] }
tempfile = { version = "3.3.0" }
thiserror = { version = "1" }
unicode-normalization = { version = "0.1" }
schemars = { version = "1.0.0-alpha.17", optional = true }
[dependencies.chrono]
version = "0.4.0"
@ -75,6 +76,7 @@ emojis = { version = "0.6" }
pretty_assertions = { version = "1.3.0" }
qcheck-macros = { version = "1", default-features = false }
qcheck = { version = "1", default-features = false }
jsonschema = { version = "0.30" }
[dev-dependencies.radicle-crypto]
path = "../radicle-crypto"

View File

@ -1,6 +1,11 @@
/// CLI configuration.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(
feature = "schemars",
derive(schemars::JsonSchema),
schemars(rename = "CliConfig")
)]
pub struct Config {
/// Whether to show hints or not in the CLI.
#[serde(default)]

View File

@ -81,8 +81,9 @@ impl std::fmt::Display for ExplorerUrl {
}
/// A public explorer, eg. `https://app.radicle.xyz`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Explorer(String);
impl Default for Explorer {

View File

@ -20,7 +20,17 @@ pub enum IdError {
/// A repository identifier.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RepoId(git::Oid);
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RepoId(
#[cfg_attr(feature = "schemars", schemars(
with = "String",
description = "A repository identifier. Starts with \"rad:\", followed by a multibase Base58 encoded Git object identifier.",
regex(pattern = r"rad:z[1-9a-km-zA-HJ-NP-Z]+"),
length(min = 5),
example = &"rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
))]
git::Oid,
);
impl fmt::Display for RepoId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {

View File

@ -23,6 +23,8 @@ pub mod logger;
pub mod node;
pub mod profile;
pub mod rad;
#[cfg(feature = "schemars")]
pub(crate) mod schemars_ext;
pub mod serde_ext;
pub mod sql;
pub mod storage;

View File

@ -181,7 +181,7 @@ impl Penalty {
}
/// Repository sync status for our own refs.
#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(tag = "status")]
#[serde(rename_all = "camelCase")]
pub enum SyncStatus {
@ -221,7 +221,7 @@ impl PartialOrd for SyncStatus {
}
/// Node user agent.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Serialize, Deserialize)]
pub struct UserAgent(String);
impl UserAgent {
@ -291,10 +291,25 @@ impl AsRef<str> for UserAgent {
}
}
/// Node alias.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, serde::Serialize, serde::Deserialize)]
/// Node alias, i.e. a short and memorable name for it.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Alias(String);
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Alias(
// To exclude control characters, one might be inclined to use the character
// class `[[:cntrl:]]` which is understood by the `regex` crate.
// However, the patterns in JSON schema must conform to ECMA-262, which does
// not specify the character class.
// Thus, we unfold its definition from <https://www.unicode.org/reports/tr18/#cntrl>,
// which refers to the "general category" named "Cc",
// see <https://unicode.org/reports/tr44/#General_Category_Values>.
// We obtain the two ranges below from <https://www.unicode.org/notes/tn36/Categories.txt>.
#[cfg_attr(
feature = "schemars",
schemars(regex(pattern = r"^[^\x00-\x1F\x7F-\x9F\s]{0,32}$"), length(max = 32))
)]
String,
);
impl Alias {
/// Create a new alias from a string. Panics if the string is not a valid alias.
@ -411,7 +426,7 @@ impl TryFrom<&sqlite::Value> for Alias {
}
/// Options passed to the "connect" node command.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectOptions {
/// Establish a persistent connection.
pub persistent: bool,
@ -500,10 +515,30 @@ impl<T: Serialize> CommandResult<T> {
}
/// Peer public protocol address.
#[derive(Wrapper, WrapperMut, Clone, Eq, PartialEq, Debug, Hash, From, Serialize, Deserialize)]
#[derive(Clone, Eq, PartialEq, Debug, Hash, From, Wrapper, WrapperMut, Serialize, Deserialize)]
#[wrapper(Deref, Display, FromStr)]
#[wrapper_mut(DerefMut)]
pub struct Address(#[serde(with = "crate::serde_ext::string")] NetAddr<HostName>);
#[cfg_attr(
feature = "schemars",
derive(schemars::JsonSchema),
schemars(description = "\
An IP address, or a DNS name, or a Tor onion name, followed by the symbol ':', \
followed by a TCP port number.\
")
)]
pub struct Address(
#[serde(with = "crate::serde_ext::string")]
#[cfg_attr(feature = "schemars", schemars(
with = "String",
regex(pattern = r"^.+:((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))$"),
extend("examples" = [
"xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776",
"seed.example.com:8776",
"192.0.2.0:31337",
]),
))]
NetAddr<HostName>,
);
impl Address {
/// Check whether this address is from the local network.
@ -667,7 +702,7 @@ impl Session {
}
/// A seed for some repository, with metadata about its status.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Seed {
/// The Node ID.
@ -708,7 +743,7 @@ impl Seed {
}
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
/// Represents a set of seeds with associated metadata. Uses an RNG
/// underneath, so every iteration returns a different ordering.
#[serde(into = "Vec<Seed>", from = "Vec<Seed>")]

View File

@ -5,6 +5,7 @@ use std::{fmt, net};
use cyphernet::addr::PeerAddr;
use localtime::LocalDuration;
use serde::{Deserialize, Serialize};
use serde_json as json;
use crate::node;
@ -57,8 +58,9 @@ pub mod seeds {
}
/// Peer-to-peer network.
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum Network {
#[default]
Main,
@ -94,16 +96,25 @@ impl Network {
}
/// Configuration parameters defining attributes of minima and maxima.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Limits {
/// Number of routing table entries before we start pruning.
pub routing_max_size: usize,
/// How long to keep a routing table entry before being pruned.
#[serde(with = "crate::serde_ext::localtime::duration")]
#[cfg_attr(
feature = "schemars",
schemars(with = "crate::schemars_ext::localtime::LocalDuration")
)]
pub routing_max_age: LocalDuration,
/// How long to keep a gossip message entry before pruning it.
#[serde(with = "crate::serde_ext::localtime::duration")]
#[cfg_attr(
feature = "schemars",
schemars(with = "crate::schemars_ext::localtime::LocalDuration")
)]
pub gossip_max_age: LocalDuration,
/// Maximum number of concurrent fetches per peer connection.
pub fetch_concurrency: usize,
@ -138,10 +149,19 @@ impl Default for Limits {
/// Limiter for byte streams.
///
/// Default: 500MiB
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(into = "String", try_from = "String")]
#[cfg_attr(
feature = "schemars",
derive(schemars::JsonSchema),
schemars(transparent)
)]
pub struct FetchPackSizeLimit {
#[cfg_attr(
feature = "schemars",
schemars(with = "crate::schemars_ext::bytesize::ByteSize")
)]
limit: bytesize::ByteSize,
}
@ -213,8 +233,9 @@ impl Default for FetchPackSizeLimit {
}
/// Connection limits.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ConnectionLimits {
/// Max inbound connections.
pub inbound: usize,
@ -232,16 +253,18 @@ impl Default for ConnectionLimits {
}
/// Rate limts for a single connection.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RateLimit {
pub fill_rate: f64,
pub capacity: usize,
}
/// Rate limits for inbound and outbound connections.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RateLimits {
pub inbound: RateLimit,
pub outbound: RateLimit,
@ -263,9 +286,30 @@ impl Default for RateLimits {
}
/// Full address used to connect to a remote node.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash)]
#[serde(transparent)]
pub struct ConnectAddress(#[serde(with = "crate::serde_ext::string")] PeerAddr<NodeId, Address>);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(
feature = "schemars",
derive(schemars::JsonSchema),
schemars(description = "\
A node address to connect to. Format: An Ed25519 public key in multibase encoding, \
followed by the symbol '@', followed by an IP address, or a DNS name, or a Tor onion \
name, followed by the symbol ':', followed by a TCP port number.\
")
)]
pub struct ConnectAddress(
#[serde(with = "crate::serde_ext::string")]
#[schemars(
with = "String",
regex(pattern = r"^.+@.+:((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))$"),
extend("examples" = [
"z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776",
"z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C@xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776",
"z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi@seed.example.com:8776",
"z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5@192.0.2.0:31337",
]),
)]
PeerAddr<NodeId, Address>,
);
impl From<PeerAddr<NodeId, Address>> for ConnectAddress {
fn from(value: PeerAddr<NodeId, Address>) -> Self {
@ -300,8 +344,9 @@ impl Deref for ConnectAddress {
}
/// Peer configuration.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum PeerConfig {
/// Static peer set. Connect to the configured peers and maintain the connections.
Static,
@ -316,8 +361,9 @@ impl Default for PeerConfig {
}
/// Relay configuration.
#[derive(Debug, Copy, Clone, Default, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum Relay {
/// Always relay messages.
Always,
@ -329,8 +375,9 @@ pub enum Relay {
}
/// Proxy configuration.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "mode")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum AddressConfig {
/// Proxy connections to this address type.
Proxy {
@ -343,8 +390,9 @@ pub enum AddressConfig {
}
/// Default seeding policy. Applies when no repository policies for the given repo are found.
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "default")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum DefaultSeedingPolicy {
/// Allow seeding.
Allow {
@ -379,13 +427,19 @@ impl From<DefaultSeedingPolicy> for SeedingPolicy {
}
/// Service configuration.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(
feature = "schemars",
derive(schemars::JsonSchema),
schemars(rename = "NodeConfig")
)]
pub struct Config {
/// Node alias.
pub alias: Alias,
/// Address to listen on.
/// Socket address (a combination of IPv4 or IPv6 address and TCP port) to listen on.
#[serde(default)]
#[cfg_attr(feature = "schemars", schemars(example = &"127.0.0.1:8776"))]
pub listen: Vec<net::SocketAddr>,
/// Peer configuration.
#[serde(default)]
@ -409,6 +463,10 @@ pub struct Config {
/// Log level.
#[serde(default = "defaults::log")]
#[serde(with = "crate::serde_ext::string")]
#[cfg_attr(
feature = "schemars",
schemars(with = "crate::schemars_ext::log::Level")
)]
pub log: log::Level,
/// Whether or not our node should relay messages.
#[serde(default, deserialize_with = "crate::serde_ext::ok_or_default")]

View File

@ -35,7 +35,7 @@ pub struct FollowPolicy {
}
/// Seeding policy of a node or repo.
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "policy")]
pub enum SeedingPolicy {
/// Allow seeding.
@ -159,6 +159,7 @@ impl TryFrom<&sqlite::Value> for Policy {
/// Follow scope of a seeded repository.
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum Scope {
/// Seed remotes that are explicitly followed.
Followed,

View File

@ -25,6 +25,7 @@ pub enum ConfigError {
/// Local radicle configuration.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Config {
/// Public explorer. This is used for generating links.
#[serde(default)]
@ -360,3 +361,19 @@ impl From<String> for ConfigPath {
ConfigPath(parts)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
#[test]
fn schema() {
use super::Config;
use crate::prelude::Alias;
use serde_json::to_value;
let schema = to_value(schemars::schema_for!(Config)).unwrap();
let config = to_value(Config::new(Alias::new("schema"))).unwrap();
jsonschema::validate(&schema, &config)
.expect("generated configuration should validate under generated JSON Schema");
}
}

View File

@ -0,0 +1,59 @@
//! This module contains auxiliary definitions for generating JSONSchemas.
//! See <https://graham.cool/schemars/examples/5-remote_derive/>.
#![allow(dead_code)]
use schemars::JsonSchema;
pub(crate) mod log {
use super::*;
/// See [`::log::Level`]
#[derive(JsonSchema)]
#[schemars(
remote = "log::Level",
description = "A log level.",
rename_all = "UPPERCASE"
)]
pub(crate) enum Level {
/// Designates very serious errors.
Error,
/// Designates hazardous situations.
Warn,
/// Designates useful information.
Info,
/// Designates lower priority information.
Debug,
/// Designates very low priority, often extremely verbose, information.
Trace,
}
}
pub(crate) mod bytesize {
use super::*;
/// See [`::bytesize::ByteSize`] as well as [`::bytesize::parse`].
/// Note that the pattern here is a little more restrictive than
/// the actual parsing logic, as it enforces particular casing and whitespace.
/// However, the regular expression is easier to read.
#[derive(JsonSchema)]
#[schemars(
remote = "bytesize::ByteSize",
description = "Byte quantities using unit prefixes according to SI or ISO/IEC 80000-13.",
extend("examples" = ["7 G", "50.3 TiB", "200 B", "4 Ki", "10 MB"]),
)]
pub(crate) struct ByteSize(
#[schemars(regex(pattern = r"^\d+(\.\d+)? ((K|M|G|T|P)i?B?|B)$"))] String,
);
}
pub(crate) mod localtime {
use super::*;
/// See [`::localtime::LocalDuration`]
#[derive(JsonSchema)]
#[schemars(
remote = "localtime::LocalDuration",
description = "A time duration measured locally in milliseconds."
)]
pub(crate) struct LocalDuration(u64);
}

View File

@ -1,29 +1,36 @@
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::prelude::RepoId;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
/// Web configuration.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(
feature = "schemars",
derive(schemars::JsonSchema),
schemars(rename = "WebConfig")
)]
pub struct Config {
/// Pinned content.
pub pinned: Pinned,
/// URL pointing to an image used in the header of a node page.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(url))]
pub banner_url: Option<String>,
/// URL pointing to an image used as the node avatar.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(url))]
pub avatar_url: Option<String>,
/// Node description.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(url))]
pub description: Option<String>,
}
/// Pinned content. This can be used to pin certain content when
/// listing, e.g. pin repositories on a web client.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Pinned {
/// Pinned repositories.