feat(options): add options metadata derive

This commit is contained in:
Bryan Helmkamp 2026-04-24 16:13:32 -04:00
parent f52ef5aa07
commit fc7382ce79
No known key found for this signature in database
9 changed files with 941 additions and 1 deletions

10
Cargo.lock generated
View file

@ -1937,6 +1937,8 @@ dependencies = [
name = "fabro-macros"
version = "0.213.0-nightly.0"
dependencies = [
"clap",
"fabro-options-metadata",
"proc-macro2",
"quote",
"syn 2.0.117",
@ -1990,6 +1992,14 @@ dependencies = [
"tracing",
]
[[package]]
name = "fabro-options-metadata"
version = "0.213.0-nightly.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "fabro-proc"
version = "0.213.0-nightly.0"

View file

@ -80,6 +80,7 @@ percent-encoding = "2"
minijinja = "2"
miette = { version = "7.6", features = ["fancy"] }
fabro-http = { path = "lib/crates/fabro-http" }
fabro-options-metadata = { path = "lib/crates/fabro-options-metadata" }
fabro-redact = { path = "lib/crates/fabro-redact" }
fabro-static = { path = "lib/crates/fabro-static" }
graphviz-sys = { git = "https://github.com/fabro-sh/graphviz-sys" }

View file

@ -742,7 +742,7 @@ Solid arrows are real dependencies. Dashed lines show phases that are independen
### Phase 6 — OptionsMetadata and CLI reference generation
- [ ] **Unit 6.1: Add `fabro-options-metadata` and `#[derive(OptionsMetadata)]`**
- [x] **Unit 6.1: Add `fabro-options-metadata` and `#[derive(OptionsMetadata)]`**
**Goal:** Ship the runtime metadata model plus a proc macro that extracts clap field help, `ValueEnum` possibilities, and option descriptions into that model.

View file

@ -16,3 +16,7 @@ workspace = true
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
[dev-dependencies]
clap.workspace = true
fabro-options-metadata.workspace = true

View file

@ -4,6 +4,8 @@ use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{DeriveInput, Ident, ItemFn, LitStr, Token, parenthesized, parse_macro_input};
mod options_metadata;
enum E2eRequirement {
Twin,
Live(LitStr),
@ -166,6 +168,14 @@ pub fn derive_combine(input: TokenStream) -> TokenStream {
impl_combine(&input)
}
#[proc_macro_derive(OptionsMetadata, attributes(option, option_group, arg, clap, serde))]
pub fn derive_options_metadata(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
options_metadata::derive_impl(input)
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}
fn impl_combine(ast: &DeriveInput) -> TokenStream {
let name = &ast.ident;
let fields = match ast.data {

View file

@ -0,0 +1,409 @@
use proc_macro2::TokenStream;
use quote::{quote, quote_spanned};
use syn::meta::ParseNestedMeta;
use syn::spanned::Spanned;
use syn::{
Attribute, Data, DataStruct, DeriveInput, ExprLit, Field, Fields, GenericArgument, Lit, Meta,
PathArguments, Type,
};
pub(crate) fn derive_impl(input: DeriveInput) -> syn::Result<TokenStream> {
let DeriveInput {
ident,
data,
attrs,
generics,
..
} = input;
let Data::Struct(DataStruct {
fields: Fields::Named(fields),
..
}) = data
else {
return Err(syn::Error::new(
ident.span(),
"OptionsMetadata can only be derived for structs with named fields",
));
};
let mut records = Vec::new();
for field in &fields.named {
if let Some(attr) = field
.attrs
.iter()
.find(|attr| attr.path().is_ident("option"))
{
records.push(handle_option(field, attr)?);
} else if field
.attrs
.iter()
.any(|attr| attr.path().is_ident("option_group"))
{
records.push(handle_option_group(field)?);
} else if has_serde_flatten(field)? {
let ty = &field.ty;
records.push(quote_spanned!(ty.span() => <#ty as ::fabro_options_metadata::OptionsMetadata>::record(visit)));
}
}
let documentation = quote_option_str(doc_string(&attrs)?);
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
Ok(quote! {
#[automatically_derived]
impl #impl_generics ::fabro_options_metadata::OptionsMetadata for #ident #ty_generics #where_clause {
fn record(visit: &mut dyn ::fabro_options_metadata::Visit) {
#(#records;)*
}
fn documentation() -> Option<&'static str> {
#documentation
}
}
})
}
fn handle_option_group(field: &Field) -> syn::Result<TokenStream> {
let ident = field_ident(field)?;
let name = option_name(field)?;
let ty = get_inner_type_if_option(&field.ty).unwrap_or(&field.ty);
Ok(quote_spanned!(
ident.span() => visit.record_set(#name, ::fabro_options_metadata::OptionSet::of::<#ty>())
))
}
fn handle_option(field: &Field, attr: &Attribute) -> syn::Result<TokenStream> {
let ident = field_ident(field)?;
let attrs = parse_option_attributes(attr)?;
let name = attrs
.name
.clone()
.or(option_long_name(field)?)
.unwrap_or_else(|| ident.to_string().replace('_', "-"));
let doc = quote_option_str(doc_string(&field.attrs)?);
let default = quote_option_str(attrs.default);
let value_type = quote_option_str(attrs.value_type);
let scope = quote_option_str(attrs.scope);
let example = quote_option_str(attrs.example);
let added_in = quote_option_str(attrs.added_in);
let deprecated = deprecated_metadata(field)?;
let possible_values = if attrs.possible_values.unwrap_or(false) || has_value_enum_arg(field)? {
let ty = get_inner_type_if_option(&field.ty).unwrap_or(&field.ty);
quote! {
Some(
<#ty as ::clap::ValueEnum>::value_variants()
.iter()
.filter_map(::clap::ValueEnum::to_possible_value)
.map(|value| ::fabro_options_metadata::PossibleValue {
name: value.get_name().to_string(),
help: value.get_help().map(ToString::to_string),
})
.collect()
)
}
} else {
quote!(None)
};
Ok(quote_spanned!(
ident.span() => visit.record_field(#name, ::fabro_options_metadata::OptionField {
doc: #doc,
default: #default,
value_type: #value_type,
scope: #scope,
example: #example,
deprecated: #deprecated,
possible_values: #possible_values,
added_in: #added_in,
})
))
}
fn field_ident(field: &Field) -> syn::Result<&syn::Ident> {
field
.ident
.as_ref()
.ok_or_else(|| syn::Error::new(field.span(), "expected named field"))
}
fn option_name(field: &Field) -> syn::Result<String> {
let ident = field_ident(field)?;
Ok(ident.to_string().replace('_', "-"))
}
fn doc_string(attrs: &[Attribute]) -> syn::Result<Option<String>> {
let docs = attrs
.iter()
.filter(|attr| attr.path().is_ident("doc"))
.map(parse_doc)
.collect::<syn::Result<Vec<_>>>()?;
let doc = docs
.into_iter()
.map(|line| line.trim().to_string())
.collect::<Vec<_>>()
.join("\n")
.trim_matches('\n')
.to_string();
if doc.is_empty() {
Ok(None)
} else {
Ok(Some(doc))
}
}
fn parse_doc(attr: &Attribute) -> syn::Result<String> {
match &attr.meta {
Meta::NameValue(name_value) => match &name_value.value {
syn::Expr::Lit(ExprLit {
lit: Lit::Str(lit), ..
}) => Ok(lit.value()),
value => Err(syn::Error::new(value.span(), "expected doc string literal")),
},
meta => Err(syn::Error::new(meta.span(), "expected doc attribute")),
}
}
#[derive(Default)]
struct FieldAttributes {
name: Option<String>,
default: Option<String>,
value_type: Option<String>,
scope: Option<String>,
example: Option<String>,
possible_values: Option<bool>,
added_in: Option<String>,
}
fn parse_option_attributes(attr: &Attribute) -> syn::Result<FieldAttributes> {
let mut attrs = FieldAttributes::default();
match &attr.meta {
Meta::Path(_) => return Ok(attrs),
Meta::List(_) => {}
meta @ Meta::NameValue(_) => {
return Err(syn::Error::new(
meta.span(),
"expected `#[option]` or `#[option(...)]`",
));
}
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("name") {
attrs.name = Some(string_literal(&meta, "name", "option")?.value());
} else if meta.path.is_ident("default") {
attrs.default = Some(string_literal(&meta, "default", "option")?.value());
} else if meta.path.is_ident("value_type") {
attrs.value_type = Some(string_literal(&meta, "value_type", "option")?.value());
} else if meta.path.is_ident("scope") {
attrs.scope = Some(string_literal(&meta, "scope", "option")?.value());
} else if meta.path.is_ident("example") {
attrs.example = Some(string_literal(&meta, "example", "option")?.value());
} else if meta.path.is_ident("possible_values") {
attrs.possible_values = Some(bool_literal(&meta, "possible_values", "option")?);
} else if meta.path.is_ident("added_in") {
attrs.added_in = Some(string_literal(&meta, "added_in", "option")?.value());
} else {
return Err(syn::Error::new(
meta.path.span(),
"unsupported `option` metadata key",
));
}
Ok(())
})?;
Ok(attrs)
}
fn deprecated_metadata(field: &Field) -> syn::Result<TokenStream> {
let Some(attr) = field
.attrs
.iter()
.find(|attr| attr.path().is_ident("deprecated"))
else {
return Ok(quote!(None));
};
let mut since = None;
let mut message = None;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("since") {
since = Some(string_literal(&meta, "since", "deprecated")?.value());
} else if meta.path.is_ident("note") {
message = Some(string_literal(&meta, "note", "deprecated")?.value());
} else {
return Err(syn::Error::new(
meta.path.span(),
"unsupported `deprecated` metadata key",
));
}
Ok(())
})?;
let since = quote_option_str(since);
let message = quote_option_str(message);
Ok(quote!(Some(::fabro_options_metadata::Deprecated {
since: #since,
message: #message,
})))
}
fn option_long_name(field: &Field) -> syn::Result<Option<String>> {
let mut long = None;
for attr in field
.attrs
.iter()
.filter(|attr| attr.path().is_ident("arg") || attr.path().is_ident("clap"))
{
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("long") {
if meta.input.peek(syn::Token![=]) {
long = Some(string_literal(&meta, "long", "arg")?.value());
} else {
long = Some(option_name(field)?);
}
}
Ok(())
})?;
}
Ok(long)
}
fn has_value_enum_arg(field: &Field) -> syn::Result<bool> {
let mut has_value_enum = false;
for attr in field
.attrs
.iter()
.filter(|attr| attr.path().is_ident("arg") || attr.path().is_ident("clap"))
{
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("value_enum") {
has_value_enum = true;
}
Ok(())
})?;
}
Ok(has_value_enum)
}
fn has_serde_flatten(field: &Field) -> syn::Result<bool> {
let mut flatten = false;
for attr in field
.attrs
.iter()
.filter(|attr| attr.path().is_ident("serde"))
{
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("flatten") {
flatten = true;
}
Ok(())
})?;
}
Ok(flatten)
}
fn get_inner_type_if_option(ty: &Type) -> Option<&Type> {
let Type::Path(type_path) = ty else {
return None;
};
if type_path.path.segments.len() != 1 || type_path.path.segments[0].ident != "Option" {
return None;
}
let PathArguments::AngleBracketed(args) = &type_path.path.segments[0].arguments else {
return None;
};
if args.args.len() != 1 {
return None;
}
let GenericArgument::Type(inner) = &args.args[0] else {
return None;
};
Some(inner)
}
fn string_literal(
meta: &ParseNestedMeta<'_>,
meta_name: &str,
attribute_name: &str,
) -> syn::Result<syn::LitStr> {
let expr: syn::Expr = meta.value()?.parse()?;
let mut value = &expr;
while let syn::Expr::Group(group) = value {
value = &group.expr;
}
if let syn::Expr::Lit(ExprLit {
lit: Lit::Str(lit), ..
}) = value
{
Ok(lit.clone())
} else {
Err(syn::Error::new(
expr.span(),
format!("expected {attribute_name} attribute to be a string: `{meta_name} = \"...\"`"),
))
}
}
fn bool_literal(
meta: &ParseNestedMeta<'_>,
meta_name: &str,
attribute_name: &str,
) -> syn::Result<bool> {
let expr: syn::Expr = meta.value()?.parse()?;
let mut value = &expr;
while let syn::Expr::Group(group) = value {
value = &group.expr;
}
if let syn::Expr::Lit(ExprLit {
lit: Lit::Bool(lit),
..
}) = value
{
Ok(lit.value)
} else {
Err(syn::Error::new(
expr.span(),
format!("expected {attribute_name} attribute to be a boolean: `{meta_name} = true`"),
))
}
}
fn quote_option_str(value: Option<String>) -> TokenStream {
if let Some(value) = value {
quote!(Some(#value))
} else {
quote!(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_option_attribute_reports_clear_error() {
let input: DeriveInput = syn::parse_quote! {
struct Args {
#[option(unknown = "value")]
field: bool,
}
};
let error = derive_impl(input).expect_err("unknown option key should fail");
assert!(
error
.to_string()
.contains("unsupported `option` metadata key")
);
}
}

View file

@ -0,0 +1,76 @@
#![expect(dead_code, reason = "derive test fixtures are inspected via metadata")]
use clap::ValueEnum;
use fabro_options_metadata::{OptionEntry, OptionsMetadata as _};
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ExecutionMode {
Fast,
Careful,
}
/// Root command options.
#[derive(fabro_macros::OptionsMetadata)]
struct RootArgs {
/// Enable verbose output.
#[arg(long)]
#[option(added_in = "0.213.0")]
verbose_output: bool,
/// Select execution mode.
#[arg(long, value_enum)]
#[option]
mode: Option<ExecutionMode>,
#[option_group]
nested: Option<NestedArgs>,
#[option]
undocumented: bool,
}
#[derive(fabro_macros::OptionsMetadata)]
struct NestedArgs {
/// Preview the work.
#[arg(long)]
#[option]
dry_run: bool,
}
#[test]
fn derive_records_fields_docs_and_value_enum_variants() {
let metadata = RootArgs::metadata();
assert_eq!(RootArgs::documentation(), Some("Root command options."));
assert!(metadata.has("verbose-output"));
assert!(metadata.has("nested.dry-run"));
let Some(OptionEntry::Field(verbose)) = metadata.find("verbose-output") else {
panic!("verbose-output should be a field");
};
assert_eq!(verbose.doc, Some("Enable verbose output."));
assert_eq!(verbose.added_in, Some("0.213.0"));
let Some(OptionEntry::Field(mode)) = metadata.find("mode") else {
panic!("mode should be a field");
};
let values = mode
.possible_values
.expect("value_enum field should record possible values");
assert_eq!(
values
.iter()
.map(|value| value.name.as_str())
.collect::<Vec<_>>(),
["fast", "careful"]
);
}
#[test]
fn derive_allows_missing_field_doc() {
let Some(OptionEntry::Field(field)) = RootArgs::metadata().find("undocumented") else {
panic!("undocumented should be a field");
};
assert_eq!(field.doc, None);
}

View file

@ -0,0 +1,16 @@
[package]
name = "fabro-options-metadata"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Runtime option metadata model for Fabro"
[lints]
workspace = true
[dependencies]
serde.workspace = true
[dev-dependencies]
serde_json.workspace = true

View file

@ -0,0 +1,414 @@
use std::collections::BTreeMap;
use std::fmt::{Debug, Display, Formatter};
use serde::{Serialize, Serializer};
/// Visits [`OptionsMetadata`] entries.
pub trait Visit {
/// Record a single option field.
fn record_field(&mut self, name: &str, field: OptionField);
/// Record a nested option set.
fn record_set(&mut self, name: &str, set: OptionSet);
}
/// Returns metadata for a type's options.
pub trait OptionsMetadata {
/// Visits each option in this type.
fn record(visit: &mut dyn Visit);
/// Returns documentation for the whole option set.
fn documentation() -> Option<&'static str> {
None
}
/// Returns the extracted metadata set.
fn metadata() -> OptionSet
where
Self: Sized + 'static,
{
OptionSet::of::<Self>()
}
}
impl<T> OptionsMetadata for Option<T>
where
T: OptionsMetadata,
{
fn record(visit: &mut dyn Visit) {
T::record(visit);
}
}
/// Metadata for an option entry, either a field or a nested option set.
#[derive(Clone, PartialEq, Eq, Debug, Serialize)]
#[serde(untagged)]
pub enum OptionEntry {
/// A single option.
Field(OptionField),
/// A nested set of options.
Set(OptionSet),
}
impl Display for OptionEntry {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Field(field) => Display::fmt(field, formatter),
Self::Set(set) => Display::fmt(set, formatter),
}
}
}
/// A set of options for a type implementing [`OptionsMetadata`].
#[derive(Copy, Clone)]
pub struct OptionSet {
record: fn(&mut dyn Visit),
doc: fn() -> Option<&'static str>,
}
impl OptionSet {
/// Create an option set for a type.
pub fn of<T>() -> Self
where
T: OptionsMetadata + 'static,
{
Self {
record: T::record,
doc: T::documentation,
}
}
/// Visit each option in this set.
pub fn record(&self, visit: &mut dyn Visit) {
(self.record)(visit);
}
/// Returns documentation for this option set.
pub fn documentation(&self) -> Option<&'static str> {
(self.doc)()
}
/// Returns true if this set contains an option by dotted name.
pub fn has(&self, name: &str) -> bool {
self.find(name).is_some()
}
/// Find an option by dotted name.
pub fn find(&self, name: &str) -> Option<OptionEntry> {
struct FindVisitor<'a> {
entry: Option<OptionEntry>,
needle: &'a str,
parts: std::str::Split<'a, char>,
}
impl Visit for FindVisitor<'_> {
fn record_field(&mut self, name: &str, field: OptionField) {
if self.entry.is_none() && name == self.needle && self.parts.next().is_none() {
self.entry = Some(OptionEntry::Field(field));
}
}
fn record_set(&mut self, name: &str, set: OptionSet) {
if self.entry.is_none() && name == self.needle {
if let Some(next) = self.parts.next() {
self.needle = next;
set.record(self);
} else {
self.entry = Some(OptionEntry::Set(set));
}
}
}
}
let mut parts = name.split('.');
let first = parts.next()?;
let mut visitor = FindVisitor {
entry: None,
needle: first,
parts,
};
self.record(&mut visitor);
visitor.entry
}
}
impl PartialEq for OptionSet {
fn eq(&self, other: &Self) -> bool {
std::ptr::fn_addr_eq(self.record, other.record) && std::ptr::fn_addr_eq(self.doc, other.doc)
}
}
impl Eq for OptionSet {}
impl Display for OptionSet {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
struct DisplayVisitor<'a, 'b> {
formatter: &'a mut Formatter<'b>,
result: std::fmt::Result,
}
impl Visit for DisplayVisitor<'_, '_> {
fn record_field(&mut self, name: &str, field: OptionField) {
self.result = self.result.and_then(|()| {
write!(self.formatter, "{name}")?;
if field.deprecated.is_some() {
write!(self.formatter, " (deprecated)")?;
}
writeln!(self.formatter)
});
}
fn record_set(&mut self, name: &str, _set: OptionSet) {
self.result = self
.result
.and_then(|()| writeln!(self.formatter, "{name}"));
}
}
let mut visitor = DisplayVisitor {
formatter,
result: Ok(()),
};
self.record(&mut visitor);
visitor.result
}
}
impl Debug for OptionSet {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
Display::fmt(self, formatter)
}
}
impl Serialize for OptionSet {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
struct SerializeVisitor<'a> {
entries: &'a mut BTreeMap<String, OptionField>,
}
impl Visit for SerializeVisitor<'_> {
fn record_field(&mut self, name: &str, field: OptionField) {
self.entries.insert(name.to_string(), field);
}
fn record_set(&mut self, name: &str, set: OptionSet) {
let mut nested = BTreeMap::new();
set.record(&mut SerializeVisitor {
entries: &mut nested,
});
for (key, value) in nested {
self.entries.insert(format!("{name}.{key}"), value);
}
}
}
let mut entries = BTreeMap::new();
self.record(&mut SerializeVisitor {
entries: &mut entries,
});
entries.serialize(serializer)
}
}
/// Metadata for a single option field.
#[derive(Debug, Eq, PartialEq, Clone, Serialize)]
pub struct OptionField {
/// Option documentation from doc comments, when present.
pub doc: Option<&'static str>,
/// The option's default value, formatted for docs.
pub default: Option<&'static str>,
/// The option value type, formatted for docs.
pub value_type: Option<&'static str>,
/// Optional scope, for docs that group settings by source.
pub scope: Option<&'static str>,
/// Example usage for the option.
pub example: Option<&'static str>,
/// Deprecation metadata.
pub deprecated: Option<Deprecated>,
/// Possible values for enum-like options.
pub possible_values: Option<Vec<PossibleValue>>,
/// Version where this option was added.
pub added_in: Option<&'static str>,
}
impl Display for OptionField {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
if let Some(doc) = self.doc {
writeln!(formatter, "{doc}")?;
writeln!(formatter)?;
}
if let Some(default) = self.default {
writeln!(formatter, "Default value: {default}")?;
}
if let Some(possible_values) = self
.possible_values
.as_ref()
.filter(|values| !values.is_empty())
{
writeln!(formatter, "Possible values:")?;
for value in possible_values {
writeln!(formatter, "- {value}")?;
}
} else if let Some(value_type) = self.value_type {
writeln!(formatter, "Type: {value_type}")?;
}
if let Some(deprecated) = &self.deprecated {
write!(formatter, "Deprecated")?;
if let Some(since) = deprecated.since {
write!(formatter, " (since {since})")?;
}
if let Some(message) = deprecated.message {
write!(formatter, ": {message}")?;
}
writeln!(formatter)?;
}
if let Some(example) = self.example {
writeln!(formatter, "Example usage:\n```toml\n{example}\n```")?;
}
Ok(())
}
}
/// Deprecation metadata for an option.
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
pub struct Deprecated {
/// Version where the option was deprecated.
pub since: Option<&'static str>,
/// Deprecation message.
pub message: Option<&'static str>,
}
/// A possible value for an enum-like option.
#[derive(Debug, Eq, PartialEq, Clone, Serialize)]
pub struct PossibleValue {
/// Value name as it appears on the CLI or in config.
pub name: String,
/// Optional value help text.
pub help: Option<String>,
}
impl Display for PossibleValue {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
write!(formatter, "`\"{}\"`", self.name)?;
if let Some(help) = &self.help {
write!(formatter, ": {help}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn field(doc: Option<&'static str>) -> OptionField {
OptionField {
doc,
default: None,
value_type: Some("bool"),
scope: None,
example: None,
deprecated: None,
possible_values: None,
added_in: None,
}
}
#[test]
fn option_set_finds_child_and_nested_options() {
struct Root;
struct Nested;
impl OptionsMetadata for Root {
fn record(visit: &mut dyn Visit) {
visit.record_field("verbose", field(Some("Enable verbose output.")));
visit.record_set("nested", Nested::metadata());
}
}
impl OptionsMetadata for Nested {
fn record(visit: &mut dyn Visit) {
visit.record_field("dry-run", field(Some("Preview the work.")));
}
}
assert!(Root::metadata().has("verbose"));
assert!(Root::metadata().has("nested.dry-run"));
assert!(!Root::metadata().has("nested.missing"));
assert!(matches!(
Root::metadata().find("nested"),
Some(OptionEntry::Set(_))
));
assert_eq!(
Root::metadata().find("nested.dry-run"),
Some(OptionEntry::Field(field(Some("Preview the work."))))
);
}
#[test]
fn option_set_display_lists_fields_and_sets() {
struct Root;
struct Nested;
impl OptionsMetadata for Root {
fn record(visit: &mut dyn Visit) {
visit.record_field("verbose", field(Some("Enable verbose output.")));
visit.record_set("nested", Nested::metadata());
}
}
impl OptionsMetadata for Nested {
fn record(_visit: &mut dyn Visit) {}
}
assert_eq!(Root::metadata().to_string(), "verbose\nnested\n");
}
#[test]
fn option_set_serializes_nested_fields_with_dot_keys() {
struct Root;
struct Nested;
impl OptionsMetadata for Root {
fn record(visit: &mut dyn Visit) {
visit.record_set("nested", Nested::metadata());
}
}
impl OptionsMetadata for Nested {
fn record(visit: &mut dyn Visit) {
visit.record_field("dry-run", field(Some("Preview the work.")));
}
}
let json = serde_json::to_value(Root::metadata()).expect("metadata should serialize");
assert_eq!(
json["nested.dry-run"]["doc"],
serde_json::json!("Preview the work.")
);
}
#[test]
fn field_doc_can_be_absent() {
struct Root;
impl OptionsMetadata for Root {
fn record(visit: &mut dyn Visit) {
visit.record_field("undocumented", field(None));
}
}
assert_eq!(
Root::metadata().find("undocumented"),
Some(OptionEntry::Field(field(None)))
);
}
}