refactor(dev): simplify generated docs tooling

Share the real CLI parser with reference generation, reuse option metadata flattening, and centralize dev command execution helpers.
This commit is contained in:
Bryan Helmkamp 2026-04-24 18:41:00 -04:00
parent a4ee62a8a4
commit 11be286fa1
No known key found for this signature in database
18 changed files with 224 additions and 230 deletions

3
Cargo.lock generated
View file

@ -1784,8 +1784,9 @@ dependencies = [
"fabro-cli",
"fabro-config",
"fabro-options-metadata",
"regex",
"shlex",
"tempfile",
"toml_edit",
"tracing-subscriber",
"walkdir",
]

View file

@ -230,6 +230,7 @@ fabro auth login [OPTIONS]
| Option | Description |
| --- | --- |
| `--dev-token <dev_token>` | Log in with a dev-token instead of browser OAuth |
| `--no-browser` | Print the browser URL instead of opening it automatically |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--timeout <timeout>` | Timeout in seconds waiting for the browser flow to complete<br />Default: `300` |
@ -972,8 +973,8 @@ fabro secret set [OPTIONS] <KEY> [VALUE]
| Option | Description |
| --- | --- |
| `--description <description>` | TODO: add CLI help text. |
| `--type <type>` | Values: `environment`, `file`<br />Default: `environment` |
| `--description <description>` | Optional human-readable description |
| `--type <type>` | Secret storage type<br />Values: `environment`, `file`<br />Default: `environment` |
| `--value-stdin` | Read the secret value from stdin |
### `fabro server`

View file

@ -2,7 +2,7 @@ use std::fmt;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use clap::{Args, Subcommand, ValueEnum};
use clap::{Args, Parser, Subcommand, ValueEnum};
use fabro_agent::cli::AgentArgs;
use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer};
use fabro_server::serve::DEFAULT_TCP_PORT;
@ -21,6 +21,31 @@ pub(crate) const LONG_VERSION: &str = concat!(
")"
);
#[derive(Parser)]
#[command(name = "fabro", version, long_version = LONG_VERSION)]
pub(crate) struct Cli {
#[command(flatten)]
pub(crate) globals: GlobalArgs,
#[command(subcommand)]
pub(crate) command: Option<Box<Commands>>,
}
impl Cli {
pub(crate) fn parse() -> Self {
<Self as Parser>::parse()
}
#[cfg(test)]
pub(crate) fn try_parse_from<I, T>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
<Self as Parser>::try_parse_from(args)
}
}
#[derive(Args)]
pub(crate) struct GlobalArgs {
/// Output as JSON
@ -573,8 +598,10 @@ pub(crate) struct SecretSetArgs {
/// Read the secret value from stdin
#[arg(long, conflicts_with = "value")]
pub(crate) value_stdin: bool,
/// Secret storage type
#[arg(long, value_enum, default_value = "environment")]
pub(crate) r#type: SecretTypeArg,
/// Optional human-readable description
#[arg(long)]
pub(crate) description: Option<String>,
}

View file

@ -5,19 +5,8 @@
mod args;
use args::{Commands, GlobalArgs, LONG_VERSION};
use clap::{Command, CommandFactory, Parser};
#[derive(Parser)]
#[command(name = "fabro", version, long_version = LONG_VERSION)]
struct Cli {
#[command(flatten)]
globals: GlobalArgs,
#[command(subcommand)]
command: Option<Box<Commands>>,
}
use clap::{Command, CommandFactory};
pub fn command_for_reference() -> Command {
Cli::command()
args::Cli::command()
}

View file

@ -18,16 +18,14 @@ mod shared;
mod sleep_inhibitor;
mod user_config;
#[cfg(test)]
use std::ffi::OsString;
use std::fmt::{self, Debug, Display};
use anyhow::Result;
use args::{
Commands, GlobalArgs, LONG_VERSION, RunCommands, ServerCommand, ServerNamespace,
global_args_cli_layer, require_no_json_override,
Cli, Commands, RunCommands, ServerCommand, ServerNamespace, global_args_cli_layer,
require_no_json_override,
};
use clap::{CommandFactory, Parser};
use clap::CommandFactory;
use fabro_static::EnvVars;
use fabro_telemetry::{git, panic as tel_panic, sanitize, sender};
use fabro_util::exit::ExitClass;
@ -39,31 +37,6 @@ use tracing::debug;
use crate::command_context::CommandContext;
#[derive(Parser)]
#[command(name = "fabro", version, long_version = LONG_VERSION)]
struct Cli {
#[command(flatten)]
globals: GlobalArgs,
#[command(subcommand)]
command: Option<Box<Commands>>,
}
impl Cli {
fn parse() -> Self {
<Self as Parser>::parse()
}
#[cfg(test)]
fn try_parse_from<I, T>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
<Self as Parser>::try_parse_from(args)
}
}
#[expect(clippy::print_stderr, reason = "fatal error reporting before exit")]
#[tokio::main]
async fn main() {

View file

@ -64,7 +64,7 @@ pub struct CliAuthLayer {
pub struct CliExecLayer {
/// Prevent idle sleep on macOS while an exec run is in flight.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(name = "prevent_idle_sleep", default = "false", value_type = "boolean")]
#[option(default = "false", value_type = "boolean")]
pub prevent_idle_sleep: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<CliExecModelLayer>,

View file

@ -535,12 +535,11 @@ pub struct RunPullRequestLayer {
/// Enable GitHub auto-merge for created pull requests. Implies `draft =
/// false`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(name = "auto_merge", default = "false", value_type = "boolean")]
#[option(default = "false", value_type = "boolean")]
pub auto_merge: Option<bool>,
/// Merge method to configure for the pull request.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(
name = "merge_strategy",
default = "\"squash\"",
value_type = "\"merge\" | \"squash\" | \"rebase\""
)]

