//! Help text generation for command-line interfaces.
//!
//! This module provides utilities to generate help text from Schema,
//! including doc comments, field names, and attribute information.
use crate::missing::normalize_program_name;
use crate::schema::{
ArgLevelSchema, ArgSchema, ConfigFieldGroupSchema, ConfigFieldSchema, ConfigStructSchema,
ConfigValueSchema, Docs, Schema, Subcommand,
};
use facet_core::Facet;
use heck::ToKebabCase;
use owo_colors::OwoColorize;
use owo_colors::Stream::Stdout;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::string::String;
use std::time::{SystemTime, UNIX_EPOCH};
use std::vec::Vec;
const DEFAULT_CONFIG_FILE_EXTENSIONS: &[&str] = &["json"];
/// Generate help text for a Facet type.
///
/// This is a convenience function that builds a Schema internally.
/// If you already have a Schema, use `generate_help_for_subcommand` instead.
pub fn generate_help<T: Facet<'static>>(config: &HelpConfig) -> String {
generate_help_for_shape(T::SHAPE, config)
}
/// Generate help text from a Shape.
///
/// This is a convenience function that builds a Schema internally.
/// If you already have a Schema, use `generate_help_for_subcommand` instead.
pub fn generate_help_for_shape(shape: &'static facet_core::Shape, config: &HelpConfig) -> String {
let schema = match Schema::from_shape(shape) {
Ok(s) => s,
Err(_) => {
// Fall back to a minimal help message
let program_name = config
.program_name
.clone()
.or_else(|| {
std::env::args()
.next()
.map(|path| normalize_program_name(&path))
})
.unwrap_or_else(|| "program".to_string());
return format!(
"{}\n\n(Schema could not be built for this type)\n",
program_name
);
}
};
generate_help_for_subcommand(&schema, &[], config)
}
/// Generate HTML help for a Facet type.
///
/// This uses the same schema data as terminal help, but renders it as a
/// standalone HTML document suitable for writing to disk or sharing.
pub fn generate_html_help<T: Facet<'static>>(config: &HelpConfig) -> String {
generate_html_help_for_shape(T::SHAPE, config)
}
/// Generate HTML help from a Shape.
pub fn generate_html_help_for_shape(
shape: &'static facet_core::Shape,
config: &HelpConfig,
) -> String {
let schema = match Schema::from_shape(shape) {
Ok(s) => s,
Err(_) => {
let program_name = resolve_program_name(config);
return render_minimal_html_help(
&program_name,
config.version.as_deref(),
"(Schema could not be built for this type)",
);
}
};
generate_html_help_for_subcommand(&schema, &[], config)
}
/// Generate HTML help for a specific subcommand path from a Schema.
pub fn generate_html_help_for_subcommand(
schema: &Schema,
subcommand_path: &[String],
config: &HelpConfig,
) -> String {
generate_html_help_for_subcommand_with_config_formats(
schema,
subcommand_path,
config,
DEFAULT_CONFIG_FILE_EXTENSIONS,
)
}
pub(crate) fn generate_html_help_for_subcommand_with_config_formats(
schema: &Schema,
subcommand_path: &[String],
config: &HelpConfig,
config_file_extensions: &[&str],
) -> String {
let program_name = resolve_program_name(config);
if subcommand_path.is_empty() {
return render_html_help_document(HtmlHelpDocument {
command: &program_name,
version: config.version.as_deref(),
docs: schema.docs(),
description: config.description.as_deref(),
args: schema.args(),
config_roots: schema.configs(),
config_file_extensions,
});
}
let mut current_args = schema.args();
let mut command_path = vec![program_name.clone()];
let mut final_sub: Option<&Subcommand> = None;
for name in subcommand_path {
let Some(sub) = current_args
.subcommands()
.values()
.find(|sub| sub.effective_name() == name)
else {
return render_html_help_document(HtmlHelpDocument {
command: &program_name,
version: config.version.as_deref(),
docs: schema.docs(),
description: config.description.as_deref(),
args: schema.args(),
config_roots: schema.configs(),
config_file_extensions,
});
};
command_path.push(sub.cli_name().to_string());
current_args = sub.args();
final_sub = Some(sub);
}
render_html_help_document(HtmlHelpDocument {
command: &command_path.join(" "),
version: None,
docs: final_sub
.map(Subcommand::docs)
.unwrap_or_else(|| schema.docs()),
description: config.description.as_deref(),
args: current_args,
config_roots: &[],
config_file_extensions: DEFAULT_CONFIG_FILE_EXTENSIONS,
})
}
/// Write an HTML help document to a unique file under the system temp directory.
///
/// The directory is intentionally not deleted when this function returns because
/// browsers may read the file after the process exits.
pub fn write_html_help_to_temp_file(html: &str) -> io::Result<PathBuf> {
let path = html_help_temp_dir().join("index.html");
let dir = path
.parent()
.ok_or_else(|| io::Error::other("could not build HTML help path"))?;
fs::create_dir_all(dir)?;
fs::write(&path, html)?;
Ok(path)
}
/// Open an HTML help file in the user's default browser.
pub fn open_html_help_file(path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref();
let status = open_file_command(path).status()?;
if status.success() {
Ok(())
} else {
Err(io::Error::other(format!(
"browser opener exited with status {status}"
)))
}
}
/// Configuration for help text generation.
#[derive(Debug, Clone)]
pub struct HelpConfig {
/// Program name (defaults to executable name)
pub program_name: Option<String>,
/// Program version
pub version: Option<String>,
/// Additional description to show after the auto-generated one
pub description: Option<String>,
/// Width for wrapping text (0 = no wrapping)
pub width: usize,
}
impl Default for HelpConfig {
fn default() -> Self {
Self {
program_name: None,
version: None,
description: None,
width: 80,
}
}
}
/// Generate help text for a specific subcommand path from a Schema.
///
/// `subcommand_path` is a list of subcommand names (e.g., `["repo", "clone"]` for `myapp repo clone --help`).
/// This navigates through the schema to find the target subcommand and generates help for it.
pub fn generate_help_for_subcommand(
schema: &Schema,
subcommand_path: &[String],
config: &HelpConfig,
) -> String {
generate_help_for_subcommand_with_config_formats(
schema,
subcommand_path,
config,
DEFAULT_CONFIG_FILE_EXTENSIONS,
)
}
pub(crate) fn generate_help_for_subcommand_with_config_formats(
schema: &Schema,
subcommand_path: &[String],
config: &HelpConfig,
config_file_extensions: &[&str],
) -> String {
let program_name = config
.program_name
.clone()
.or_else(|| {
std::env::args()
.next()
.map(|path| normalize_program_name(&path))
})
.unwrap_or_else(|| "program".to_string());
if subcommand_path.is_empty() {
return generate_help_from_schema(schema, &program_name, config, config_file_extensions);
}
// Navigate to the subcommand
let mut current_args = schema.args();
let mut command_path = vec![program_name.clone()];
for name in subcommand_path {
// The path contains effective names (e.g., "Clone", "rm") from ConfigValue.
// Look up by effective_name since that's what's stored in the path.
let sub = current_args
.subcommands()
.values()
.find(|s| s.effective_name() == name);
if let Some(sub) = sub {
command_path.push(sub.cli_name().to_string());
current_args = sub.args();
} else {
// Subcommand not found, fall back to root help
return generate_help_from_schema(
schema,
&program_name,
config,
config_file_extensions,
);
}
}
// Find the final subcommand to get its docs
let mut final_sub: Option<&Subcommand> = None;
let mut args = schema.args();
for name in subcommand_path {
let sub = args
.subcommands()
.values()
.find(|s| s.effective_name() == name);
if let Some(sub) = sub {
final_sub = Some(sub);
args = sub.args();
}
}
generate_help_for_subcommand_level(current_args, final_sub, &command_path.join(" "), config)
}
/// Generate help from a built Schema.
fn generate_help_from_schema(
schema: &Schema,
program_name: &str,
config: &HelpConfig,
config_file_extensions: &[&str],
) -> String {
let mut out = String::new();
// Program name and version
if let Some(version) = &config.version {
out.push_str(&format!("{program_name} {version}\n"));
} else {
out.push_str(&format!("{program_name}\n"));
}
// Type doc comment from schema
if let Some(summary) = schema.docs().summary() {
out.push('\n');
out.push_str(summary.trim());
out.push('\n');
}
if let Some(details) = schema.docs().details() {
for line in details.lines() {
out.push_str(line.trim());
out.push('\n');
}
}
// Additional description
if let Some(desc) = &config.description {
out.push('\n');
out.push_str(desc);
out.push('\n');
}
out.push('\n');
generate_arg_level_help(
&mut out,
schema.args(),
schema.configs(),
program_name,
config,
config_file_extensions,
);
out
}
/// Generate help for a subcommand level.
fn generate_help_for_subcommand_level(
args: &ArgLevelSchema,
subcommand: Option<&Subcommand>,
full_command: &str,
config: &HelpConfig,
) -> String {
let mut out = String::new();
// Header with full command
out.push_str(&format!("{full_command}\n"));
// Doc comment for the subcommand
if let Some(sub) = subcommand {
if let Some(summary) = sub.docs().summary() {
out.push('\n');
out.push_str(summary.trim());
out.push('\n');
}
if let Some(details) = sub.docs().details() {
for line in details.lines() {
out.push_str(line.trim());
out.push('\n');
}
}
}
// Additional description from config
if let Some(desc) = &config.description {
out.push('\n');
out.push_str(desc);
out.push('\n');
}
out.push('\n');
generate_arg_level_help(
&mut out,
args,
&[],
full_command,
config,
DEFAULT_CONFIG_FILE_EXTENSIONS,
);
out
}
fn resolve_program_name(config: &HelpConfig) -> String {
config
.program_name
.clone()
.or_else(|| {
std::env::args()
.next()
.map(|path| normalize_program_name(&path))
})
.unwrap_or_else(|| "program".to_string())
}
fn html_help_temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
std::env::temp_dir()
.join("figue-help-tabs")
.join(format!("{}-{nanos}", std::process::id()))
}
#[cfg(target_os = "macos")]
fn open_file_command(path: &Path) -> Command {
let mut command = Command::new("open");
command.arg(path);
command
}
#[cfg(target_os = "windows")]
fn open_file_command(path: &Path) -> Command {
let mut command = Command::new("cmd");
command.arg("/C").arg("start").arg("").arg(path);
command
}
#[cfg(all(unix, not(target_os = "macos")))]
fn open_file_command(path: &Path) -> Command {
let mut command = Command::new("xdg-open");
command.arg(path);
command
}
struct HtmlHelpDocument<'a> {
command: &'a str,
version: Option<&'a str>,
docs: &'a crate::schema::Docs,
description: Option<&'a str>,
args: &'a ArgLevelSchema,
config_roots: &'a [ConfigStructSchema],
config_file_extensions: &'a [&'a str],
}
fn render_minimal_html_help(command: &str, version: Option<&str>, message: &str) -> String {
let docs = crate::schema::Docs::default();
let args = ArgLevelSchema::default();
render_html_help_document(HtmlHelpDocument {
command,
version,
docs: &docs,
description: Some(message),
args: &args,
config_roots: &[],
config_file_extensions: DEFAULT_CONFIG_FILE_EXTENSIONS,
})
}
fn render_html_help_document(doc: HtmlHelpDocument<'_>) -> String {
let title = match doc.version {
Some(version) => format!("{} {}", doc.command, version),
None => doc.command.to_string(),
};
let mut out = String::new();
out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
out.push_str(" <meta charset=\"utf-8\">\n");
out.push_str(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
out.push_str(" <title>");
push_escaped(&mut out, &title);
out.push_str("</title>\n");
out.push_str(" <style>\n");
out.push_str(
" @font-face { font-family: 'Inter Variable'; font-style: normal; font-display: swap; font-weight: 100 900; src: url(https://cdn.jsdelivr.net/fontsource/fonts/inter:vf@latest/latin-wght-normal.woff2) format('woff2-variations'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }\n",
);
out.push_str(
" @font-face { font-family: 'Maple Mono'; font-style: normal; font-display: swap; font-weight: 400; src: url(https://cdn.jsdelivr.net/fontsource/fonts/maple-mono@latest/maple-mono-latin-400-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/maple-mono@latest/maple-mono-latin-400-normal.woff) format('woff'); }\n",
);
out.push_str(
" :root { color-scheme: light dark; interpolate-size: allow-keywords; --bg: light-dark(#f8f7f3, #171819); --fg: light-dark(#202124, #f1efe7); --muted: light-dark(#68645d, #b8b2a7); --panel: light-dark(#ffffff, #212325); --line: light-dark(#dedbd2, #34373a); --accent: light-dark(#0f766e, #5eead4); --code: light-dark(#f0eee7, #2b2d2f); --soft: light-dark(#edf7f5, #183532); --value-bg: light-dark(#fff4d6, #3a2f18); --value-fg: light-dark(#8a4b0b, #fbbf24); --env-bg: light-dark(#e9f7ef, #143321); --env-fg: light-dark(#166534, #86efac); --search-bg: light-dark(#fde68a, #43330c); --search-fg: light-dark(#111827, #fff7d6); --search-current-bg: light-dark(#f59e0b, #b45309); --search-current-fg: light-dark(#111827, #fff7d6); }\n",
);
out.push_str(
" * { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.62 \"Inter Variable\", Inter, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; text-rendering: optimizeLegibility; }\n",
);
out.push_str(
" .topbar { position: sticky; top: 0; z-index: 10; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(12px); }\n",
);
out.push_str(
" .topbar-inner { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 10px 0 6px; display: grid; grid-template-columns: minmax(160px, auto) minmax(240px, 1fr); gap: 16px; align-items: center; } .topbar-title { font-weight: 750; font-size: 1rem; white-space: nowrap; }\n",
);
out.push_str(
" .breadcrumbs { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 0 0 9px; color: var(--muted); font-size: .82rem; overflow-x: auto; scrollbar-width: thin; } .breadcrumbs ol { display: flex; align-items: center; gap: 0; min-height: 1.45rem; margin: 0; padding: 0; list-style: none; white-space: nowrap; } .breadcrumbs li { display: inline-flex; align-items: center; min-width: 0; } .breadcrumbs li + li::before { content: \"/\"; margin: 0 5px; color: color-mix(in srgb, var(--muted) 62%, transparent); } .breadcrumbs a { display: inline-block; max-width: 26ch; overflow: hidden; text-overflow: ellipsis; color: var(--muted); text-decoration: none; border-radius: 4px; padding: 1px 4px; } .breadcrumbs a:hover { color: var(--accent); background: var(--soft); } .breadcrumbs li:last-child a { color: var(--fg); font-weight: 650; }\n",
);
out.push_str(
" .layout { width: min(1180px, calc(100% - 32px)); margin: 28px auto 72px; display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 28px; align-items: start; } main { min-width: 0; } .side-nav { position: sticky; top: 76px; max-height: calc(100vh - 92px); overflow-y: auto; display: flex; flex-direction: column; gap: 3px; padding-left: 12px; padding-right: 8px; border-left: 2px solid var(--line); font-size: .9rem; scrollbar-width: thin; } .side-nav-title { margin: 0 0 8px; color: var(--muted); font-size: .72rem; font-weight: 750; text-transform: uppercase; letter-spacing: .08em; } .side-nav a { color: var(--muted); text-decoration: none; padding: 4px 0; } .side-nav a.nav-section { color: var(--fg); font-weight: 650; } .side-nav a.nav-command { padding-left: 12px; font-size: .86rem; } .side-nav a:hover { color: var(--accent); }\n",
);
out.push_str(
" header { max-width: 74ch; border-bottom: 1px solid var(--line); padding-bottom: 28px; margin-bottom: 30px; } h1 { margin: 0 0 8px; font-size: clamp(2rem, 4vw, 2.45rem); line-height: 1.08; letter-spacing: 0; } h2 { margin: 34px 0 14px; font-size: 1.02rem; line-height: 1.2; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); scroll-margin-top: 118px; } h3 { margin: 20px 0 10px; font-size: 1.02rem; line-height: 1.35; scroll-margin-top: 118px; } section, details.schema-node, details.command-card, .config-schema-panel, .schema-node { scroll-margin-top: 118px; }\n",
);
out.push_str(
" p { margin: 0 0 .85rem; max-width: 72ch; } a { color: var(--accent); text-underline-offset: 2px; } .summary { font-size: 1.12rem; line-height: 1.55; color: var(--fg); } .description, .details, .meta, .name-meta { color: var(--muted); } .details p + p { margin-top: .95rem; } .meta, .name-meta { font-size: .9rem; line-height: 1.45; } .name-meta { display: block; margin-top: 7px; } .intro-copy { position: relative; max-height: 13.5rem; overflow: hidden; } .intro-copy.is-expanded { max-height: none; } .intro-copy:not(.is-expanded)::after { content: \"\"; position: absolute; inset: auto 0 0; height: 48px; background: linear-gradient(transparent, var(--bg)); pointer-events: none; } .intro-toggle { margin-top: 4px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--fg); font: inherit; cursor: pointer; } .markdown-body > :first-child { margin-top: 0; } .markdown-body p { margin-bottom: .85rem; } .markdown-body ul, .markdown-body ol { margin: 0 0 .9rem 1.4rem; padding: 0; } .markdown-body li { margin: .25rem 0; }\n",
);
out.push_str(
" .usage { display: block; padding: 14px 16px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); overflow-x: auto; }\n",
);
out.push_str(
" .search { width: 100%; padding: 10px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--fg); font: inherit; }\n",
);
out.push_str(
" .search-hit { background: var(--search-bg); color: var(--search-fg); border-radius: 3px; box-shadow: 0 0 0 1px color-mix(in srgb, var(--search-bg) 70%, transparent); } .search-current { background: var(--search-current-bg); color: var(--search-current-fg); box-shadow: 0 0 0 2px color-mix(in srgb, var(--search-current-bg) 55%, transparent); }\n",
);
out.push_str(
" table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; } th, td { padding: 10px 12px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; } tr:last-child td { border-bottom: 0; } th { color: var(--muted); font-weight: 600; font-size: .88rem; }\n",
);
out.push_str(
" code { font: .93em/1.45 \"Maple Mono\", ui-monospace, SFMono-Regular, Consolas, \"Liberation Mono\", monospace; background: var(--code); color: var(--fg); border-radius: 4px; padding: 2px 5px; white-space: nowrap; vertical-align: .03em; } code.value-token { background: var(--value-bg); color: var(--value-fg); } code.env-token { background: var(--env-bg); color: var(--env-fg); } .names { width: 32%; } .empty { color: var(--muted); } .badge { display: inline-block; margin: 2px 4px 2px 0; padding: 2px 7px; border-radius: 999px; background: var(--soft); color: var(--accent); font-size: .82rem; font-weight: 600; }\n",
);
out.push_str(
" details.schema-node, details.command-card { margin: 10px 0; padding: 10px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); } details.inline-schema { margin-top: 12px; } details.schema-node details.schema-node { margin-left: 18px; background: transparent; } summary { cursor: pointer; font-weight: 650; list-style: none; } summary::-webkit-details-marker { display: none; } details > summary::before { content: \"+\"; display: inline-grid; place-items: center; width: 1.1em; margin-right: 4px; color: var(--accent); font-weight: 750; } details[open] > summary::before { content: \"-\"; } details::details-content { block-size: 0; overflow: clip; transition: block-size .22s ease, content-visibility .22s allow-discrete; } details[open]::details-content { block-size: auto; } @media (prefers-reduced-motion: reduce) { details::details-content { transition: none; } } .node-body, .command-body { margin-top: 8px; padding-left: 2px; } .node-grid { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(100px, auto); gap: 8px 16px; align-items: start; } .config-field { display: grid; grid-template-columns: minmax(0, 34%) minmax(0, 1fr); gap: 12px 20px; padding: 10px 0; border-bottom: 1px solid var(--line); } .config-field:last-child { border-bottom: 0; } .config-name, .config-desc { min-width: 0; } .config-name code { white-space: normal; overflow-wrap: anywhere; word-break: break-word; } .config-desc p:last-child { margin-bottom: 0; } .override-details { margin-top: 8px; color: var(--muted); font-size: .9rem; } .override-details summary { font-weight: 600; } .override-grid { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 8px; align-items: center; margin-top: 6px; } .override-grid code { min-width: 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; } .copy-button { width: 1.55rem; height: 1.55rem; padding: 0; display: inline-grid; place-items: center; border: 1px solid var(--line); border-radius: 5px; background: var(--panel); color: var(--muted); cursor: pointer; } .copy-button svg { width: .9rem; height: .9rem; stroke: currentColor; } .copy-button:hover { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } .copy-button.is-copied { color: var(--accent); background: var(--soft); } .override-label { color: var(--muted); font-weight: 650; display: inline-flex; align-items: center; gap: 6px; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } .layer-examples { display: grid; gap: 4px; }\n",
);
out.push_str(
" .config-schema-panel { margin-top: 16px; padding: 14px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }\n",
);
out.push_str(
" @media (max-width: 820px) { .topbar-inner { grid-template-columns: 1fr; } .layout { display: block; } .side-nav { display: none; } .names { width: 40%; } .config-field { grid-template-columns: 1fr; } }\n",
);
out.push_str(" </style>\n</head>\n<body>\n");
out.push_str("<div class=\"topbar\"><div class=\"topbar-inner\"><div class=\"topbar-title\">");
push_escaped(&mut out, doc.command);
out.push_str("</div><input class=\"search\" type=\"search\" placeholder=\"Search flags, commands, config paths, defaults (Cmd/Ctrl-K)\" aria-label=\"Search help\"></div><nav class=\"breadcrumbs\" aria-label=\"Breadcrumb\"><ol data-breadcrumbs><li><a href=\"#top\">");
push_escaped(&mut out, doc.command);
out.push_str("</a></li></ol></nav></div>\n");
out.push_str("<div class=\"layout\">\n");
render_html_side_nav(&mut out, doc.args, doc.config_roots);
out.push_str("<main id=\"top\">\n<header>\n");
out.push_str(" <h1>");
push_escaped(&mut out, doc.command);
out.push_str("</h1>\n");
if let Some(version) = doc.version {
out.push_str(" <p class=\"description\">Version ");
push_escaped(&mut out, version);
out.push_str("</p>\n");
}
render_html_intro(&mut out, doc.docs, doc.description);
out.push_str("</header>\n");
render_html_usage(&mut out, doc.args, doc.config_roots, doc.command);
render_html_command_sections(&mut out, doc.args, doc.command);
render_html_layer_summary(&mut out, doc.config_roots, doc.config_file_extensions);
render_html_arg_sections(
&mut out,
doc.args,
doc.config_roots,
doc.config_file_extensions,
);
render_html_search_script(&mut out);
out.push_str("</main>\n</div>\n</body>\n</html>\n");
out
}
fn render_html_side_nav(
out: &mut String,
args: &ArgLevelSchema,
config_roots: &[ConfigStructSchema],
) {
out.push_str("<nav class=\"side-nav\" aria-label=\"Sections\">\n");
out.push_str(" <p class=\"side-nav-title\">On This Page</p>\n");
out.push_str(" <a class=\"nav-section\" href=\"#usage-heading\">Usage</a>\n");
if args.has_subcommands() {
out.push_str(" <a class=\"nav-section\" href=\"#commands-heading\">Commands</a>\n");
for sub in args.subcommands().values() {
out.push_str(" <a class=\"nav-command\" href=\"#");
push_escaped(out, &command_heading_id(&[sub.cli_name()]));
out.push_str("\">");
push_escaped(out, sub.cli_name());
out.push_str("</a>\n");
}
}
if !config_roots.is_empty() {
out.push_str(" <a class=\"nav-section\" href=\"#layers-heading\">Configuration</a>\n");
for config_root in config_roots {
let Some(name) = config_root.field_name() else {
continue;
};
out.push_str(" <a class=\"nav-command\" href=\"#");
push_escaped(out, &config_schema_heading_id(name));
out.push_str("\">Config fields for ");
push_escaped(out, name);
out.push_str("</a>\n");
}
}
out.push_str(" <a class=\"nav-section\" href=\"#options-heading\">Top-Level Options</a>\n");
out.push_str("</nav>\n");
}
fn render_html_intro(out: &mut String, docs: &crate::schema::Docs, description: Option<&str>) {
if docs.summary().is_none() && docs.details().is_none() && description.is_none() {
return;
}
let mut markdown = String::new();
if let Some(summary) = docs.summary() {
markdown.push_str(summary.trim());
markdown.push_str("\n\n");
}
if let Some(details) = docs.details() {
markdown.push_str(details.trim());
markdown.push_str("\n\n");
}
if let Some(description) = description {
markdown.push_str(description.trim());
markdown.push('\n');
}
out.push_str(" <div class=\"intro-copy markdown-body\" data-collapsible-intro data-markdown-target=\"intro\"></div>\n");
out.push_str(" <script type=\"text/plain\" data-markdown-source=\"intro\">");
push_script_text(out, &markdown);
out.push_str("</script>\n");
out.push_str(
" <button class=\"intro-toggle\" type=\"button\" data-intro-toggle>Show description</button>\n",
);
}
fn render_html_usage(
out: &mut String,
args: &ArgLevelSchema,
config_roots: &[ConfigStructSchema],
command: &str,
) {
out.push_str(
"<section aria-labelledby=\"usage-heading\" data-breadcrumb-label=\"Usage\" data-breadcrumb-anchor=\"usage-heading\">\n",
);
out.push_str(" <h2 id=\"usage-heading\">Usage</h2>\n");
out.push_str(" <code class=\"usage\">");
push_escaped(out, command);
let has_flags = args
.args()
.iter()
.any(|(_, arg)| !arg.kind().is_positional())
|| !config_roots.is_empty();
if has_flags {
out.push_str(" [OPTIONS]");
}
for (_, arg) in args
.args()
.iter()
.filter(|(_, arg)| arg.kind().is_positional())
{
let name = arg.name().to_uppercase();
if arg.required() {
out.push(' ');
push_escaped(out, &format!("<{name}>"));
} else {
out.push(' ');
push_escaped(out, &format!("[{name}]"));
}
}
if args.has_subcommands() {
if args.subcommand_optional() {
out.push_str(" [COMMAND]");
} else {
push_escaped(out, " <COMMAND>");
}
}
out.push_str("</code>\n</section>\n");
}
fn render_html_command_sections(out: &mut String, args: &ArgLevelSchema, command: &str) {
if !args.has_subcommands() {
return;
}
out.push_str(
"<section aria-labelledby=\"commands-heading\" data-breadcrumb-label=\"Commands\" data-breadcrumb-anchor=\"commands-heading\">\n",
);
out.push_str(" <h2 id=\"commands-heading\">Commands</h2>\n");
for sub in args.subcommands().values() {
render_html_command_card(out, sub, &[sub.cli_name()], command);
}
out.push_str("</section>\n");
}
fn render_html_command_card(out: &mut String, sub: &Subcommand, path: &[&str], root_command: &str) {
let heading_id = command_heading_id(path);
out.push_str("<details class=\"command-card search-item\" data-breadcrumb-label=\"");
push_escaped(out, sub.cli_name());
out.push_str("\" data-breadcrumb-anchor=\"");
push_escaped(out, &heading_id);
out.push_str("\">\n<summary id=\"");
push_escaped(out, &heading_id);
out.push_str("\"><code>");
push_escaped(out, sub.cli_name());
out.push_str("</code>");
if let Some(summary) = sub.docs().summary() {
out.push_str(" <span class=\"meta\">");
push_markdown(out, summary.trim());
out.push_str("</span>");
}
out.push_str("</summary>\n<div class=\"command-body\">\n");
out.push_str("<p class=\"meta\">Usage <code>");
push_escaped(out, root_command);
for segment in path {
out.push(' ');
push_escaped(out, segment);
}
render_html_command_usage_suffix(out, sub.args());
out.push_str("</code></p>\n");
render_html_arg_level_tables(out, sub.args(), "Arguments", "Options");
if sub.args().has_subcommands() {
out.push_str("<h3>Subcommands</h3>\n");
for nested in sub.args().subcommands().values() {
let mut nested_path = path.to_vec();
nested_path.push(nested.cli_name());
render_html_command_card(out, nested, &nested_path, root_command);
}
}
out.push_str("</div>\n</details>\n");
}
fn render_html_command_usage_suffix(out: &mut String, args: &ArgLevelSchema) {
let has_flags = args
.args()
.iter()
.any(|(_, arg)| !arg.kind().is_positional());
if has_flags {
out.push_str(" [OPTIONS]");
}
for (_, arg) in args
.args()
.iter()
.filter(|(_, arg)| arg.kind().is_positional())
{
let name = arg.name().to_uppercase();
if arg.required() {
out.push(' ');
push_escaped(out, &format!("<{name}>"));
} else {
out.push(' ');
push_escaped(out, &format!("[{name}]"));
}
}
if args.has_subcommands() {
if args.subcommand_optional() {
out.push_str(" [COMMAND]");
} else {
push_escaped(out, " <COMMAND>");
}
}
}
fn render_html_arg_level_tables(
out: &mut String,
args: &ArgLevelSchema,
positionals_title: &str,
flags_title: &str,
) {
let positionals: Vec<&ArgSchema> = args
.args()
.iter()
.filter_map(|(_, arg)| arg.kind().is_positional().then_some(arg))
.collect();
let flags: Vec<&ArgSchema> = args
.args()
.iter()
.filter_map(|(_, arg)| (!arg.kind().is_positional()).then_some(arg))
.collect();
if !positionals.is_empty() {
out.push_str("<h3 data-breadcrumb-label=\"");
push_escaped(out, positionals_title);
out.push_str("\">");
push_escaped(out, positionals_title);
out.push_str("</h3>\n<table>\n <thead><tr><th>Name</th><th>Description</th></tr></thead>\n <tbody>\n");
for arg in positionals {
render_html_arg_row(out, arg);
}
out.push_str(" </tbody>\n</table>\n");
}
if !flags.is_empty() {
out.push_str("<h3 data-breadcrumb-label=\"");
push_escaped(out, flags_title);
out.push_str("\">");
push_escaped(out, flags_title);
out.push_str("</h3>\n<table>\n <thead><tr><th>Name</th><th>Description</th></tr></thead>\n <tbody>\n");
for arg in flags {
render_html_arg_row(out, arg);
}
out.push_str(" </tbody>\n</table>\n");
}
}
fn render_html_layer_summary(
out: &mut String,
config_roots: &[ConfigStructSchema],
config_file_extensions: &[&str],
) {
if config_roots.is_empty() {
return;
}
out.push_str("<section aria-labelledby=\"layers-heading\" class=\"search-item\" data-breadcrumb-label=\"Configuration\" data-breadcrumb-anchor=\"layers-heading\">\n");
out.push_str(" <h2 id=\"layers-heading\">Configuration</h2>\n");
out.push_str(" <p>When a field is set in more than one place, precedence is <span class=\"badge\">CLI</span> > <span class=\"badge\">Environment</span> > <span class=\"badge\">Config file</span> > <span class=\"badge\">Defaults</span>.</p>\n");
out.push_str(" <p class=\"description\">Supported file formats: ");
push_escaped(out, &config_file_extension_list(config_file_extensions));
out.push_str(".</p>\n</section>\n");
}
fn render_html_arg_sections(
out: &mut String,
args: &ArgLevelSchema,
config_roots: &[ConfigStructSchema],
config_file_extensions: &[&str],
) {
let positionals: Vec<&ArgSchema> = args
.args()
.iter()
.filter_map(|(_, arg)| arg.kind().is_positional().then_some(arg))
.collect();
let flags: Vec<&ArgSchema> = args
.args()
.iter()
.filter_map(|(_, arg)| (!arg.kind().is_positional()).then_some(arg))
.collect();
if !positionals.is_empty() {
render_html_table_start(out, "arguments", "Arguments");
for arg in positionals {
render_html_arg_row(out, arg);
}
out.push_str(" </tbody>\n</table>\n</section>\n");
}
if !flags.is_empty() || !config_roots.is_empty() {
render_html_table_start(out, "options", "Top-Level Options");
for arg in flags {
render_html_arg_row(out, arg);
}
for config_root in config_roots {
render_html_config_rows(out, config_root, config_file_extensions);
}
out.push_str(" </tbody>\n</table>\n");
for config_root in config_roots {
render_html_config_schema_panel(out, config_root);
}
out.push_str("</section>\n");
}
}
fn render_html_table_start(out: &mut String, id: &str, title: &str) {
out.push_str("<section aria-labelledby=\"");
push_escaped(out, id);
out.push_str("-heading\" data-breadcrumb-label=\"");
push_escaped(out, title);
out.push_str("\" data-breadcrumb-anchor=\"");
push_escaped(out, id);
out.push_str("-heading\">\n <h2 id=\"");
push_escaped(out, id);
out.push_str("-heading\">");
push_escaped(out, title);
out.push_str(
"</h2>\n<table>\n <thead><tr><th>Name</th><th>Description</th></tr></thead>\n <tbody>\n",
);
}
fn render_html_arg_row(out: &mut String, arg: &ArgSchema) {
out.push_str(" <tr class=\"search-item\"><td class=\"names\">");
for (idx, part) in arg_help_names(arg).iter().enumerate() {
if idx > 0 {
out.push(' ');
}
render_code_tokens(out, part);
}
render_arg_name_meta(out, arg);
out.push_str("</td><td>");
if arg.docs().summary().is_some() || arg.docs().details().is_some() {
render_html_docs(out, arg.docs());
} else {
out.push_str("<span class=\"empty\">No description.</span>");
}
if arg.kind().is_counted() {
out.push_str("<p class=\"description\">can be repeated</p>");
}
out.push_str("</td></tr>\n");
}
fn render_arg_name_meta(out: &mut String, arg: &ArgSchema) {
let hide_false_bool_default = arg.value().inner_if_option().is_bool()
&& arg.default().map(config_value_summary).as_deref() == Some("false");
let has_enum_values = arg.value().inner_if_option().enum_variants().is_some();
if hide_false_bool_default && !has_enum_values {
return;
}
out.push_str("<span class=\"name-meta\">");
if let Some(default) = arg.default().filter(|_| !hide_false_bool_default) {
out.push_str("Default ");
out.push_str("<code>");
push_escaped(out, &config_value_summary(default));
out.push_str("</code>");
} else if arg.required() {
out.push_str("Required");
} else if !arg.kind().is_positional() && !arg.kind().is_counted() && !arg.value().is_bool() {
out.push_str("Optional value");
} else {
out.push_str("Optional");
}
if let Some(variants) = arg.value().inner_if_option().enum_variants() {
out.push_str("<br>Values ");
for variant in variants {
out.push_str("<code class=\"value-token\">");
push_escaped(out, variant);
out.push_str("</code> ");
}
}
out.push_str("</span>");
}
fn arg_help_names(arg: &ArgSchema) -> Vec<String> {
if arg.kind().is_positional() {
return vec![format!("<{}>", arg.name().to_uppercase())];
}
let mut names = Vec::new();
if let Some(c) = arg.kind().short() {
names.push(format!("-{c},"));
}
let is_bool = arg.value().inner_if_option().is_bool();
let is_counted = arg.kind().is_counted();
let mut long = if is_bool {
if arg.default().map(config_value_summary).as_deref() == Some("false") {
format!("--{}", arg.name().to_kebab_case())
} else {
format!("--[no-]{}", arg.name().to_kebab_case())
}
} else {
format!("--{}", arg.name().to_kebab_case())
};
if !is_counted && !arg.value().is_bool() {
let placeholder = if let Some(label) = arg.label() {
Some(label.to_uppercase())
} else if arg.value().inner_if_option().enum_variants().is_some() {
None
} else {
Some(arg.value().type_identifier().to_uppercase())
};
if let Some(placeholder) = placeholder {
long.push_str(&format!(" <{placeholder}>"));
}
}
names.push(long);
names
}
fn render_code_tokens(out: &mut String, text: &str) {
for (idx, token) in text.split_whitespace().enumerate() {
if idx > 0 {
out.push(' ');
}
if token.starts_with('-') {
out.push_str("<code>");
} else {
out.push_str("<code class=\"value-token\">");
}
push_escaped(out, token);
out.push_str("</code>");
}
}
fn command_heading_id(path: &[&str]) -> String {
let mut id = "command".to_string();
for segment in path {
id.push('-');
id.push_str(&segment.to_kebab_case());
}
id
}
fn config_schema_heading_id(root_name: &str) -> String {
format!("config-fields-{}", root_name.to_kebab_case())
}
fn config_node_id(path: &str) -> String {
let mut id = String::from("config-node-");
let mut last_was_dash = true;
for c in path.chars() {
let next = if c.is_ascii_alphanumeric() {
Some(c.to_ascii_lowercase())
} else if matches!(c, '-' | '_' | '.' | '<' | '>') {
Some('-')
} else {
None
};
let Some(next) = next else {
continue;
};
if next == '-' {
if last_was_dash {
continue;
}
last_was_dash = true;
} else {
last_was_dash = false;
}
id.push(next);
}
id.trim_end_matches('-').to_string()
}
fn config_file_example(root_name: &str, extensions: &[&str]) -> String {
let extension = extensions
.iter()
.copied()
.find(|extension| *extension == "jsonc")
.or_else(|| extensions.first().copied())
.unwrap_or("json");
format!("{}.{}", root_name.to_kebab_case(), extension)
}
fn render_html_config_rows(
out: &mut String,
config_root: &ConfigStructSchema,
config_file_extensions: &[&str],
) {
let Some(name) = config_root.field_name() else {
return;
};
let config_flag = format!("--{}", name.to_kebab_case());
out.push_str(" <tr class=\"search-item\"><td class=\"names\">");
render_code_tokens(
out,
&format!(
"{config_flag} {}",
config_file_example(name, config_file_extensions)
),
);
out.push_str("</td><td>");
push_markdown(
out,
config_root
.docs()
.summary()
.unwrap_or("Load configuration values from a file."),
);
out.push_str("<p class=\"description\">");
push_escaped(out, &format_config_file_extensions(config_file_extensions));
out.push_str("</p><p class=\"description\"><a href=\"#");
push_escaped(out, &config_schema_heading_id(name));
out.push_str("\">View config fields.</a></p>");
out.push_str("</td></tr>\n");
}
fn render_html_config_schema_panel(out: &mut String, config_root: &ConfigStructSchema) {
let Some(root_path) = config_root.field_name() else {
return;
};
out.push_str("<div class=\"config-schema-panel search-item\" data-breadcrumb-label=\"");
push_escaped(out, &format!("Config fields for {root_path}"));
out.push_str("\" data-breadcrumb-anchor=\"");
push_escaped(out, &config_schema_heading_id(root_path));
out.push_str("\">\n");
out.push_str("<h3 id=\"");
push_escaped(out, &config_schema_heading_id(root_path));
out.push_str("\">Config fields for <code>");
push_escaped(out, root_path);
out.push_str("</code></h3>\n<div class=\"node-body\">\n");
if let Some(env_prefix) = config_root.env_prefix() {
out.push_str("<p class=\"meta\">Environment prefix: <code class=\"env-token\">");
push_escaped(out, env_prefix);
out.push_str("</code></p>\n");
}
render_html_config_struct_children(out, config_root, root_path, 1, config_root.env_prefix());
out.push_str("</div>\n</div>\n");
}
fn render_html_config_struct_node(
out: &mut String,
config_struct: &ConfigStructSchema,
display_name: Option<&str>,
path: &str,
depth: usize,
env_prefix: Option<&str>,
) {
let name = display_name.unwrap_or("config");
let node_id = config_node_id(path);
out.push_str("<details id=\"");
push_escaped(out, &node_id);
out.push_str("\" class=\"schema-node search-item\" data-breadcrumb-label=\"");
push_escaped(out, name);
out.push_str("\" data-breadcrumb-anchor=\"");
push_escaped(out, &node_id);
out.push_str("\">\n<summary><code>");
push_escaped(out, name);
out.push_str("</code> <span class=\"meta\">struct ");
push_escaped(out, config_struct.shape().type_identifier);
out.push_str("</span></summary>\n<div class=\"node-body\">\n");
if let Some(summary) = config_struct.docs().summary() {
out.push_str("<p>");
push_markdown(out, summary.trim());
out.push_str("</p>\n");
}
if depth == 0
&& let Some(env_prefix) = config_struct.env_prefix()
{
out.push_str("<p class=\"meta\">Environment prefix: <code class=\"env-token\">");
push_escaped(out, env_prefix);
out.push_str("</code></p>\n");
}
render_html_config_struct_children(out, config_struct, path, depth + 1, env_prefix);
out.push_str("</div>\n</details>\n");
}
fn render_html_config_struct_children(
out: &mut String,
config_struct: &ConfigStructSchema,
path: &str,
depth: usize,
env_prefix: Option<&str>,
) {
render_html_config_children(
out,
config_struct.fields(),
config_struct.field_groups(),
path,
path,
depth,
env_prefix,
);
}
fn render_html_config_group_children(
out: &mut String,
group: &ConfigFieldGroupSchema,
config_path: &str,
group_path: &str,
depth: usize,
env_prefix: Option<&str>,
) {
render_html_config_children(
out,
group.fields(),
group.field_groups(),
config_path,
group_path,
depth,
env_prefix,
);
}
fn render_html_config_children(
out: &mut String,
fields: &indexmap::IndexMap<String, ConfigFieldSchema, std::hash::RandomState>,
groups: &[ConfigFieldGroupSchema],
config_path: &str,
group_path: &str,
depth: usize,
env_prefix: Option<&str>,
) {
for (field_name, field) in fields {
if config_field_is_grouped(groups, field_name) {
continue;
}
let child_path = join_path(config_path, field_name);
render_html_config_field_node(out, field_name, &child_path, field, depth, env_prefix);
}
for group in groups {
let child_group_path = join_path(&join_path(group_path, "__group"), group.name());
render_html_config_field_group(
out,
group,
config_path,
&child_group_path,
depth,
env_prefix,
);
}
}
fn config_field_is_grouped(groups: &[ConfigFieldGroupSchema], field_name: &str) -> bool {
groups
.iter()
.any(|group| group.fields().contains_key(field_name))
}
fn render_html_config_field_group(
out: &mut String,
group: &ConfigFieldGroupSchema,
config_path: &str,
group_path: &str,
depth: usize,
env_prefix: Option<&str>,
) {
let group_id = config_node_id(group_path);
out.push_str("<details id=\"");
push_escaped(out, &group_id);
out.push_str("\" class=\"schema-node search-item\" data-breadcrumb-label=\"");
push_escaped(out, group.name());
out.push_str("\" data-breadcrumb-anchor=\"");
push_escaped(out, &group_id);
out.push_str("\">\n<summary><code>");
push_escaped(out, group.name());
out.push_str(
"</code> <span class=\"meta\">group</span></summary>\n<div class=\"node-body\">\n",
);
render_html_docs(out, group.docs());
render_html_config_group_children(out, group, config_path, group_path, depth + 1, env_prefix);
out.push_str("</div>\n</details>\n");
}
fn render_html_config_field_node(
out: &mut String,
field_name: &str,
path: &str,
field: &ConfigFieldSchema,
depth: usize,
env_prefix: Option<&str>,
) {
match field.value().inner_if_option() {
ConfigValueSchema::Struct(config_struct) => {
render_html_config_struct_node(
out,
config_struct,
Some(field_name),
path,
depth,
env_prefix,
);
}
ConfigValueSchema::Vec(vec_schema) => {
let node_id = config_node_id(path);
out.push_str("<details id=\"");
push_escaped(out, &node_id);
out.push_str("\" class=\"schema-node search-item\" data-breadcrumb-label=\"");
push_escaped(out, field_name);
out.push_str("\" data-breadcrumb-anchor=\"");
push_escaped(out, &node_id);
out.push_str("\">\n<summary><code>");
push_escaped(out, field_name);
out.push_str("</code> <span class=\"meta\">list</span>");
render_default_summary(out, field.default());
out.push_str("</summary>\n<div class=\"node-body\">\n");
render_config_field_docs(out, field);
render_config_override_details(out, path, env_prefix);
let item_path = join_path(path, "<INDEX>");
render_html_config_value_node(
out,
"<INDEX>",
&item_path,
vec_schema.element(),
depth,
env_prefix,
);
out.push_str("</div>\n</details>\n");
}
ConfigValueSchema::Enum(enum_schema) => {
let node_id = config_node_id(path);
out.push_str("<details id=\"");
push_escaped(out, &node_id);
out.push_str("\" class=\"schema-node search-item\" data-breadcrumb-label=\"");
push_escaped(out, field_name);
out.push_str("\" data-breadcrumb-anchor=\"");
push_escaped(out, &node_id);
out.push_str("\">\n<summary><code>");
push_escaped(out, field_name);
out.push_str("</code> <span class=\"meta\">enum ");
push_escaped(out, field.value().type_identifier());
out.push_str("</span>");
render_default_summary(out, field.default());
out.push_str("</summary>\n<div class=\"node-body\">\n");
render_config_field_docs(out, field);
render_config_override_details(out, path, env_prefix);
for (variant_name, variant) in enum_schema.variants() {
let variant_path = join_path(path, variant_name);
let variant_id = config_node_id(&variant_path);
out.push_str("<details id=\"");
push_escaped(out, &variant_id);
out.push_str("\" class=\"schema-node search-item\" data-breadcrumb-label=\"");
push_escaped(out, variant_name);
out.push_str("\" data-breadcrumb-anchor=\"");
push_escaped(out, &variant_id);
out.push_str("\">\n<summary><code>");
push_escaped(out, variant_name);
out.push_str("</code> <span class=\"meta\">variant</span></summary>\n<div class=\"node-body\">\n");
if let Some(summary) = variant.docs().summary() {
out.push_str("<p>");
push_markdown(out, summary.trim());
out.push_str("</p>\n");
}
if variant.fields().is_empty() {
out.push_str("<p class=\"meta\">Unit variant.</p>\n");
}
for (variant_field_name, variant_field) in variant.fields() {
let variant_path = join_path(&variant_path, variant_field_name);
render_html_config_field_node(
out,
variant_field_name,
&variant_path,
variant_field,
depth + 1,
env_prefix,
);
}
out.push_str("</div>\n</details>\n");
}
out.push_str("</div>\n</details>\n");
}
ConfigValueSchema::Leaf(_) => {
render_html_config_leaf_node(out, field_name, path, field, env_prefix)
}
ConfigValueSchema::Option { .. } => unreachable!("inner_if_option removes Option wrappers"),
}
}
fn render_html_config_value_node(
out: &mut String,
name: &str,
path: &str,
value: &ConfigValueSchema,
depth: usize,
env_prefix: Option<&str>,
) {
match value.inner_if_option() {
ConfigValueSchema::Struct(config_struct) => {
render_html_config_struct_node(
out,
config_struct,
Some(name),
path,
depth + 1,
env_prefix,
);
}
ConfigValueSchema::Vec(vec_schema) => {
let item_path = join_path(path, "<INDEX>");
render_html_config_value_node(
out,
"<INDEX>",
&item_path,
vec_schema.element(),
depth + 1,
env_prefix,
);
}
ConfigValueSchema::Enum(enum_schema) => {
let node_id = config_node_id(path);
out.push_str("<details id=\"");
push_escaped(out, &node_id);
out.push_str("\" class=\"schema-node search-item\" data-breadcrumb-label=\"");
push_escaped(out, name);
out.push_str("\" data-breadcrumb-anchor=\"");
push_escaped(out, &node_id);
out.push_str("\">\n<summary><code>");
push_escaped(out, name);
out.push_str("</code> <span class=\"meta\">enum ");
push_escaped(out, value.type_identifier());
out.push_str("</span></summary>\n<div class=\"node-body\">\n");
render_config_override_details(out, path, env_prefix);
for variant_name in enum_schema.variants().keys() {
out.push_str("<p><code>");
push_escaped(out, variant_name);
out.push_str("</code></p>\n");
}
out.push_str("</div>\n</details>\n");
}
ConfigValueSchema::Leaf(_) => {
let node_id = config_node_id(path);
out.push_str("<div id=\"");
push_escaped(out, &node_id);
out.push_str("\" class=\"schema-node search-item node-grid\" data-breadcrumb-label=\"");
push_escaped(out, name);
out.push_str("\" data-breadcrumb-anchor=\"");
push_escaped(out, &node_id);
out.push_str("\"><div><code>");
push_escaped(out, name);
out.push_str("</code> <span class=\"meta\">");
push_escaped(out, value.type_identifier());
out.push_str("</span>");
render_config_override_details(out, path, env_prefix);
out.push_str("</div></div>\n");
}
ConfigValueSchema::Option { .. } => unreachable!("inner_if_option removes Option wrappers"),
}
}
fn render_html_config_leaf_node(
out: &mut String,
field_name: &str,
path: &str,
field: &ConfigFieldSchema,
env_prefix: Option<&str>,
) {
let node_id = config_node_id(path);
out.push_str("<div id=\"");
push_escaped(out, &node_id);
out.push_str("\" class=\"schema-node search-item config-field\" data-breadcrumb-label=\"");
push_escaped(out, field_name);
out.push_str("\" data-breadcrumb-anchor=\"");
push_escaped(out, &node_id);
out.push_str("\"><div class=\"config-name\"><code>");
push_escaped(out, field_name);
out.push_str("</code> <span class=\"meta\">");
push_escaped(out, field.value().type_identifier());
out.push_str("</span>");
render_config_default_meta(out, field.value(), field.default());
out.push_str("</div><div class=\"config-desc\">");
render_config_field_docs(out, field);
render_config_override_details(out, path, env_prefix);
out.push_str("</div></div>\n");
}
fn render_config_field_docs(out: &mut String, field: &ConfigFieldSchema) {
if field.docs().summary().is_some() || field.docs().details().is_some() {
render_html_docs(out, field.docs());
}
}
fn render_config_override_details(out: &mut String, path: &str, env_prefix: Option<&str>) {
out.push_str("<details class=\"override-details\"><summary>Overrides</summary>\n");
out.push_str("<div class=\"override-grid\">\n");
let cli_flag = format!("--{path}");
out.push_str("<span class=\"override-label\">");
render_copy_button(out, &cli_flag);
out.push_str("CLI</span><code>");
push_escaped(out, &cli_flag);
out.push_str("</code>\n");
if let Some(env_prefix) = env_prefix {
let env_name = env_override_name(env_prefix, path);
out.push_str("<span class=\"override-label\">");
render_copy_button(out, &env_name);
out.push_str("Env</span><code class=\"env-token\">");
push_escaped(out, &env_name);
out.push_str("</code>\n");
}
out.push_str("</div>\n</details>\n");
}
fn render_copy_button(out: &mut String, value: &str) {
out.push_str("<button class=\"copy-button\" type=\"button\" data-copy=\"");
push_escaped(out, value);
out.push_str("\" aria-label=\"Copy ");
push_escaped(out, value);
out.push_str("\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg><span class=\"sr-only\">Copy ");
push_escaped(out, value);
out.push_str("</span></button>");
}
fn render_config_default_meta(
out: &mut String,
value: &ConfigValueSchema,
default: Option<&crate::config_value::ConfigValue>,
) {
let false_bool_default =
value.is_bool() && default.map(config_value_summary).as_deref() == Some("false");
if false_bool_default || (default.is_none() && value.is_bool()) {
return;
}
out.push_str("<span class=\"name-meta\">");
if let Some(default) = default {
out.push_str("Default ");
out.push_str("<code class=\"value-token\">");
push_escaped(out, &config_value_summary(default));
out.push_str("</code>");
} else if value.is_option() {
out.push_str("Optional");
} else {
out.push_str("Required");
}
out.push_str("</span>");
}
fn env_override_name(env_prefix: &str, path: &str) -> String {
let mut parts = path.split('.');
let _root = parts.next();
let env_path = parts
.map(|segment| {
segment
.replace('-', "_")
.replace("<INDEX>", "INDEX")
.to_uppercase()
})
.collect::<Vec<_>>()
.join("__");
if env_path.is_empty() {
env_prefix.to_string()
} else {
format!("{env_prefix}__{env_path}")
}
}
fn render_default_summary(out: &mut String, default: Option<&crate::config_value::ConfigValue>) {
if let Some(default) = default {
out.push_str(" <span class=\"meta\">default ");
out.push_str("<code>");
push_escaped(out, &config_value_summary(default));
out.push_str("</code></span>");
}
}
fn config_value_summary(value: &crate::config_value::ConfigValue) -> String {
match value {
crate::config_value::ConfigValue::Null(_) => "null".to_string(),
crate::config_value::ConfigValue::Bool(s) => s.value.to_string(),
crate::config_value::ConfigValue::Integer(s) => s.value.to_string(),
crate::config_value::ConfigValue::Float(s) => s.value.to_string(),
crate::config_value::ConfigValue::String(s) => s.value.clone(),
crate::config_value::ConfigValue::Array(s) => {
format!("list[{}]", s.value.len())
}
crate::config_value::ConfigValue::Object(s) => {
format!("object{{{} fields}}", s.value.len())
}
crate::config_value::ConfigValue::Enum(s) => s.value.variant.clone(),
}
}
fn join_path(prefix: &str, segment: &str) -> String {
if prefix.is_empty() {
segment.to_kebab_case()
} else {
format!("{prefix}.{}", segment.to_kebab_case())
}
}
fn render_html_search_script(out: &mut String) {
out.push_str(
r##"<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
for (const source of document.querySelectorAll('[data-markdown-source]')) {
const key = source.getAttribute('data-markdown-source');
const target = document.querySelector(`[data-markdown-target="${key}"]`);
if (!target) {
continue;
}
if (window.marked) {
target.innerHTML = marked.parse(source.textContent);
} else {
target.textContent = source.textContent;
}
}
const searchInput = document.querySelector('.search');
const searchableRoot = document.querySelector('main');
const intro = document.querySelector('[data-collapsible-intro]');
const introToggle = document.querySelector('[data-intro-toggle]');
const breadcrumbList = document.querySelector('[data-breadcrumbs]');
const breadcrumbHome = document.querySelector('.topbar-title')?.textContent?.trim() || document.title || 'Home';
let searchHits = [];
let currentHit = -1;
let revealingSearchHit = false;
let breadcrumbFrame = 0;
introToggle?.addEventListener('click', () => {
intro?.classList.toggle('is-expanded');
introToggle.textContent = intro?.classList.contains('is-expanded')
? 'Collapse description'
: 'Show description';
});
document.addEventListener('keydown', event => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
searchInput?.focus();
searchInput?.select();
}
});
searchInput?.addEventListener('input', () => {
updateSearch(searchInput.value);
});
searchInput?.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
moveSearch(event.shiftKey ? -1 : 1);
}
});
document.addEventListener('scroll', scheduleBreadcrumbUpdate, { passive: true });
window.addEventListener('resize', scheduleBreadcrumbUpdate);
breadcrumbList?.addEventListener('click', event => {
const link = event.target.closest('a[href^="#"]');
if (!link) {
return;
}
const id = decodeURIComponent(link.getAttribute('href').slice(1));
const target = document.getElementById(id);
if (!target) {
return;
}
event.preventDefault();
revealBreadcrumbTarget(target);
target.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
history.replaceState(null, '', `#${encodeURIComponent(id)}`);
});
for (const details of document.querySelectorAll('details')) {
details.addEventListener('toggle', () => {
scheduleBreadcrumbUpdate();
if (!details.open || revealingSearchHit) {
return;
}
window.setTimeout(() => {
keepOpenedDetailsComfortable(details);
}, 280);
});
}
scheduleBreadcrumbUpdate();
function scheduleBreadcrumbUpdate() {
if (breadcrumbFrame) {
return;
}
breadcrumbFrame = requestAnimationFrame(() => {
breadcrumbFrame = 0;
updateBreadcrumbs();
});
}
function updateBreadcrumbs() {
if (!breadcrumbList) {
return;
}
const active = currentBreadcrumbTarget();
const trail = breadcrumbTrail(active);
breadcrumbList.replaceChildren();
appendBreadcrumbCrumb(breadcrumbList, breadcrumbHome, 'top');
for (const item of trail) {
appendBreadcrumbCrumb(
breadcrumbList,
item.getAttribute('data-breadcrumb-label'),
item.getAttribute('data-breadcrumb-anchor')
);
}
}
function currentBreadcrumbTarget() {
const items = [...document.querySelectorAll('[data-breadcrumb-label][data-breadcrumb-anchor]')]
.filter(item => item.getClientRects().length > 0);
if (items.length === 0) {
return null;
}
const topbarBottom = document.querySelector('.topbar')?.getBoundingClientRect().bottom || 0;
const activationLine = topbarBottom + 16;
let active = items[0];
for (const item of items) {
const rect = item.getBoundingClientRect();
if (rect.top <= activationLine) {
active = item;
} else {
break;
}
}
return active;
}
function breadcrumbTrail(active) {
const trail = [];
let node = active;
while (node) {
if (
node.matches?.('[data-breadcrumb-label][data-breadcrumb-anchor]') &&
node.getAttribute('data-breadcrumb-label') &&
node.getAttribute('data-breadcrumb-anchor')
) {
trail.push(node);
}
node = node.parentElement?.closest('[data-breadcrumb-label][data-breadcrumb-anchor]');
}
return trail.reverse();
}
function appendBreadcrumbCrumb(list, label, anchor) {
if (!label || !anchor) {
return;
}
const li = document.createElement('li');
const link = document.createElement('a');
link.href = `#${anchor}`;
link.textContent = label;
li.append(link);
list.append(li);
}
function revealBreadcrumbTarget(target) {
revealingSearchHit = true;
let details = target.closest('details');
while (details) {
details.open = true;
details = details.parentElement?.closest('details');
}
requestAnimationFrame(() => {
revealingSearchHit = false;
scheduleBreadcrumbUpdate();
});
}
function keepOpenedDetailsComfortable(details) {
const rect = details.getBoundingClientRect();
const topbarBottom = document.querySelector('.topbar')?.getBoundingClientRect().bottom || 0;
const padding = 40;
const visibleTop = topbarBottom + padding;
const visibleBottom = window.innerHeight - padding;
if (rect.top >= visibleTop && rect.bottom <= visibleBottom) {
return;
}
const capacity = visibleBottom - visibleTop;
let delta = 0;
if (rect.height > capacity) {
delta = rect.top - visibleTop;
} else if (rect.top < visibleTop) {
delta = rect.top - visibleTop;
} else if (rect.bottom > visibleBottom) {
delta = rect.bottom - visibleBottom;
}
if (Math.abs(delta) > 1) {
window.scrollBy({ top: delta, behavior: 'smooth' });
}
}
document.addEventListener('click', async event => {
const button = event.target.closest('[data-copy]');
if (!button) {
return;
}
event.preventDefault();
const value = button.getAttribute('data-copy');
if (!value) {
return;
}
try {
await navigator.clipboard.writeText(value);
button.classList.add('is-copied');
button.setAttribute('aria-label', `Copied ${value}`);
window.setTimeout(() => {
button.classList.remove('is-copied');
button.setAttribute('aria-label', `Copy ${value}`);
}, 1200);
} catch {
button.setAttribute('aria-label', `Failed to copy ${value}`);
window.setTimeout(() => {
button.setAttribute('aria-label', `Copy ${value}`);
}, 1200);
}
});
function updateSearch(rawQuery) {
clearSearchHighlights();
const query = rawQuery.trim();
if (!query || !searchableRoot) {
return;
}
const pattern = new RegExp(escapeRegExp(query), 'gi');
const walker = document.createTreeWalker(
searchableRoot,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
if (!node.nodeValue.trim()) {
return NodeFilter.FILTER_REJECT;
}
const parent = node.parentElement;
if (!parent || parent.closest('header, script, style, textarea, input, .override-details, mark.search-hit')) {
return NodeFilter.FILTER_REJECT;
}
pattern.lastIndex = 0;
return pattern.test(node.nodeValue)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
}
);
const nodes = [];
while (walker.nextNode()) {
nodes.push(walker.currentNode);
}
for (const node of nodes) {
highlightTextNode(node, pattern);
}
if (searchHits.length > 0) {
currentHit = 0;
scrollToSearchHit(currentHit);
}
}
function clearSearchHighlights() {
for (const mark of Array.from(document.querySelectorAll('mark.search-hit'))) {
mark.replaceWith(document.createTextNode(mark.textContent));
}
searchableRoot?.normalize();
searchHits = [];
currentHit = -1;
}
function highlightTextNode(node, pattern) {
pattern.lastIndex = 0;
const text = node.nodeValue;
const fragment = document.createDocumentFragment();
let cursor = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
if (match.index > cursor) {
fragment.append(document.createTextNode(text.slice(cursor, match.index)));
}
const mark = document.createElement('mark');
mark.className = 'search-hit';
mark.textContent = match[0];
fragment.append(mark);
searchHits.push(mark);
cursor = match.index + match[0].length;
}
if (cursor < text.length) {
fragment.append(document.createTextNode(text.slice(cursor)));
}
node.replaceWith(fragment);
}
function moveSearch(delta) {
if (searchHits.length === 0) {
updateSearch(searchInput?.value || '');
}
if (searchHits.length === 0) {
return;
}
currentHit = (currentHit + delta + searchHits.length) % searchHits.length;
scrollToSearchHit(currentHit);
}
function scrollToSearchHit(index) {
const hit = searchHits[index];
if (!hit) {
return;
}
for (const mark of searchHits) {
mark.classList.remove('search-current');
}
hit.classList.add('search-current');
revealSearchHit(hit);
hit.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
}
function revealSearchHit(hit) {
revealingSearchHit = true;
let details = hit.parentElement?.closest('details');
while (details) {
details.open = true;
details = details.parentElement?.closest('details');
}
requestAnimationFrame(() => {
revealingSearchHit = false;
});
const collapsedIntro = hit.closest('[data-collapsible-intro]');
if (collapsedIntro && !collapsedIntro.classList.contains('is-expanded')) {
collapsedIntro.classList.add('is-expanded');
if (introToggle) {
introToggle.textContent = 'Collapse description';
}
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
</script>
"##,
);
}
fn render_html_docs(out: &mut String, docs: &Docs) {
let mut text = String::new();
if let Some(summary) = docs.summary() {
text.push_str(summary.trim());
}
if let Some(details) = docs.details() {
if !text.is_empty() {
text.push('\n');
}
text.push_str(details.trim());
}
for paragraph in text.split("\n\n") {
let paragraph = paragraph
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ");
if paragraph.is_empty() {
continue;
}
out.push_str("<p>");
push_markdown(out, ¶graph);
out.push_str("</p>\n");
}
}
fn push_markdown(out: &mut String, text: &str) {
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '`'
&& let Some(end) = chars[i + 1..].iter().position(|c| *c == '`')
{
out.push_str("<code>");
push_escaped(out, &chars[i + 1..i + 1 + end].iter().collect::<String>());
out.push_str("</code>");
i += end + 2;
} else if chars[i] == '*'
&& chars.get(i + 1) == Some(&'*')
&& let Some(end) = chars[i + 2..]
.windows(2)
.position(|window| window == ['*', '*'])
{
out.push_str("<strong>");
push_escaped(out, &chars[i + 2..i + 2 + end].iter().collect::<String>());
out.push_str("</strong>");
i += end + 4;
} else if chars[i] == '['
&& let Some(label_end) = chars[i + 1..].iter().position(|c| *c == ']')
{
let after_label = i + 1 + label_end + 1;
if chars.get(after_label) == Some(&'(')
&& let Some(url_end) = chars[after_label + 1..].iter().position(|c| *c == ')')
{
let label = chars[i + 1..i + 1 + label_end].iter().collect::<String>();
let url = chars[after_label + 1..after_label + 1 + url_end]
.iter()
.collect::<String>();
out.push_str("<a href=\"");
push_escaped(out, &url);
out.push_str("\">");
push_escaped(out, &label);
out.push_str("</a>");
i = after_label + url_end + 2;
continue;
}
push_escaped(out, &chars[i].to_string());
i += 1;
} else {
push_escaped(out, &chars[i].to_string());
i += 1;
}
}
}
fn push_escaped(out: &mut String, text: &str) {
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
}
fn push_script_text(out: &mut String, text: &str) {
out.push_str(&text.replace("</script", "<\\/script"));
}
/// Wrap `text` into lines of at most `max_width - indent.len()` characters,
/// prefixing each line with `indent`. When `max_width` is 0 the text is
/// returned on a single line (no wrapping).
fn wrap_text(text: &str, indent: &str, max_width: usize) -> String {
let available = if max_width == 0 || max_width <= indent.len() {
// No wrapping or degenerate case – just one line.
let mut s = indent.to_string();
s.push_str(text);
return s;
} else {
max_width - indent.len()
};
let mut result = String::new();
let mut line = String::new();
for word in text.split_whitespace() {
if line.is_empty() {
line.push_str(word);
} else if line.len() + 1 + word.len() <= available {
line.push(' ');
line.push_str(word);
} else {
result.push_str(indent);
result.push_str(&line);
result.push('\n');
line.clear();
line.push_str(word);
}
}
if !line.is_empty() {
result.push_str(indent);
result.push_str(&line);
}
result
}
/// Generate help output for an argument level (args + subcommands).
fn generate_arg_level_help(
out: &mut String,
args: &ArgLevelSchema,
config_roots: &[ConfigStructSchema],
program_name: &str,
config: &HelpConfig,
config_file_extensions: &[&str],
) {
// Separate positionals and named flags
let mut positionals: Vec<&ArgSchema> = Vec::new();
let mut flags: Vec<&ArgSchema> = Vec::new();
for (_name, arg) in args.args().iter() {
if arg.kind().is_positional() {
positionals.push(arg);
} else {
flags.push(arg);
}
}
// Usage line
out.push_str(&format!("{}:\n ", "USAGE".yellow().bold()));
out.push_str(program_name);
if !flags.is_empty() || !config_roots.is_empty() {
out.push_str(" [OPTIONS]");
}
for pos in &positionals {
let name = pos.name().to_uppercase();
if pos.required() {
out.push_str(&format!(" <{name}>"));
} else {
out.push_str(&format!(" [{name}]"));
}
}
if args.has_subcommands() {
if args.subcommand_optional() {
out.push_str(" [COMMAND]");
} else {
out.push_str(" <COMMAND>");
}
}
out.push_str("\n\n");
// Positional arguments
if !positionals.is_empty() {
out.push_str(&format!("{}:\n", "ARGUMENTS".yellow().bold()));
for arg in &positionals {
write_arg_help(out, arg, config);
}
out.push('\n');
}
// Options
if !flags.is_empty() || !config_roots.is_empty() {
out.push_str(&format!("{}:\n", "OPTIONS".yellow().bold()));
for arg in &flags {
write_arg_help(out, arg, config);
}
for config_root in config_roots {
write_config_help(out, config_root, config, config_file_extensions);
}
out.push('\n');
}
// Subcommands
if args.has_subcommands() {
out.push_str(&format!("{}:\n", "COMMANDS".yellow().bold()));
for sub in args.subcommands().values() {
write_subcommand_help(out, sub, config);
}
out.push('\n');
}
}
/// Write help for a config root.
fn write_config_help(
out: &mut String,
config_root: &ConfigStructSchema,
config: &HelpConfig,
config_file_extensions: &[&str],
) {
let Some(name) = config_root.field_name() else {
return;
};
let cli_name = name.to_kebab_case();
let config_flag = format!("--{cli_name}");
out.push_str(" ");
out.push_str(&format!(
"{} <FILE>",
config_flag.if_supports_color(Stdout, |text| text.green())
));
out.push('\n');
let file_help = config_root
.docs()
.summary()
.unwrap_or("Load configuration values from a file.");
out.push_str(&wrap_text(file_help, " ", config.width));
out.push('\n');
out.push_str(&wrap_text(
&format_config_file_extensions(config_file_extensions),
" ",
config.width,
));
out.push('\n');
for item in config_override_help_items(&config_flag, config_root) {
write_config_override_help(out, &item, config);
}
}
fn format_config_file_extensions(extensions: &[&str]) -> String {
let formatted = config_file_extension_list(extensions);
if formatted.is_empty() {
"No config file formats are registered.".to_string()
} else {
format!("Supported file formats: {formatted}.")
}
}
fn config_file_extension_list(extensions: &[&str]) -> String {
let mut unique = Vec::new();
for extension in extensions {
let extension = extension.trim_start_matches('.');
if !extension.is_empty()
&& !unique
.iter()
.any(|existing: &&str| existing.eq_ignore_ascii_case(extension))
{
unique.push(extension);
}
}
if unique.is_empty() {
return String::new();
}
unique
.iter()
.map(|extension| format!(".{extension}"))
.collect::<Vec<_>>()
.join(", ")
}
struct ConfigOverrideHelpItem {
flag: String,
placeholder: String,
help: Option<String>,
}
fn write_config_override_help(
out: &mut String,
item: &ConfigOverrideHelpItem,
config: &HelpConfig,
) {
out.push_str(" ");
out.push_str(&format!(
"{} <{}>",
item.flag.if_supports_color(Stdout, |text| text.green()),
item.placeholder
));
out.push('\n');
if let Some(help) = &item.help {
out.push_str(&wrap_text(help, " ", config.width));
out.push('\n');
}
}
fn config_override_help_items(
config_flag: &str,
config_root: &ConfigStructSchema,
) -> Vec<ConfigOverrideHelpItem> {
let mut items = Vec::new();
collect_config_struct_overrides(config_flag, config_root, Vec::new(), &mut items);
items
}
fn collect_config_struct_overrides(
config_flag: &str,
config_struct: &ConfigStructSchema,
path: Vec<String>,
items: &mut Vec<ConfigOverrideHelpItem>,
) {
for (field_name, field) in config_struct.fields() {
let mut field_path = path.clone();
field_path.push(field_name.clone());
collect_config_field_override(config_flag, field_path, field, items);
}
}
fn collect_config_field_override(
config_flag: &str,
path: Vec<String>,
field: &ConfigFieldSchema,
items: &mut Vec<ConfigOverrideHelpItem>,
) {
let help = field.docs().summary().map(str::to_string);
collect_config_value_overrides(config_flag, path, field.value(), help, items);
}
fn collect_config_value_overrides(
config_flag: &str,
path: Vec<String>,
value: &ConfigValueSchema,
help: Option<String>,
items: &mut Vec<ConfigOverrideHelpItem>,
) {
match value.inner_if_option() {
ConfigValueSchema::Struct(config_struct) => {
collect_config_struct_overrides(config_flag, config_struct, path, items);
}
ConfigValueSchema::Vec(vec_schema) => {
let mut element_path = path;
element_path.push("<INDEX>".to_string());
collect_config_value_overrides(
config_flag,
element_path,
vec_schema.element(),
help,
items,
);
}
ConfigValueSchema::Enum(enum_schema) => {
let variants: Vec<&str> = enum_schema.variants().keys().map(String::as_str).collect();
if enum_schema
.variants()
.values()
.any(|variant| variant.fields().is_empty())
{
items.push(ConfigOverrideHelpItem {
flag: config_override_flag(config_flag, &path),
placeholder: variants.join(","),
help: help.clone(),
});
}
for (variant_name, variant_schema) in enum_schema.variants() {
let mut variant_path = path.clone();
variant_path.push(variant_name.clone());
for (field_name, field) in variant_schema.fields() {
let mut field_path = variant_path.clone();
field_path.push(field_name.clone());
collect_config_field_override(config_flag, field_path, field, items);
}
}
}
ConfigValueSchema::Leaf(_) => {
items.push(ConfigOverrideHelpItem {
flag: config_override_flag(config_flag, &path),
placeholder: value.type_identifier().to_uppercase(),
help,
});
}
ConfigValueSchema::Option { .. } => unreachable!("inner_if_option removes Option wrappers"),
}
}
fn config_override_flag(config_flag: &str, path: &[String]) -> String {
format!("{config_flag}.{}", path.join("."))
}
/// Write help for a single argument.
fn write_arg_help(out: &mut String, arg: &ArgSchema, config: &HelpConfig) {
out.push_str(" ");
let is_positional = arg.kind().is_positional();
// Short flag (or spacing for alignment)
if let Some(c) = arg.kind().short() {
out.push_str(&format!(
"{}, ",
format!("-{c}").if_supports_color(Stdout, |text| text.green())
));
} else {
// Add spacing to align with flags that have short options
out.push_str(" ");
}
// Long flag or positional name
let name = arg.name();
let is_counted = arg.kind().is_counted();
if is_positional {
out.push_str(&format!(
"{}",
format!("<{}>", name.to_uppercase()).if_supports_color(Stdout, |text| text.green())
));
} else {
let is_bool = arg.value().inner_if_option().is_bool();
let flag_str = if is_bool {
format!("--[no-]{}", name.to_kebab_case())
} else {
format!("--{}", name.to_kebab_case())
};
out.push_str(&format!(
"{}",
flag_str.if_supports_color(Stdout, |text| text.green())
));
// Show value placeholder for non-bool, non-counted types
if !is_counted && !arg.value().is_bool() {
let placeholder = if let Some(desc) = arg.label() {
desc.to_uppercase()
} else if let Some(variants) = arg.value().inner_if_option().enum_variants() {
variants.join(",")
} else {
arg.value().type_identifier().to_uppercase()
};
out.push_str(&format!(" <{}>", placeholder));
}
}
// Doc comment
const DOC_INDENT: &str = " ";
if let Some(summary) = arg.docs().summary() {
out.push('\n');
out.push_str(&wrap_text(summary.trim(), DOC_INDENT, config.width));
}
if is_counted {
out.push('\n');
out.push_str(&wrap_text("[can be repeated]", DOC_INDENT, config.width));
}
out.push('\n');
}
/// Write help for a subcommand.
fn write_subcommand_help(out: &mut String, sub: &Subcommand, config: &HelpConfig) {
out.push_str(" ");
out.push_str(&format!(
"{}",
sub.cli_name()
.if_supports_color(Stdout, |text| text.green())
));
// Doc comment
if let Some(summary) = sub.docs().summary() {
out.push('\n');
out.push_str(&wrap_text(summary.trim(), " ", config.width));
}
out.push('\n');
}
#[cfg(test)]
mod tests {
use super::*;
use facet::Facet;
use figue_attrs as args;
/// Common arguments that can be flattened into other structs
#[derive(Facet)]
struct CommonArgs {
/// Enable verbose output
#[facet(args::named, crate::short = 'v')]
verbose: bool,
/// Enable quiet mode
#[facet(args::named, crate::short = 'q')]
quiet: bool,
}
/// Args struct with flattened common args
#[derive(Facet)]
struct ArgsWithFlatten {
/// Input file
#[facet(args::positional)]
input: String,
/// Common options
#[facet(flatten)]
common: CommonArgs,
}
#[test]
fn test_flatten_args_appear_in_help() {
let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
// Flattened fields should appear at top level
assert!(
help.contains("--[no-]verbose"),
"help should contain --[no-]verbose from flattened CommonArgs"
);
assert!(help.contains("-v"), "help should contain -v short flag");
assert!(
help.contains("--[no-]quiet"),
"help should contain --[no-]quiet from flattened CommonArgs"
);
assert!(help.contains("-q"), "help should contain -q short flag");
// The flattened field name 'common' should NOT appear as a flag
assert!(
!help.contains("--common"),
"help should not show --common as a flag"
);
}
#[test]
fn test_flatten_docs_preserved() {
let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
// Doc comments from flattened fields should be present
assert!(
help.contains("verbose output"),
"help should contain verbose field doc"
);
assert!(
help.contains("quiet mode"),
"help should contain quiet field doc"
);
}
/// Arguments for the serve subcommand
#[derive(Facet)]
struct ServeArgs {
/// Port to serve on
#[facet(args::named)]
port: u16,
/// Host to bind to
#[facet(args::named)]
host: String,
}
/// Top-level command with tuple variant subcommand
#[derive(Facet)]
struct TupleVariantArgs {
/// Subcommand to run
#[facet(args::subcommand)]
command: Option<TupleVariantCommand>,
}
/// Command enum with tuple variant
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum TupleVariantCommand {
/// Start the server
Serve(ServeArgs),
}
#[test]
fn test_label_overrides_placeholder() {
#[derive(Facet)]
struct TDArgs {
/// Input path
#[facet(args::named, args::label = "PATH")]
input: std::path::PathBuf,
}
let schema = Schema::from_shape(TDArgs::SHAPE).unwrap();
let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
// Only assert on the placeholder to avoid issues with ANSI color codes around the flag name
assert!(
help.contains("<PATH>"),
"help should use custom label placeholder"
);
}
#[test]
fn test_tuple_variant_fields_not_shown_as_option() {
let schema = Schema::from_shape(TupleVariantArgs::SHAPE).unwrap();
// Path contains effective names (e.g., "Serve" not "serve")
let help =
generate_help_for_subcommand(&schema, &["Serve".to_string()], &HelpConfig::default());
// The inner struct's fields should appear
assert!(
help.contains("--port"),
"help should contain --port from ServeArgs"
);
assert!(
help.contains("--host"),
"help should contain --host from ServeArgs"
);
// The tuple field "0" should NOT appear as --0
assert!(
!help.contains("--0"),
"help should NOT show --0 for tuple variant wrapper field"
);
assert!(
!help.contains("SERVEARGS"),
"help should NOT show SERVEARGS as an option value"
);
}
#[test]
fn test_config_roots_appear_in_help() {
#[derive(Facet)]
#[allow(dead_code)]
struct Args {
/// Session configuration
#[facet(args::config, args::env_prefix = "BEE", rename = "cfg")]
cfg: SessionConfig,
/// Evaluation configuration
#[facet(args::config, args::env_prefix = "BEE_EVAL", rename = "eval")]
eval: EvalConfig,
}
#[derive(Facet)]
struct SessionConfig {
/// Session hostname
#[facet(default = "localhost")]
host: String,
/// Labels attached to this session
tags: Vec<String>,
server: ServerConfig,
}
#[derive(Facet)]
struct ServerConfig {
/// Server port
#[facet(default = 8080)]
port: u16,
}
#[derive(Facet)]
struct EvalConfig {
/// Number of evaluation samples
#[facet(default = 10)]
samples: u32,
}
let schema = Schema::from_shape(Args::SHAPE).unwrap();
let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
let help = strip_ansi_escapes::strip_str(&help);
assert!(help.contains("--cfg <FILE>"));
assert!(help.contains("Session configuration"));
assert!(help.contains("Supported file formats: .json."));
assert!(help.contains("--cfg.host <STRING>"));
assert!(help.contains("Session hostname"));
assert!(help.contains("--cfg.tags.<INDEX> <STRING>"));
assert!(help.contains("Labels attached to this session"));
assert!(help.contains("--cfg.server.port <U16>"));
assert!(help.contains("Server port"));
assert!(help.contains("--eval <FILE>"));
assert!(help.contains("Evaluation configuration"));
assert!(help.contains("--eval.samples <U32>"));
assert!(help.contains("Number of evaluation samples"));
assert!(!help.contains("--cfg.<KEY>"));
assert!(!help.contains("--eval.<KEY>"));
}
#[test]
fn test_html_help_contains_schema_sections() {
#[derive(Facet)]
#[allow(dead_code)]
struct Args {
/// Input <path> to process
#[facet(args::positional)]
input: String,
/// Enable verbose output
#[facet(args::named, args::short = 'v')]
verbose: bool,
}
let config = HelpConfig {
program_name: Some("tool".to_string()),
version: Some("1.2.3".to_string()),
..Default::default()
};
let html = generate_html_help::<Args>(&config);
assert!(html.contains("<h1>tool</h1>"));
assert!(html.contains("Version 1.2.3"));
assert!(html.contains("<INPUT>"));
assert!(html.contains("Input <path> to process"));
assert!(html.contains("--[no-]verbose"));
assert!(html.contains("class=\"breadcrumbs\""));
assert!(html.contains("data-breadcrumbs"));
assert!(html.contains("function updateBreadcrumbs()"));
assert!(html.contains("<nav class=\"side-nav\""));
assert!(html.contains("max-height: calc(100vh - 92px); overflow-y: auto;"));
assert!(html.contains("On This Page"));
assert!(html.contains("Top-Level Options"));
assert!(html.contains("data-collapsible-intro"));
assert!(!html.contains("<th>Default</th>"));
}
#[test]
fn test_html_help_config_schema_search_defaults_and_markdown() {
#[derive(Facet)]
struct Args {
/// **Application** settings from all layers.
#[facet(args::config, args::env_prefix = "APP")]
settings: Settings,
}
#[derive(Facet)]
struct Settings {
/// Nested server settings.
server: Server,
}
#[derive(Facet)]
struct Server {
/// Port with a `default` value.
#[facet(default = 8080)]
port: u16,
}
let config = HelpConfig {
program_name: Some("tool".to_string()),
..Default::default()
};
let html = generate_html_help::<Args>(&config);
assert!(html.contains("class=\"search\""));
assert!(html.contains("metaKey || event.ctrlKey"));
assert!(html.contains("mark.search-hit"));
assert!(html.contains("scrollIntoView"));
assert!(!html.contains("item.hidden"));
assert!(html.contains("marked.min.js"));
assert!(html.contains("<strong>Application</strong> settings from all layers."));
assert!(html.contains("precedence is <span class=\"badge\">CLI</span> >"));
assert!(html.contains("data-copy=\"--settings.server.port\""));
assert!(html.contains("<code class=\"env-token\">APP__SERVER__PORT</code>"));
assert!(html.contains("data-copy=\"APP__SERVER__PORT\""));
assert!(!html.contains(
"<code>--settings.server.port</code><code class=\"value-token\"><U16></code>"
));
assert!(!html.contains(
"<code class=\"env-token\">APP__SERVER__PORT</code><code class=\"value-token\">=...</code>"
));
assert!(
html.contains(
"<code>--settings</code> <code class=\"value-token\">settings.json</code>"
)
);
assert!(html.contains("View config fields."));
assert!(html.contains("--settings.server.port"));
assert!(html.contains("Config fields for <code>settings</code>"));
assert!(html.contains("config-schema-panel"));
assert!(!html.contains("data-collapsible-schema"));
assert!(!html.contains("Show all config fields"));
assert!(html.contains("Default <code class=\"value-token\">8080</code>"));
assert!(!html.contains("config-schema-heading"));
assert!(!html.contains("--settings.server.port <U16></code></td><td>"));
assert!(html.contains("data-breadcrumb-label=\"Config fields for settings\""));
assert!(html.contains("data-breadcrumb-label=\"server\""));
assert!(html.contains(".config-name code { white-space: normal;"));
assert!(html.contains(
".override-grid { display: grid; grid-template-columns: max-content minmax(0, 1fr);"
));
}
#[test]
fn test_html_help_config_meta_matches_implicit_defaults() {
#[derive(Facet)]
struct Args {
#[facet(args::config)]
settings: Settings,
}
#[derive(Facet)]
#[allow(dead_code)]
struct Settings {
/// Enables the feature.
#[facet(default)]
enabled: bool,
/// Optional queue size.
queue_size: Option<usize>,
/// Implicitly false when omitted.
implicit_flag: bool,
}
let html = generate_html_help::<Args>(&HelpConfig::default());
assert!(!html.contains("Default <code class=\"value-token\">false</code>"));
assert!(!html.contains("<code>implicit_flag</code> <span class=\"meta\">bool</span><span class=\"name-meta\">Required</span>"));
assert!(html.contains("<code>queue_size</code> <span class=\"meta\">usize</span><span class=\"name-meta\">Optional</span>"));
}
#[test]
fn test_html_help_arg_rows_include_wrapped_doc_details() {
#[derive(Facet)]
struct Args {
/// Output path for the alignment JSONL (one
/// object per line).
#[facet(args::named)]
out: Option<String>,
}
let html = generate_html_help::<Args>(&HelpConfig::default());
assert!(html.contains("<p>Output path for the alignment JSONL (one object per line).</p>"));
}
#[test]
fn test_html_help_groups_flattened_config_fields() {
#[derive(Facet)]
struct Args {
#[facet(args::config)]
settings: Settings,
}
#[derive(Facet)]
#[allow(dead_code)]
struct Settings {
/// Runtime tuning controls.
#[facet(flatten)]
runtime: Runtime,
/// Service host.
host: String,
}
#[derive(Facet)]
#[allow(dead_code)]
struct Runtime {
/// Worker count.
#[facet(default = 4)]
workers: usize,
}
let html = generate_html_help::<Args>(&HelpConfig::default());
assert!(html.contains("data-breadcrumb-label=\"runtime\""));
assert!(html.contains("<span class=\"meta\">group</span>"));
assert!(html.contains("<p>Runtime tuning controls.</p>"));
assert!(html.contains("data-breadcrumb-label=\"workers\""));
assert!(html.contains("data-copy=\"--settings.workers\""));
assert!(!html.contains("--settings.runtime.workers"));
}
#[test]
fn test_html_help_renders_command_specific_options() {
#[derive(Facet)]
struct Args {
#[facet(args::subcommand)]
command: Command,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum Command {
/// Start the service.
Serve {
/// Bind port.
#[facet(args::named, default = 8080)]
port: u16,
},
}
let config = HelpConfig {
program_name: Some("tool".to_string()),
..Default::default()
};
let html = generate_html_help::<Args>(&config);
assert!(html.contains("<h2 id=\"commands-heading\">Commands</h2>"));
assert!(html.contains("id=\"command-serve\""));
assert!(html.contains("Usage <code>tool serve [OPTIONS]</code>"));
assert!(
html.contains("<code>--port</code> <code class=\"value-token\"><U16></code>")
);
assert!(html.contains("Default <code>8080</code>"));
}
#[test]
fn test_html_help_formats_false_bool_and_enum_values() {
#[derive(Facet)]
#[allow(dead_code)]
struct Args {
/// Enable feature.
#[facet(args::named, default)]
feature: bool,
/// Shell to generate.
#[facet(args::named)]
shell: Option<Shell>,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum Shell {
Bash,
Zsh,
Fish,
}
let html = generate_html_help::<Args>(&HelpConfig::default());
assert!(html.contains("<code>--feature</code>"));
assert!(!html.contains("--[no-]feature"));
assert!(!html.contains("Default <code>false</code>"));
assert!(html.contains("<code>--shell</code>"));
assert!(html.contains("Optional value<br>Values"));
assert!(!html.contains("--shell <bash,zsh,fish>"));
assert!(html.contains("<br>Values <code class=\"value-token\">bash</code>"));
}
#[test]
fn test_write_html_help_to_temp_file_keeps_file() {
let path = write_html_help_to_temp_file("<!doctype html><title>help</title>")
.expect("HTML help should be written");
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some("index.html")
);
assert!(path.exists());
assert_eq!(
std::fs::read_to_string(path).expect("HTML help should be readable"),
"<!doctype html><title>help</title>"
);
}
#[test]
fn test_long_doc_comment_wraps() {
#[derive(Facet)]
struct LongDocArgs {
/// This is a very long description that should definitely be wrapped because it exceeds the default width of eighty columns by quite a bit
#[facet(args::named)]
output: String,
}
let schema = Schema::from_shape(LongDocArgs::SHAPE).unwrap();
let config = HelpConfig {
width: 80,
..Default::default()
};
let help = generate_help_for_subcommand(&schema, &[], &config);
eprintln!("{help}");
// Every doc-comment line should fit within 80 columns
for line in help.lines() {
// Strip ANSI escape codes before measuring length
let plain: String = line
.chars()
.fold((String::new(), false), |(mut s, in_esc), c| {
if in_esc {
if c == 'm' { (s, false) } else { (s, true) }
} else if c == '\x1b' {
(s, true)
} else {
s.push(c);
(s, false)
}
})
.0;
assert!(
plain.len() <= 80,
"line exceeds 80 columns ({} chars): {:?}",
plain.len(),
plain
);
}
}
}