diff --git a/Cargo.lock b/Cargo.lock index 21ad125e4..ea165ef35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index c4becce7a..b5fed2e2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md b/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md index 0b2ac1da9..e7a7c5ce9 100644 --- a/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md +++ b/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md @@ -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. diff --git a/lib/crates/fabro-macros/Cargo.toml b/lib/crates/fabro-macros/Cargo.toml index e322ac5cc..80d0ba207 100644 --- a/lib/crates/fabro-macros/Cargo.toml +++ b/lib/crates/fabro-macros/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-macros/src/lib.rs b/lib/crates/fabro-macros/src/lib.rs index 31007fd75..bf3632bfb 100644 --- a/lib/crates/fabro-macros/src/lib.rs +++ b/lib/crates/fabro-macros/src/lib.rs @@ -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 { diff --git a/lib/crates/fabro-macros/src/options_metadata.rs b/lib/crates/fabro-macros/src/options_metadata.rs new file mode 100644 index 000000000..7eadba5be --- /dev/null +++ b/lib/crates/fabro-macros/src/options_metadata.rs @@ -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 { + 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 { + 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 { + 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 { + let ident = field_ident(field)?; + Ok(ident.to_string().replace('_', "-")) +} + +fn doc_string(attrs: &[Attribute]) -> syn::Result> { + let docs = attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .map(parse_doc) + .collect::>>()?; + let doc = docs + .into_iter() + .map(|line| line.trim().to_string()) + .collect::>() + .join("\n") + .trim_matches('\n') + .to_string(); + + if doc.is_empty() { + Ok(None) + } else { + Ok(Some(doc)) + } +} + +fn parse_doc(attr: &Attribute) -> syn::Result { + 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, + default: Option, + value_type: Option, + scope: Option, + example: Option, + possible_values: Option, + added_in: Option, +} + +fn parse_option_attributes(attr: &Attribute) -> syn::Result { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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) -> 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") + ); + } +} diff --git a/lib/crates/fabro-macros/tests/options_metadata.rs b/lib/crates/fabro-macros/tests/options_metadata.rs new file mode 100644 index 000000000..53b83af1a --- /dev/null +++ b/lib/crates/fabro-macros/tests/options_metadata.rs @@ -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, + + #[option_group] + nested: Option, + + #[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::>(), + ["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); +} diff --git a/lib/crates/fabro-options-metadata/Cargo.toml b/lib/crates/fabro-options-metadata/Cargo.toml new file mode 100644 index 000000000..b81be493a --- /dev/null +++ b/lib/crates/fabro-options-metadata/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-options-metadata/src/lib.rs b/lib/crates/fabro-options-metadata/src/lib.rs new file mode 100644 index 000000000..fbd3b4657 --- /dev/null +++ b/lib/crates/fabro-options-metadata/src/lib.rs @@ -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::() + } +} + +impl OptionsMetadata for Option +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() -> 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 { + struct FindVisitor<'a> { + entry: Option, + 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(&self, serializer: S) -> Result + where + S: Serializer, + { + struct SerializeVisitor<'a> { + entries: &'a mut BTreeMap, + } + + 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, + /// Possible values for enum-like options. + pub possible_values: Option>, + /// 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, +} + +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))) + ); + } +}