View file

@ -20,7 +20,8 @@ clap.workspace = true
fabro-cli = { path = "../fabro-cli" }
fabro-config = { path = "../fabro-config" }
fabro-options-metadata.workspace = true
regex.workspace = true
shlex = "1"
toml_edit.workspace = true
tracing-subscriber.workspace = true
walkdir.workspace = true

View file

@ -4,7 +4,7 @@ use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::{Args, ValueEnum};
use super::{PlannedCommand, command, shell_arg, workspace_root};
use super::{PlannedCommand, run_command, shell_arg, workspace_root};
const ZIG_VERSION: &str = "0.13.0";
@ -103,7 +103,7 @@ impl DockerBuildPlan {
self.arch.target()
);
let build_command = self.build_command();
self.run_command(&build_command)?;
run_command(&self.workspace_root, &build_command)?;
println!("Extracting binary from builder cache...");
std::fs::create_dir_all(self.context_dir()).with_context(|| {
@ -113,7 +113,7 @@ impl DockerBuildPlan {
)
})?;
let extract_command = self.extract_command();
self.run_command(&extract_command)?;
run_command(&self.workspace_root, &extract_command)?;
if self.compile_only {
println!(
@ -125,7 +125,7 @@ impl DockerBuildPlan {
println!("Building Docker image as {}...", self.tag);
let image_build_command = self.image_build_command();
self.run_command(&image_build_command)
run_command(&self.workspace_root, &image_build_command)
}
fn dry_run_lines(&self) -> Vec<String> {
@ -200,19 +200,6 @@ impl DockerBuildPlan {
.arg(".")
}
fn run_command(&self, planned: &PlannedCommand) -> Result<()> {
let status = command(planned)
.current_dir(&self.workspace_root)
.status()
.with_context(|| format!("running {}", planned.to_shell_line()))?;
if !status.success() {
bail!("command failed with {status}: {}", planned.to_shell_line());
}
Ok(())
}
fn context_dir(&self) -> PathBuf {
self.workspace_root
.join("docker-context")

View file

@ -30,7 +30,7 @@ pub(crate) fn generate_cli_reference(args: GenerateCliReferenceArgs) -> Result<(
let path = root.join(CLI_REFERENCE_PATH);
let current =
std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let generated = render_cli_reference(fabro_cli::command_for_reference());
let generated = render_cli_reference(fabro_cli::command_for_reference())?;
let updated = replace_generated_region(
&current,
&generated,
@ -54,17 +54,22 @@ pub(crate) fn generate_cli_reference(args: GenerateCliReferenceArgs) -> Result<(
Ok(())
}
fn render_cli_reference(mut command: Command) -> String {
fn render_cli_reference(mut command: Command) -> Result<String> {
command.build();
let mut output = String::new();
render_command(&mut output, &command, &[], 2);
output.trim_end().to_string()
render_command(&mut output, &command, &[], 2)?;
Ok(output.trim_end().to_string())
}
fn render_command(output: &mut String, command: &Command, parents: &[&str], level: usize) {
fn render_command(
output: &mut String,
command: &Command,
parents: &[&str],
level: usize,
) -> Result<()> {
if command.is_hide_set() {
return;
return Ok(());
}
let path = command_path(command, parents);
@ -91,7 +96,7 @@ fn render_command(output: &mut String, command: &Command, parents: &[&str], leve
output.push_str("| `");
output.push_str(&argument_name(arg));
output.push_str("` | ");
output.push_str(&arg_help(arg));
output.push_str(&arg_help(arg, &path)?);
output.push_str(" |\n");
}
output.push('\n');
@ -106,7 +111,7 @@ fn render_command(output: &mut String, command: &Command, parents: &[&str], leve
output.push_str("| `");
output.push_str(&option_name(arg));
output.push_str("` | ");
output.push_str(&arg_help(arg));
output.push_str(&arg_help(arg, &path)?);
output.push_str(" |\n");
}
output.push('\n');
@ -135,8 +140,10 @@ fn render_command(output: &mut String, command: &Command, parents: &[&str], leve
let mut next_parents = parents.to_vec();
next_parents.push(command.get_name());
for subcommand in visible_subcommands {
render_command(output, subcommand, &next_parents, level + 1);
render_command(output, subcommand, &next_parents, level + 1)?;
}
Ok(())
}
fn command_path(command: &Command, parents: &[&str]) -> String {
@ -216,7 +223,7 @@ fn option_takes_value(arg: &Arg) -> bool {
arg.get_num_args().is_some_and(|range| range.takes_values())
}
fn arg_help(arg: &Arg) -> String {
fn arg_help(arg: &Arg, command_path: &str) -> Result<String> {
let mut parts = Vec::new();
if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) {
let help = help.to_string();
@ -248,10 +255,12 @@ fn arg_help(arg: &Arg) -> String {
}
if parts.is_empty() {
"TODO: add CLI help text.".to_string()
} else {
parts.join("<br />")
bail!(
"{command_path} argument `{}` is missing help text",
arg.get_id()
)
}
Ok(parts.join("<br />"))
}
fn is_boolean_switch(arg: &Arg) -> bool {

View file

@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use fabro_options_metadata::{OptionField, OptionSet, Visit};
use fabro_options_metadata::{OptionField, OptionSet};
use super::{markdown_cell, replace_generated_region, workspace_root};
@ -159,7 +159,7 @@ fn render_section(output: &mut String, section: &Section) {
output.push_str("```toml title=\"settings.toml\"\n");
output.push_str(section.example);
output.push_str("\n```\n\n");
render_field_table(output, collect_fields(section.set));
render_field_table(output, section.set.fields());
}
fn render_field_table(output: &mut String, fields: BTreeMap<String, OptionField>) {
@ -181,35 +181,6 @@ fn render_field_table(output: &mut String, fields: BTreeMap<String, OptionField>
output.push('\n');
}
fn collect_fields(set: OptionSet) -> BTreeMap<String, OptionField> {
struct CollectVisitor<'a> {
prefix: String,
entries: &'a mut BTreeMap<String, OptionField>,
}
impl Visit for CollectVisitor<'_> {
fn record_field(&mut self, name: &str, field: OptionField) {
self.entries
.insert(format!("{}{}", self.prefix, name), field);
}
fn record_set(&mut self, name: &str, set: OptionSet) {
let previous = self.prefix.clone();
self.prefix.push_str(name);
self.prefix.push('.');
set.record(self);
self.prefix = previous;
}
}
let mut entries = BTreeMap::new();
set.record(&mut CollectVisitor {
prefix: String::new(),
entries: &mut entries,
});
entries
}
fn field_type(field: &OptionField) -> String {
if let Some(possible_values) = field
.possible_values

View file

@ -6,7 +6,7 @@ mod refresh_spa;
mod release;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::{Command, Output};
use anyhow::{Context, Result};
pub(crate) use check_spa_budgets::{CheckSpaBudgetsArgs, check_spa_budgets};
@ -123,14 +123,29 @@ pub(crate) fn command(planned: &PlannedCommand) -> Command {
command
}
pub(crate) fn shell_arg(arg: impl AsRef<str>) -> String {
let arg = arg.as_ref();
if arg
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || "_-./:=@".contains(ch))
{
return arg.to_string();
pub(crate) fn run_command(cwd: &Path, planned: &PlannedCommand) -> Result<()> {
let status = command(planned)
.current_dir(cwd)
.status()
.with_context(|| format!("running {}", planned.to_shell_line()))?;
if !status.success() {
anyhow::bail!("command failed with {status}: {}", planned.to_shell_line());
}
format!("'{}'", arg.replace('\'', "'\\''"))
Ok(())
}
pub(crate) fn capture_command(cwd: &Path, planned: &PlannedCommand) -> Result<Output> {
command(planned)
.current_dir(cwd)
.output()
.with_context(|| format!("running {}", planned.to_shell_line()))
}
pub(crate) fn shell_arg(arg: impl AsRef<str>) -> String {
let arg = arg.as_ref();
shlex::try_quote(arg).map_or_else(
|_| format!("'{}'", arg.replace('\'', "'\\''")),
std::borrow::Cow::into_owned,
)
}

View file

@ -17,17 +17,21 @@ pub(crate) struct RefreshSpaArgs {
skip_build: bool,
}
pub(crate) fn refresh_spa(args: RefreshSpaArgs) -> Result<()> {
let root = args.root.unwrap_or_else(workspace_root);
refresh_spa_root(&root, args.skip_build)
}
#[expect(
clippy::print_stdout,
reason = "dev refresh-spa command reports progress directly"
)]
pub(crate) fn refresh_spa(args: RefreshSpaArgs) -> Result<()> {
let root = args.root.unwrap_or_else(workspace_root);
pub(super) fn refresh_spa_root(root: &Path, skip_build: bool) -> Result<()> {
let web_dir = root.join("apps/fabro-web");
let dist_dir = web_dir.join("dist");
let asset_dir = root.join("lib/crates/fabro-spa/assets");
if !args.skip_build {
if !skip_build {
println!("Running bun run build in apps/fabro-web...");
run_bun_build(&web_dir)?;
}

View file

@ -1,12 +1,12 @@
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Output;
use anyhow::{Context, Result, bail};
use chrono::{Local, NaiveDate};
use clap::{Args, ValueEnum};
use super::{PlannedCommand, command, workspace_root};
use super::refresh_spa::refresh_spa_root;
use super::{PlannedCommand, capture_command, run_command, workspace_root};
const RELEASE_EPOCH: &str = "2026-01-01";
const RELEASE_TEST_SEGMENT_WRITE_KEY: &str = "fake-for-local-smoke";
@ -86,26 +86,30 @@ pub(crate) fn release(args: ReleaseArgs) -> Result<()> {
update_version(&cargo_toml, &current_version, &new_version)?;
println!("Updated {}", cargo_toml.display());
plan.run_command(
run_command(
&plan.root,
&PlannedCommand::new("cargo")
.arg("update")
.arg("--workspace"),
)?;
println!("Updated Cargo.lock");
plan.run_command(
run_command(
&plan.root,
&PlannedCommand::new("git")
.arg("add")
.arg("Cargo.toml")
.arg("Cargo.lock"),
)?;
plan.run_command(
run_command(
&plan.root,
&PlannedCommand::new("git")
.arg("commit")
.arg("-m")
.arg(format!("Bump version to {new_version}")),
)?;
plan.run_command(
run_command(
&plan.root,
&PlannedCommand::new("git")
.arg("tag")
.arg("-a")
@ -113,7 +117,8 @@ pub(crate) fn release(args: ReleaseArgs) -> Result<()> {
.arg("-m")
.arg(&tag),
)?;
plan.run_command(
run_command(
&plan.root,
&PlannedCommand::new("git")
.arg("push")
.arg("origin")
@ -167,7 +172,8 @@ impl ReleasePlan {
}
fn ensure_clean_worktree(&self) -> Result<()> {
let output = self.capture_command(
let output = capture_command(
&self.root,
&PlannedCommand::new("git")
.arg("status")
.arg("--porcelain")
@ -187,8 +193,8 @@ impl ReleasePlan {
}
fn verify_spa_assets(&self) -> Result<()> {
self.run_command(&Self::refresh_spa_command())?;
let output = self.capture_command(&Self::spa_assets_diff_command())?;
refresh_spa_root(&self.root, false)?;
let output = capture_command(&self.root, &Self::spa_assets_diff_command())?;
if !output.status.success() {
bail!("fabro-spa assets are stale. Commit the refreshed assets before releasing.");
}
@ -207,7 +213,7 @@ impl ReleasePlan {
}
println!("Running release-mode test smoke (SEGMENT_WRITE_KEY baked in)...");
self.run_command(&Self::release_tests_command())
run_command(&self.root, &Self::release_tests_command())
}
#[expect(
@ -283,7 +289,8 @@ impl ReleasePlan {
}
fn tag_exists(&self, tag: &str) -> Result<bool> {
let output = self.capture_command(
let output = capture_command(
&self.root,
&PlannedCommand::new("git")
.arg("rev-parse")
.arg("--verify")
@ -292,25 +299,6 @@ impl ReleasePlan {
)?;
Ok(output.status.success())
}
fn run_command(&self, planned: &PlannedCommand) -> Result<()> {
let status = command(planned)
.current_dir(&self.root)
.status()
.with_context(|| format!("running {}", planned.to_shell_line()))?;
if !status.success() {
bail!("command failed with {status}: {}", planned.to_shell_line());
}
Ok(())
}
fn capture_command(&self, planned: &PlannedCommand) -> Result<Output> {
command(planned)
.current_dir(&self.root)
.output()
.with_context(|| format!("running {}", planned.to_shell_line()))
}
}
#[expect(
@ -320,20 +308,10 @@ impl ReleasePlan {
fn read_current_version(cargo_toml: &Path) -> Result<String> {
let contents = std::fs::read_to_string(cargo_toml)
.with_context(|| format!("reading {}", cargo_toml.display()))?;
for line in contents.lines().map(str::trim) {
let Some(rest) = line.strip_prefix("version = \"") else {
continue;
};
let Some(version) = rest.strip_suffix('"') else {
continue;
};
return Ok(version.to_string());
}
bail!(
"could not find workspace package version in {}",
cargo_toml.display()
)
let manifest = contents
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("parsing {}", cargo_toml.display()))?;
workspace_package_version(&manifest, cargo_toml).map(ToOwned::to_owned)
}
#[expect(
@ -343,15 +321,32 @@ fn read_current_version(cargo_toml: &Path) -> Result<String> {
fn update_version(cargo_toml: &Path, current_version: &str, new_version: &str) -> Result<()> {
let contents = std::fs::read_to_string(cargo_toml)
.with_context(|| format!("reading {}", cargo_toml.display()))?;
let needle = format!("version = \"{current_version}\"");
let replacement = format!("version = \"{new_version}\"");
if !contents.contains(&needle) {
let mut manifest = contents
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("parsing {}", cargo_toml.display()))?;
let version = workspace_package_version(&manifest, cargo_toml)?;
if version != current_version {
bail!(
"could not find current version {current_version} in {}",
cargo_toml.display()
);
}
std::fs::write(cargo_toml, contents.replacen(&needle, &replacement, 1))
manifest["workspace"]["package"]["version"] = toml_edit::value(new_version);
std::fs::write(cargo_toml, manifest.to_string())
.with_context(|| format!("writing {}", cargo_toml.display()))
}
fn workspace_package_version<'a>(
manifest: &'a toml_edit::DocumentMut,
cargo_toml: &Path,
) -> Result<&'a str> {
manifest["workspace"]["package"]["version"]
.as_str()
.with_context(|| {
format!(
"could not find [workspace.package] version in {}",
cargo_toml.display()
)
})
}

View file

@ -47,10 +47,6 @@ Tail copy.
contents.contains("### `fabro run`"),
"generated output should include subcommand reference:\n{contents}"
);
assert!(
contents.contains("TODO: add CLI help text."),
"undocumented clap args should be visible follow-up work:\n{contents}"
);
assert!(
!contents.contains("stale"),
"stale generated content should be replaced:\n{contents}"

View file

@ -1,5 +1,4 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
mod docker_build;
mod generate_cli_reference;
@ -42,19 +41,6 @@ fn read_file(root: &Path, path: &str) -> String {
std::fs::read_to_string(root.join(path)).expect("reading fixture file")
}
#[expect(
clippy::disallowed_methods,
reason = "integration test intentionally shells out to Cargo to verify the cargo dev alias"
)]
fn cargo_dev(args: &[&str]) -> Output {
Command::new("cargo")
.arg("dev")
.args(args)
.current_dir(workspace_root())
.output()
.expect("cargo dev should run")
}
#[test]
fn help_lists_scaffolded_commands() {
let output = fabro_dev()
@ -81,20 +67,11 @@ fn help_lists_scaffolded_commands() {
}
#[test]
fn cargo_dev_alias_resolves_to_fabro_dev_help() {
let output = cargo_dev(&["--help"]);
fn cargo_dev_alias_points_at_fabro_dev() {
let config = read_file(&workspace_root(), ".cargo/config.toml");
assert!(
output.status.success(),
"cargo dev --help failed\nstdout:\n{}\nstderr:\n{}",
output_text(&output.stdout),
output_text(&output.stderr)
);
let stdout = output_text(&output.stdout);
assert!(
stdout.contains("docker-build"),
"cargo dev help should come from fabro-dev:\n{stdout}"
config.contains(r#"dev = "run --package fabro-dev --""#),
"cargo dev alias should invoke fabro-dev:\n{config}"
);
}

View file

@ -81,7 +81,7 @@ fn handle_option(field: &Field, attr: &Attribute) -> syn::Result<TokenStream> {
.name
.clone()
.or(option_long_name(field)?)
.unwrap_or_else(|| ident.to_string().replace('_', "-"));
.unwrap_or_else(|| ident.to_string());
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);
@ -409,4 +409,17 @@ mod tests {
.contains("unsupported `option` metadata key")
);
}
#[test]
fn option_metadata_defaults_to_field_name_without_clap_long() {
let input: DeriveInput = syn::parse_quote! {
struct Args {
#[option]
prevent_idle_sleep: bool,
}
};
let tokens = derive_impl(input).expect("metadata should derive");
assert!(tokens.to_string().contains("\"prevent_idle_sleep\""));
}
}

View file

@ -130,6 +130,36 @@ impl OptionSet {
self.record(&mut visitor);
visitor.entry
}
/// Returns all field entries flattened by dotted name.
pub fn fields(&self) -> BTreeMap<String, OptionField> {
struct FieldsVisitor<'a> {
entries: &'a mut BTreeMap<String, OptionField>,
prefix: String,
}
impl Visit for FieldsVisitor<'_> {
fn record_field(&mut self, name: &str, field: OptionField) {
self.entries
.insert(format!("{}{}", self.prefix, name), field);
}
fn record_set(&mut self, name: &str, set: OptionSet) {
let previous = self.prefix.clone();
self.prefix.push_str(name);
self.prefix.push('.');
set.record(self);
self.prefix = previous;
}
}
let mut entries = BTreeMap::new();
self.record(&mut FieldsVisitor {
entries: &mut entries,
prefix: String::new(),
});
entries
}
}
impl PartialEq for OptionSet {
@ -185,31 +215,7 @@ impl Serialize for OptionSet {
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)
self.fields().serialize(serializer)
}
}
@ -396,6 +402,36 @@ mod tests {
);
}
#[test]
fn option_set_fields_flatten_nested_fields_with_dot_keys() {
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.")));
}
}
let fields = Root::metadata().fields();
assert_eq!(fields.len(), 2);
assert_eq!(
fields.get("nested.dry-run"),
Some(&field(Some("Preview the work.")))
);
assert_eq!(
fields.get("verbose"),
Some(&field(Some("Enable verbose output.")))
);
}
#[test]
fn field_doc_can_be_absent() {
struct Root;