#![allow(clippy::format_push_string)]
use std::collections::BTreeMap;
use clap::builder::ValueHint;
use hyperchad_docs_config::{ConfigDocSchema, FieldDoc, NestedFieldDoc};
pub struct CliReference {
root_name: &'static str,
command: clap::Command,
max_depth: usize,
include_usage: bool,
}
impl CliReference {
#[must_use]
pub const fn new(root_name: &'static str, command: clap::Command) -> Self {
Self {
root_name,
command,
max_depth: usize::MAX,
include_usage: true,
}
}
#[must_use]
pub const fn max_depth(mut self, max_depth: usize) -> Self {
self.max_depth = max_depth;
self
}
#[must_use]
pub const fn include_usage(mut self, include_usage: bool) -> Self {
self.include_usage = include_usage;
self
}
#[must_use]
pub fn render(&self) -> String {
let mut doc = String::new();
render_command(
&mut doc,
&self.command,
&[self.root_name],
0,
self.max_depth,
self.include_usage,
);
doc
}
}
pub struct ConfigReference<T: ConfigDocSchema> {
intro: String,
env_overrides: Vec<EnvOverrideDoc>,
appendices: Vec<String>,
section_appendices: Vec<SectionAppendix>,
section_heading_style: SectionHeadingStyle,
option_column_label: &'static str,
_marker: std::marker::PhantomData<T>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SectionHeadingStyle {
Name,
TomlTable,
}
#[derive(Clone)]
struct SectionAppendix {
section_name: String,
markdown: String,
}
impl<T: ConfigDocSchema> ConfigReference<T> {
#[must_use]
pub const fn new() -> Self {
Self {
intro: String::new(),
env_overrides: Vec::new(),
appendices: Vec::new(),
section_appendices: Vec::new(),
section_heading_style: SectionHeadingStyle::Name,
option_column_label: "Key",
_marker: std::marker::PhantomData,
}
}
#[must_use]
pub fn intro(mut self, intro: impl Into<String>) -> Self {
self.intro = intro.into();
self
}
#[must_use]
pub fn env_override(
mut self,
variable: &'static str,
scope: &'static str,
description: &'static str,
) -> Self {
self.env_overrides.push(EnvOverrideDoc {
variable,
scope,
description,
});
self
}
#[must_use]
pub fn env_overrides<I>(mut self, env_overrides: I) -> Self
where
I: IntoIterator<Item = EnvOverrideDoc>,
{
self.env_overrides.extend(env_overrides);
self
}
#[must_use]
pub fn section_appendix(
mut self,
section_name: impl Into<String>,
markdown: impl Into<String>,
) -> Self {
self.section_appendices.push(SectionAppendix {
section_name: section_name.into(),
markdown: markdown.into(),
});
self
}
#[must_use]
pub const fn section_heading_style(mut self, style: SectionHeadingStyle) -> Self {
self.section_heading_style = style;
self
}
#[must_use]
pub const fn toml_table_headings(mut self) -> Self {
self.section_heading_style = SectionHeadingStyle::TomlTable;
self
}
#[must_use]
pub const fn option_column_label(mut self, label: &'static str) -> Self {
self.option_column_label = label;
self
}
#[must_use]
pub fn append_markdown(mut self, markdown: impl Into<String>) -> Self {
self.appendices.push(markdown.into());
self
}
#[must_use]
pub fn render(&self) -> String {
let mut doc = render_config_reference::<T>(
&self.intro,
&self.env_overrides,
&self.section_appendices,
self.section_heading_style,
self.option_column_label,
);
for appendix in &self.appendices {
if !doc.ends_with("\n\n") {
doc.push_str("\n\n");
}
doc.push_str(appendix);
}
doc
}
}
impl<T: ConfigDocSchema> Default for ConfigReference<T> {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone)]
pub struct EnvOverrideDoc {
pub variable: &'static str,
pub scope: &'static str,
pub description: &'static str,
}
#[must_use]
pub fn cli_reference(root_name: &'static str, cmd: clap::Command) -> String {
CliReference::new(root_name, cmd).render()
}
#[allow(clippy::too_many_lines)]
fn render_command(
doc: &mut String,
cmd: &clap::Command,
path: &[&str],
depth: usize,
max_depth: usize,
include_usage: bool,
) {
let full_path = path.join(" ");
let heading = match depth {
0 => "##",
1 => "###",
_ => "####",
};
doc.push_str(&format!("{heading} `{full_path}`\n\n"));
if let Some(about) = cmd.get_about() {
doc.push_str(&format!("{about}\n\n"));
}
let options: Vec<_> = cmd
.get_arguments()
.filter(|arg| !arg.is_hide_set() && arg.get_id() != "help" && arg.get_id() != "version")
.collect();
let positionals: Vec<_> = options.iter().filter(|arg| arg.is_positional()).collect();
let flags: Vec<_> = options.iter().filter(|arg| !arg.is_positional()).collect();
if include_usage && (!positionals.is_empty() || !flags.is_empty()) {
let mut usage = format!("`{full_path}");
for pos in &positionals {
let name = pos.get_id().as_str().to_uppercase();
if pos.is_required_set() {
usage.push_str(&format!(" <{name}>"));
} else {
usage.push_str(&format!(" [{name}]"));
}
}
if !flags.is_empty() {
usage.push_str(" [OPTIONS]");
}
usage.push('`');
doc.push_str(&format!("**Usage:** {usage}\n\n"));
}
if !positionals.is_empty() {
doc.push_str("**Arguments:**\n\n| Name | Description | Required |\n|------|-------------|----------|\n");
for arg in positionals {
let desc = arg.get_help().map(ToString::to_string).unwrap_or_default();
let required = if arg.is_required_set() { "yes" } else { "no" };
doc.push_str(&format!(
"| `{}` | {} | {required} |\n",
escape_markdown_table_cell(arg.get_id().as_str()),
escape_markdown_table_cell(&desc),
));
}
doc.push('\n');
}
if !flags.is_empty() {
doc.push_str("**Options:**\n\n| Flag | Description | Values | Default |\n|------|-------------|--------|---------|\n");
for flag in flags {
let mut names = Vec::new();
if let Some(short) = flag.get_short() {
names.push(format!("-{short}"));
}
if let Some(long) = flag.get_long() {
names.push(format!("--{long}"));
}
let flag_name = if names.is_empty() {
flag.get_id().to_string()
} else {
names.join(", ")
};
let desc = flag.get_help().map(ToString::to_string).unwrap_or_default();
let values = render_possible_values(flag);
let default = flag
.get_default_values()
.iter()
.map(|value| value.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(", ");
let default = if default.is_empty() {
String::new()
} else {
format!("`{}`", escape_inline_code(&default))
};
doc.push_str(&format!(
"| `{}` | {} | {} | {} |\n",
escape_markdown_table_cell(&flag_name),
escape_markdown_table_cell(&desc),
escape_markdown_table_cell(&values),
escape_markdown_table_cell(&default),
));
}
doc.push('\n');
}
let subcommands: Vec<_> = cmd
.get_subcommands()
.filter(|sub| !sub.is_hide_set())
.collect();
if !subcommands.is_empty() && depth < 2 {
doc.push_str("**Subcommands:**\n\n");
for sub in &subcommands {
let desc = sub.get_about().map(ToString::to_string).unwrap_or_default();
doc.push_str(&format!("- `{}` — {desc}\n", sub.get_name()));
}
doc.push('\n');
}
for sub in subcommands {
if depth >= max_depth {
continue;
}
let mut child_path = path.to_vec();
child_path.push(sub.get_name());
render_command(doc, sub, &child_path, depth + 1, max_depth, include_usage);
}
}
fn render_possible_values(arg: &clap::Arg) -> String {
let mut parts = Vec::new();
let possible_values = arg.get_possible_values();
if possible_values.is_empty() {
let value_hint = match arg.get_value_hint() {
ValueHint::FilePath => "file path",
ValueHint::DirPath => "directory path",
ValueHint::Url => "URL",
ValueHint::CommandName | ValueHint::CommandString => "command",
_ => "",
};
if !value_hint.is_empty() {
parts.push(value_hint.to_string());
}
} else {
parts.push(
possible_values
.iter()
.map(|value| format!("`{}`", value.get_name()))
.collect::<Vec<_>>()
.join(", "),
);
}
if is_repeatable(arg) {
parts.push("repeatable".to_string());
}
parts.join(", ")
}
fn is_repeatable(arg: &clap::Arg) -> bool {
matches!(
arg.get_action(),
clap::ArgAction::Append | clap::ArgAction::Count
) || arg
.get_num_args()
.is_some_and(|range| range.max_values() != 1 || range.min_values() > 1)
}
#[must_use]
pub fn config_reference<T: ConfigDocSchema>(
intro: &str,
env_overrides: &[EnvOverrideDoc],
) -> String {
render_config_reference::<T>(intro, env_overrides, &[], SectionHeadingStyle::Name, "Key")
}
fn render_config_reference<T: ConfigDocSchema>(
intro: &str,
env_overrides: &[EnvOverrideDoc],
section_appendices: &[SectionAppendix],
section_heading_style: SectionHeadingStyle,
option_column_label: &str,
) -> String {
let mut doc = String::new();
if !intro.is_empty() {
doc.push_str(intro);
if !intro.ends_with("\n\n") {
doc.push_str("\n\n");
}
}
if !env_overrides.is_empty() {
doc.push_str("## Path & Env Overrides\n\n| Variable | Scope | Behavior |\n|----------|-------|----------|\n");
for override_doc in env_overrides {
doc.push_str(&format!(
"| `{}` | {} | {} |\n",
escape_markdown_table_cell(override_doc.variable),
escape_markdown_table_cell(override_doc.scope),
escape_markdown_table_cell(override_doc.description),
));
}
doc.push_str("\n---\n\n");
}
for field in T::field_docs() {
if let Some(NestedFieldDoc::Inline { fields, defaults }) = field.nested {
let (fields, defaults) = flatten_field_docs(&fields, &defaults, "");
render_section(
&mut doc,
field.toml_key,
field.description,
&fields,
&defaults,
section_heading_style,
option_column_label,
);
append_section_appendices(&mut doc, field.toml_key, section_appendices);
}
}
doc
}
#[derive(Clone)]
struct RenderField {
key: String,
type_display: &'static str,
description: &'static str,
enum_values: Option<&'static [&'static str]>,
}
fn append_section_appendices(
doc: &mut String,
section_name: &str,
section_appendices: &[SectionAppendix],
) {
for appendix in section_appendices
.iter()
.filter(|appendix| appendix.section_name == section_name)
{
if !doc.ends_with("\n\n") {
doc.push_str("\n\n");
}
doc.push_str(&appendix.markdown);
if !doc.ends_with("\n\n") {
doc.push_str("\n\n");
}
}
}
fn render_section(
doc: &mut String,
section_name: &str,
section_description: &str,
fields: &[RenderField],
defaults: &BTreeMap<String, String>,
heading_style: SectionHeadingStyle,
option_column_label: &str,
) {
match heading_style {
SectionHeadingStyle::Name => doc.push_str(&format!("## `{section_name}`\n\n")),
SectionHeadingStyle::TomlTable => doc.push_str(&format!("## `[{section_name}]`\n\n")),
}
if !section_description.is_empty() {
doc.push_str(section_description);
doc.push_str("\n\n");
}
doc.push_str(&format!(
"| {option_column_label} | Type | Default | Description |\n|-----|------|---------|-------------|\n",
));
for field in fields {
let default = defaults.get(&field.key).map_or(String::new(), |value| {
format!("`{}`", escape_inline_code(value))
});
let mut description = field.description.to_string();
if let Some(values) = field.enum_values {
description.push_str(" Valid values: ");
description.push_str(
&values
.iter()
.map(|value| format!("`{value}`"))
.collect::<Vec<_>>()
.join(", "),
);
description.push('.');
}
doc.push_str(&format!(
"| `{}` | `{}` | {} | {} |\n",
escape_markdown_table_cell(&field.key),
escape_markdown_table_cell(field.type_display),
escape_markdown_table_cell(&default),
escape_markdown_table_cell(&description),
));
}
doc.push_str("\n---\n\n");
}
fn flatten_field_docs(
fields: &[FieldDoc],
defaults: &BTreeMap<String, String>,
prefix: &str,
) -> (Vec<RenderField>, BTreeMap<String, String>) {
let mut flattened_fields = Vec::new();
let mut flattened_defaults = BTreeMap::new();
for field in fields {
let full_key = dotted_key(prefix, field.toml_key);
match field.nested.clone() {
Some(NestedFieldDoc::Inline { fields, defaults }) => {
let (child_fields, child_defaults) =
flatten_field_docs(&fields, &defaults, &full_key);
flattened_fields.extend(child_fields);
flattened_defaults.extend(child_defaults);
}
Some(NestedFieldDoc::Map {
key_placeholder,
value_fields,
value_defaults,
}) => {
let map_prefix = dotted_key(&full_key, key_placeholder);
let (child_fields, child_defaults) =
flatten_field_docs(&value_fields, &value_defaults, &map_prefix);
flattened_fields.extend(child_fields);
flattened_defaults.extend(child_defaults);
}
Some(NestedFieldDoc::List {
index_placeholder,
item_fields,
item_defaults,
}) => {
let list_prefix = dotted_key(&full_key, index_placeholder);
let (child_fields, child_defaults) =
flatten_field_docs(&item_fields, &item_defaults, &list_prefix);
flattened_fields.extend(child_fields);
flattened_defaults.extend(child_defaults);
}
Some(NestedFieldDoc::MapValue {
key_placeholder,
value_type_display,
value_description,
value_enum_values,
}) => {
flattened_fields.push(RenderField {
key: dotted_key(&full_key, key_placeholder),
type_display: value_type_display,
description: value_description,
enum_values: value_enum_values,
});
}
Some(NestedFieldDoc::ListValue {
index_placeholder,
item_type_display,
item_description,
item_enum_values,
}) => {
flattened_fields.push(RenderField {
key: dotted_key(&full_key, index_placeholder),
type_display: item_type_display,
description: item_description,
enum_values: item_enum_values,
});
}
None => {
if let Some(default) = defaults.get(field.toml_key) {
flattened_defaults.insert(full_key.clone(), default.clone());
}
flattened_fields.push(RenderField {
key: full_key,
type_display: field.type_display,
description: field.description,
enum_values: field.enum_values,
});
}
}
}
(flattened_fields, flattened_defaults)
}
fn dotted_key(prefix: &str, key: &str) -> String {
if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
}
}
fn escape_markdown_table_cell(value: &str) -> String {
value.replace('|', "\\|").replace('\n', "<br>")
}
fn escape_inline_code(value: &str) -> String {
value.replace('`', "\\`")
}
#[cfg(test)]
mod tests {
use hyperchad_docs_config::{ConfigDocSchema, FieldDoc, NestedFieldDoc};
use std::collections::BTreeMap;
use super::*;
#[derive(Default)]
struct TestConfig;
impl ConfigDocSchema for TestConfig {
fn section_name() -> &'static str {
"server"
}
fn section_description() -> &'static str {
"Server settings."
}
fn default_values() -> BTreeMap<String, String> {
BTreeMap::new()
}
fn field_docs() -> Vec<FieldDoc> {
vec![FieldDoc {
toml_key: "server",
description: "Server settings.",
type_display: "table",
enum_values: None,
nested: Some(NestedFieldDoc::Inline {
fields: vec![FieldDoc {
toml_key: "host",
description: "Bind host.",
type_display: "string",
enum_values: None,
nested: None,
}],
defaults: BTreeMap::from([("host".to_string(), "127.0.0.1".to_string())]),
}),
}]
}
}
#[test]
fn config_reference_appends_section_markdown_after_matching_section() {
let doc = ConfigReference::<TestConfig>::new()
.section_appendix(
"server",
"### Server examples\n\n```toml\n[server]\nhost = \"0.0.0.0\"\n```",
)
.render();
assert!(doc.contains("## `server`"));
assert!(doc.contains("### Server examples"));
assert!(doc.contains("host = \"0.0.0.0\""));
}
#[test]
fn config_reference_flattens_dynamic_map_and_list_values() {
#[derive(Default)]
struct DynamicConfig;
impl ConfigDocSchema for DynamicConfig {
fn section_name() -> &'static str {
"dynamic"
}
fn section_description() -> &'static str {
"Dynamic settings."
}
fn default_values() -> BTreeMap<String, String> {
BTreeMap::new()
}
fn field_docs() -> Vec<FieldDoc> {
vec![FieldDoc {
toml_key: "dynamic",
description: "Dynamic settings.",
type_display: "table",
enum_values: None,
nested: Some(NestedFieldDoc::Inline {
fields: vec![
FieldDoc {
toml_key: "tools",
description: "Per-tool enablement.",
type_display: "table",
enum_values: None,
nested: Some(NestedFieldDoc::MapValue {
key_placeholder: "<tool-id>",
value_type_display: "bool",
value_description: "Enable or disable this tool.",
value_enum_values: None,
}),
},
FieldDoc {
toml_key: "modes",
description: "Mode preference order.",
type_display: "array<string>",
enum_values: None,
nested: Some(NestedFieldDoc::ListValue {
index_placeholder: "<index>",
item_type_display: "string",
item_description: "Mode id.",
item_enum_values: Some(&["auto", "manual"]),
}),
},
],
defaults: BTreeMap::new(),
}),
}]
}
}
let doc = ConfigReference::<DynamicConfig>::new().render();
assert!(doc.contains("tools.<tool-id>"));
assert!(doc.contains("Enable or disable this tool."));
assert!(doc.contains("modes.<index>"));
assert!(doc.contains("`auto`, `manual`"));
}
}