Skip to main content

clap_markdown_generator/
lib.rs

1//! Generate Markdown documentation from a [`clap`] command definition.
2//!
3//! The most common entry point is [`generate_markdown`], which accepts any type
4//! deriving `clap::Parser`.
5//!
6//! ```
7//! use clap::Parser;
8//!
9//! #[derive(Parser)]
10//! #[command(name = "node", about = "Runs a node")]
11//! struct Cli {
12//!     /// Path to the config file
13//!     #[arg(long, default_value = "config.toml")]
14//!     config: String,
15//! }
16//!
17//! let markdown = clap_markdown_generator::generate_markdown::<Cli>();
18//! assert!(markdown.contains("--config <CONFIG>"));
19//! assert!(markdown.contains("<a id=\"node-config\"></a>"));
20//! ```
21
22use std::{ffi::OsStr, fmt, sync::Arc};
23
24use clap::{Arg, ArgAction, Command, CommandFactory};
25
26/// Generate Markdown for any type that can build a [`clap::Command`].
27///
28/// This works with structs deriving `clap::Parser`, `clap::Args`, or any type
29/// manually implementing [`CommandFactory`].
30pub fn generate_markdown<T>() -> String
31where
32    T: CommandFactory,
33{
34    generate_markdown_for_command(T::command())
35}
36
37/// Generate Markdown for an existing [`clap::Command`].
38pub fn generate_markdown_for_command(command: Command) -> String {
39    MarkdownRenderer::default().render(command)
40}
41
42/// Rendering options for Markdown generation.
43#[derive(Debug, Clone)]
44pub struct MarkdownOptions {
45    /// Include hidden clap arguments and subcommands.
46    pub include_hidden: bool,
47    /// Include subcommands recursively.
48    pub include_subcommands: bool,
49    /// Include a small parameter summary with links to each parameter.
50    pub include_toc: bool,
51    /// Skip the detailed parameter sections.
52    pub skip_parameter_details: bool,
53    /// Include explicit HTML anchor id elements before parameters and subcommands.
54    pub include_html_anchors: bool,
55    /// Include a parameter usage line in detailed parameter content.
56    pub include_usage: bool,
57    /// Controls how command headings are rendered.
58    pub command_heading: CommandHeadingStyle,
59    /// Controls how the parameter summary is rendered.
60    pub summary: SummaryOptions,
61    /// Controls how each detailed parameter heading is rendered.
62    pub parameter_heading: ParameterHeadingStyle,
63    /// Controls how each detailed parameter body is rendered.
64    pub parameter_content: ParameterContentStyle,
65}
66
67impl Default for MarkdownOptions {
68    fn default() -> Self {
69        Self {
70            include_hidden: false,
71            include_subcommands: true,
72            include_toc: true,
73            skip_parameter_details: false,
74            include_html_anchors: true,
75            include_usage: true,
76            command_heading: CommandHeadingStyle::Display,
77            summary: SummaryOptions::default(),
78            parameter_heading: ParameterHeadingStyle::Display,
79            parameter_content: ParameterContentStyle::Table,
80        }
81    }
82}
83
84/// Rendering options for the parameter summary.
85#[derive(Debug, Clone)]
86pub struct SummaryOptions {
87    /// Include the parameter summary.
88    pub enabled: bool,
89    /// Controls whether values like `<CONFIG>` are shown in summary entries.
90    pub value_style: SummaryValueStyle,
91    /// Include the first line of each parameter description.
92    pub include_description: bool,
93    /// Controls how each parameter summary entry is rendered.
94    pub entry: SummaryEntryStyle,
95}
96
97impl Default for SummaryOptions {
98    fn default() -> Self {
99        Self {
100            enabled: true,
101            value_style: SummaryValueStyle::NamesAndValues,
102            include_description: true,
103            entry: SummaryEntryStyle::Default,
104        }
105    }
106}
107
108/// Information available to command heading callbacks.
109#[derive(Debug, Clone)]
110pub struct CommandInfo {
111    /// Current command name.
112    pub name: String,
113    /// Full command path from the root command to the current command.
114    pub path: Vec<String>,
115    /// Full command path formatted for display, such as `app run`.
116    pub display: String,
117    /// Command description, if one is configured.
118    pub description: Option<String>,
119    /// Markdown heading level used for this command.
120    pub heading_level: usize,
121}
122
123/// Information available to parameter formatting callbacks.
124#[derive(Debug, Clone)]
125pub struct ParameterInfo {
126    /// Stable anchor id for this parameter.
127    pub anchor: String,
128    /// clap argument id.
129    pub name: String,
130    /// Full display form, such as `-c <CONFIG>, --config <CONFIG>`.
131    pub display: String,
132    /// Display form without values, such as `-c, --config`.
133    pub display_names: String,
134    /// Parameter description, if one is configured.
135    pub description: Option<String>,
136    /// Whether this parameter is required.
137    pub required: bool,
138    /// Whether this parameter accepts multiple occurrences or values.
139    pub multiple: bool,
140    /// Whether this parameter takes a value.
141    pub takes_value: bool,
142    /// clap value names.
143    pub value_names: Vec<String>,
144    /// clap default values.
145    pub default_values: Vec<String>,
146    /// Environment variable name, if one is configured.
147    pub env: Option<String>,
148    /// Possible values exposed by the clap value parser.
149    pub possible_values: Vec<String>,
150}
151
152/// Controls how command headings are rendered.
153#[derive(Clone)]
154pub enum CommandHeadingStyle {
155    /// Render the full command path inside backticks.
156    Display,
157    /// Skip command headings.
158    None,
159    /// Render the command heading with a callback.
160    ///
161    /// If the callback returns an empty string, the heading is skipped.
162    Custom(Arc<dyn Fn(&CommandInfo) -> String + Send + Sync + 'static>),
163}
164
165impl CommandHeadingStyle {
166    /// Create a custom command heading callback.
167    pub fn custom(formatter: impl Fn(&CommandInfo) -> String + Send + Sync + 'static) -> Self {
168        Self::Custom(Arc::new(formatter))
169    }
170
171    /// Render a command heading for the given command.
172    pub fn render(&self, command: &CommandInfo) -> String {
173        match self {
174            Self::Display => format!("`{}`", escape_markdown_text(&command.display)),
175            Self::None => String::new(),
176            Self::Custom(formatter) => formatter(command),
177        }
178    }
179}
180
181impl fmt::Debug for CommandHeadingStyle {
182    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183        match self {
184            Self::Display => formatter.write_str("Display"),
185            Self::None => formatter.write_str("None"),
186            Self::Custom(_) => formatter.write_str("Custom(<callback>)"),
187        }
188    }
189}
190
191/// Controls how parameter summary entries are rendered.
192#[derive(Clone)]
193pub enum SummaryEntryStyle {
194    /// Render the default linked summary entry.
195    Default,
196    /// Render each summary entry with a callback.
197    ///
198    /// The callback should return a complete Markdown list item. If it returns
199    /// an empty string, the parameter is skipped from the summary.
200    Custom(Arc<dyn Fn(&ParameterInfo) -> String + Send + Sync + 'static>),
201}
202
203impl SummaryEntryStyle {
204    /// Create a custom summary entry callback.
205    pub fn custom(formatter: impl Fn(&ParameterInfo) -> String + Send + Sync + 'static) -> Self {
206        Self::Custom(Arc::new(formatter))
207    }
208
209    /// Render a summary entry for the given parameter.
210    pub fn render(&self, parameter: &ParameterInfo, options: &SummaryOptions, output: &mut String) {
211        match self {
212            Self::Default => render_default_summary_entry(parameter, options, output),
213            Self::Custom(formatter) => {
214                let entry = formatter(parameter);
215                if !entry.is_empty() {
216                    output.push_str(&entry);
217                    if !entry.ends_with('\n') {
218                        output.push('\n');
219                    }
220                }
221            }
222        }
223    }
224}
225
226impl fmt::Debug for SummaryEntryStyle {
227    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
228        match self {
229            Self::Default => formatter.write_str("Default"),
230            Self::Custom(_) => formatter.write_str("Custom(<callback>)"),
231        }
232    }
233}
234
235/// Controls how parameter names are rendered in the summary.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum SummaryValueStyle {
238    /// Render only parameter names, such as `-c, --config`.
239    NamesOnly,
240    /// Render parameter names and their values, such as `-c <CONFIG>, --config <CONFIG>`.
241    NamesAndValues,
242}
243
244/// Controls how detailed parameter headings are rendered.
245#[derive(Clone)]
246pub enum ParameterHeadingStyle {
247    /// Render the full clap display form, such as ``### `-c <CONFIG>, --config <CONFIG>```.
248    Display,
249    /// Render the clap argument id, such as `### config`.
250    Name,
251    /// Render the parameter heading with a callback.
252    ///
253    /// If the callback returns an empty string, the heading is skipped.
254    Custom(Arc<dyn Fn(&ParameterInfo) -> String + Send + Sync + 'static>),
255}
256
257impl ParameterHeadingStyle {
258    /// Create a custom parameter heading callback.
259    pub fn custom(formatter: impl Fn(&ParameterInfo) -> String + Send + Sync + 'static) -> Self {
260        Self::Custom(Arc::new(formatter))
261    }
262
263    /// Render a parameter heading for the given parameter.
264    pub fn render(&self, parameter: &ParameterInfo) -> String {
265        match self {
266            Self::Display => {
267                format!("`{}`", escape_markdown_text(&parameter.display))
268            }
269            Self::Name => parameter.name.clone(),
270            Self::Custom(formatter) => formatter(parameter),
271        }
272    }
273}
274
275impl fmt::Debug for ParameterHeadingStyle {
276    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
277        match self {
278            Self::Display => formatter.write_str("Display"),
279            Self::Name => formatter.write_str("Name"),
280            Self::Custom(_) => formatter.write_str("Custom(<callback>)"),
281        }
282    }
283}
284
285/// Controls how detailed parameter content is rendered.
286#[derive(Clone)]
287pub enum ParameterContentStyle {
288    /// Render the metadata table.
289    Table,
290    /// Render metadata as compact prose.
291    Text,
292    /// Render the parameter content with a callback.
293    ///
294    /// If the callback returns an empty string, no content is rendered.
295    Custom(Arc<dyn Fn(&ParameterInfo) -> String + Send + Sync + 'static>),
296}
297
298impl ParameterContentStyle {
299    /// Create a custom parameter content callback.
300    pub fn custom(formatter: impl Fn(&ParameterInfo) -> String + Send + Sync + 'static) -> Self {
301        Self::Custom(Arc::new(formatter))
302    }
303
304    /// Render the parameter content for the given parameter.
305    pub fn render(
306        &self,
307        parameter: &ParameterInfo,
308        options: &ParameterContentOptions,
309        output: &mut String,
310    ) {
311        match self {
312            Self::Table => render_parameter_table(parameter, options, output),
313            Self::Text => render_parameter_text(parameter, options, output),
314            Self::Custom(formatter) => {
315                push_custom_block(output, &formatter(parameter));
316            }
317        }
318    }
319}
320
321impl fmt::Debug for ParameterContentStyle {
322    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
323        match self {
324            Self::Table => formatter.write_str("Table"),
325            Self::Text => formatter.write_str("Text"),
326            Self::Custom(_) => formatter.write_str("Custom(<callback>)"),
327        }
328    }
329}
330
331#[derive(Debug, Clone, Copy)]
332pub struct ParameterContentOptions {
333    pub include_usage: bool,
334}
335
336/// Generate Markdown for an existing [`clap::Command`] with explicit options.
337pub fn generate_markdown_for_command_with_options(
338    command: Command,
339    options: MarkdownOptions,
340) -> String {
341    MarkdownRenderer { options }.render(command)
342}
343
344#[derive(Debug, Default)]
345struct MarkdownRenderer {
346    options: MarkdownOptions,
347}
348
349impl MarkdownRenderer {
350    fn render(&self, mut command: Command) -> String {
351        command.build();
352
353        let mut output = String::new();
354        let command_path = vec![command.get_name().to_owned()];
355
356        self.render_command(&command, &command_path, 1, &mut output);
357        trim_trailing_blank_lines(&mut output);
358        output.push('\n');
359        output
360    }
361
362    fn render_command(
363        &self,
364        command: &Command,
365        command_path: &[String],
366        heading_level: usize,
367        output: &mut String,
368    ) {
369        let command_info = command_info(command, command_path, heading_level);
370        let command_heading = self.options.command_heading.render(&command_info);
371        if !command_heading.is_empty() {
372            push_heading(output, heading_level, &command_heading);
373        }
374
375        if let Some(description) = &command_info.description {
376            output.push_str(description);
377            output.push_str("\n\n");
378        }
379
380        let parameters = self.collect_parameters(command, command_path);
381        if self.options.include_toc && self.options.summary.enabled && !parameters.is_empty() {
382            push_heading(output, heading_level + 1, "Parameters");
383            for parameter in &parameters {
384                self.render_summary_entry(parameter, output);
385            }
386            output.push('\n');
387        }
388
389        if !self.options.skip_parameter_details {
390            for parameter in &parameters {
391                self.render_parameter(parameter, heading_level + 2, output);
392            }
393        }
394
395        let subcommands = self.visible_subcommands(command);
396        if self.options.include_subcommands && !subcommands.is_empty() {
397            push_heading(output, heading_level + 1, "Subcommands");
398            for subcommand in &subcommands {
399                let mut subcommand_path = command_path.to_vec();
400                subcommand_path.push(subcommand.get_name().to_owned());
401
402                let anchor = slugify(&subcommand_path.join("-"));
403                if self.options.include_html_anchors {
404                    output.push_str(&format!("<a id=\"{}\"></a>\n", anchor));
405                }
406                output.push_str(&format!("- [`{}`](#{})", subcommand.get_name(), anchor));
407                if let Some(description) = command_description(subcommand)
408                    && let Some(summary) = summary_line(&description)
409                {
410                    output.push_str(&format!(": {}", summary));
411                }
412                output.push('\n');
413            }
414            output.push('\n');
415
416            for subcommand in subcommands {
417                let mut subcommand_path = command_path.to_vec();
418                subcommand_path.push(subcommand.get_name().to_owned());
419                self.render_command(subcommand, &subcommand_path, heading_level + 1, output);
420            }
421        }
422    }
423
424    fn render_parameter(
425        &self,
426        parameter: &ParameterInfo,
427        heading_level: usize,
428        output: &mut String,
429    ) {
430        if self.options.include_html_anchors {
431            output.push_str(&format!("<a id=\"{}\"></a>\n", parameter.anchor));
432        }
433        let heading = self.options.parameter_heading.render(parameter);
434        if !heading.is_empty() {
435            push_heading(output, heading_level, &heading);
436        }
437
438        if let Some(description) = &parameter.description {
439            output.push_str(description);
440            output.push_str("\n\n");
441        }
442
443        self.options.parameter_content.render(
444            parameter,
445            &ParameterContentOptions {
446                include_usage: self.options.include_usage,
447            },
448            output,
449        );
450    }
451
452    fn render_summary_entry(&self, parameter: &ParameterInfo, output: &mut String) {
453        self.options
454            .summary
455            .entry
456            .render(parameter, &self.options.summary, output);
457    }
458
459    fn collect_parameters(&self, command: &Command, command_path: &[String]) -> Vec<ParameterInfo> {
460        command
461            .get_arguments()
462            .filter(|arg| !is_generated_action(arg))
463            .filter(|arg| self.options.include_hidden || !arg.is_hide_set())
464            .map(|arg| parameter_doc(arg, command_path))
465            .collect()
466    }
467
468    fn visible_subcommands<'a>(&self, command: &'a Command) -> Vec<&'a Command> {
469        if !self.options.include_subcommands {
470            return Vec::new();
471        }
472
473        command
474            .get_subcommands()
475            .filter(|subcommand| !is_generated_help_subcommand(subcommand))
476            .filter(|subcommand| self.options.include_hidden || !subcommand.is_hide_set())
477            .collect()
478    }
479}
480
481fn render_default_summary_entry(
482    parameter: &ParameterInfo,
483    options: &SummaryOptions,
484    output: &mut String,
485) {
486    let summary_display = match options.value_style {
487        SummaryValueStyle::NamesOnly => &parameter.display_names,
488        SummaryValueStyle::NamesAndValues => &parameter.display,
489    };
490    output.push_str(&format!(
491        "- [`{}`](#{})",
492        escape_markdown_text(summary_display),
493        parameter.anchor
494    ));
495
496    if options.include_description
497        && let Some(description) = &parameter.description
498        && let Some(summary) = summary_line(description)
499    {
500        output.push_str(&format!(": {}", summary));
501    }
502
503    output.push('\n');
504}
505
506fn render_parameter_table(
507    parameter: &ParameterInfo,
508    options: &ParameterContentOptions,
509    output: &mut String,
510) {
511    output.push_str("| Field | Value |\n");
512    output.push_str("| --- | --- |\n");
513    if options.include_usage {
514        output.push_str(&format!(
515            "| Usage | `{}` |\n",
516            escape_table_cell(&parameter.display)
517        ));
518    }
519    output.push_str(&format!("| Required | {} |\n", yes_no(parameter.required)));
520    output.push_str(&format!(
521        "| Value | {} |\n",
522        if parameter.takes_value { "Yes" } else { "No" }
523    ));
524
525    if !parameter.value_names.is_empty() {
526        output.push_str(&format!(
527            "| Value name | {} |\n",
528            parameter
529                .value_names
530                .iter()
531                .map(|value| format!("`{}`", escape_table_cell(value)))
532                .collect::<Vec<_>>()
533                .join(", ")
534        ));
535    }
536
537    if parameter.multiple {
538        output.push_str("| Multiple | Yes |\n");
539    }
540
541    if !parameter.default_values.is_empty() {
542        output.push_str(&format!(
543            "| Default value | {} |\n",
544            parameter
545                .default_values
546                .iter()
547                .map(|value| format!("`{}`", escape_table_cell(value)))
548                .collect::<Vec<_>>()
549                .join(", ")
550        ));
551    }
552
553    if let Some(env) = &parameter.env {
554        output.push_str(&format!("| Environment | `{}` |\n", escape_table_cell(env)));
555    }
556
557    if !parameter.possible_values.is_empty() {
558        output.push_str(&format!(
559            "| Possible values | {} |\n",
560            parameter
561                .possible_values
562                .iter()
563                .map(|value| format!("`{}`", escape_table_cell(value)))
564                .collect::<Vec<_>>()
565                .join(", ")
566        ));
567    }
568
569    output.push('\n');
570}
571
572fn render_parameter_text(
573    parameter: &ParameterInfo,
574    options: &ParameterContentOptions,
575    output: &mut String,
576) {
577    let mut parts = vec![
578        format!("Required: {}.", yes_no(parameter.required)),
579        format!(
580            "Value: {}.",
581            if parameter.takes_value { "Yes" } else { "No" }
582        ),
583    ];
584
585    if options.include_usage {
586        parts.push(format!(
587            "Usage: `{}`.",
588            escape_markdown_text(&parameter.display)
589        ));
590    }
591
592    if !parameter.value_names.is_empty() {
593        parts.push(format!(
594            "Value name: {}.",
595            parameter
596                .value_names
597                .iter()
598                .map(|value| format!("`{}`", escape_markdown_text(value)))
599                .collect::<Vec<_>>()
600                .join(", ")
601        ));
602    }
603
604    if parameter.multiple {
605        parts.push("Multiple: Yes.".to_owned());
606    }
607
608    if !parameter.default_values.is_empty() {
609        parts.push(format!(
610            "Default value: {}.",
611            parameter
612                .default_values
613                .iter()
614                .map(|value| format!("`{}`", escape_markdown_text(value)))
615                .collect::<Vec<_>>()
616                .join(", ")
617        ));
618    }
619
620    if let Some(env) = &parameter.env {
621        parts.push(format!("Environment: `{}`.", escape_markdown_text(env)));
622    }
623
624    if !parameter.possible_values.is_empty() {
625        parts.push(format!(
626            "Possible values: {}.",
627            parameter
628                .possible_values
629                .iter()
630                .map(|value| format!("`{}`", escape_markdown_text(value)))
631                .collect::<Vec<_>>()
632                .join(", ")
633        ));
634    }
635
636    output.push_str(&parts.join(" "));
637    output.push_str("\n\n");
638}
639
640fn command_info(command: &Command, command_path: &[String], heading_level: usize) -> CommandInfo {
641    CommandInfo {
642        name: command.get_name().to_owned(),
643        path: command_path.to_vec(),
644        display: command_path.join(" "),
645        description: command_description(command),
646        heading_level,
647    }
648}
649
650fn parameter_doc(arg: &Arg, command_path: &[String]) -> ParameterInfo {
651    let display = display_arg(arg);
652    let display_names = display_arg_names(arg);
653    let anchor_parts = command_path
654        .iter()
655        .map(String::as_str)
656        .chain(std::iter::once(arg.get_id().as_str()))
657        .collect::<Vec<_>>()
658        .join("-");
659
660    ParameterInfo {
661        anchor: slugify(&anchor_parts),
662        name: arg.get_id().to_string(),
663        display,
664        display_names,
665        description: arg_description(arg),
666        required: arg.is_required_set(),
667        multiple: arg_allows_multiple(arg),
668        takes_value: arg_takes_value(arg),
669        value_names: arg
670            .get_value_names()
671            .unwrap_or_default()
672            .iter()
673            .map(ToString::to_string)
674            .collect(),
675        default_values: arg
676            .get_default_values()
677            .iter()
678            .map(|value| os_to_string(value.as_os_str()))
679            .collect(),
680        env: arg.get_env().map(os_to_string),
681        possible_values: possible_values(arg),
682    }
683}
684
685fn display_arg(arg: &Arg) -> String {
686    display_arg_with_values(arg, true)
687}
688
689fn display_arg_names(arg: &Arg) -> String {
690    display_arg_with_values(arg, false)
691}
692
693fn display_arg_with_values(arg: &Arg, include_values: bool) -> String {
694    let mut names = Vec::new();
695
696    if let Some(short) = arg.get_short() {
697        names.push(format!("-{}", short));
698    }
699
700    if let Some(long) = arg.get_long() {
701        names.push(format!("--{}", long));
702    }
703
704    if names.is_empty() {
705        names.push(format!("<{}>", arg.get_id()));
706    }
707
708    if !include_values {
709        return names.join(", ");
710    }
711
712    let value_names = arg
713        .get_value_names()
714        .unwrap_or_default()
715        .iter()
716        .map(|name| format!("<{}>", name))
717        .collect::<Vec<_>>();
718
719    if !value_names.is_empty() && arg_takes_value(arg) {
720        let values = value_names.join(" ");
721        names
722            .into_iter()
723            .map(|name| {
724                if name.starts_with('-') {
725                    format!("{} {}", name, values)
726                } else {
727                    name
728                }
729            })
730            .collect::<Vec<_>>()
731            .join(", ")
732    } else {
733        names.join(", ")
734    }
735}
736
737fn command_description(command: &Command) -> Option<String> {
738    command
739        .get_long_about()
740        .or_else(|| command.get_about())
741        .map(ToString::to_string)
742        .map(normalize_description)
743        .filter(|description| !description.is_empty())
744}
745
746fn arg_description(arg: &Arg) -> Option<String> {
747    arg.get_long_help()
748        .or_else(|| arg.get_help())
749        .map(ToString::to_string)
750        .map(normalize_description)
751        .filter(|description| !description.is_empty())
752}
753
754fn possible_values(arg: &Arg) -> Vec<String> {
755    arg.get_value_parser()
756        .possible_values()
757        .into_iter()
758        .flatten()
759        .map(|value| value.get_name().to_owned())
760        .collect()
761}
762
763fn arg_takes_value(arg: &Arg) -> bool {
764    matches!(arg.get_action(), ArgAction::Set | ArgAction::Append)
765}
766
767fn arg_allows_multiple(arg: &Arg) -> bool {
768    matches!(arg.get_action(), ArgAction::Append | ArgAction::Count)
769}
770
771fn is_generated_action(arg: &Arg) -> bool {
772    matches!(
773        arg.get_action(),
774        ArgAction::Help | ArgAction::HelpShort | ArgAction::HelpLong | ArgAction::Version
775    )
776}
777
778fn is_generated_help_subcommand(command: &Command) -> bool {
779    command.get_name() == "help"
780        && command_description(command)
781            .is_some_and(|description| description.starts_with("Print this message or the help"))
782}
783
784fn os_to_string(value: &OsStr) -> String {
785    value.to_string_lossy().into_owned()
786}
787
788fn push_heading(output: &mut String, level: usize, title: &str) {
789    output.push_str(&"#".repeat(level.max(1)));
790    output.push(' ');
791    output.push_str(title);
792    output.push_str("\n\n");
793}
794
795fn trim_trailing_blank_lines(output: &mut String) {
796    while output.ends_with('\n') {
797        output.pop();
798    }
799}
800
801fn push_custom_block(output: &mut String, block: &str) {
802    if block.is_empty() {
803        return;
804    }
805
806    output.push_str(block);
807
808    if !block.ends_with('\n') {
809        output.push('\n');
810    }
811
812    if !output.ends_with("\n\n") {
813        output.push('\n');
814    }
815}
816
817fn normalize_description(description: String) -> String {
818    description.trim().to_owned()
819}
820
821fn summary_line(description: &str) -> Option<&str> {
822    description
823        .lines()
824        .map(str::trim)
825        .find(|line| !line.is_empty())
826}
827
828fn yes_no(value: bool) -> &'static str {
829    if value { "Yes" } else { "No" }
830}
831
832fn escape_markdown_text(value: &str) -> String {
833    value.replace('[', "\\[").replace(']', "\\]")
834}
835
836fn escape_table_cell(value: &str) -> String {
837    escape_markdown_text(value).replace('|', "\\|")
838}
839
840fn slugify(value: &str) -> String {
841    let mut slug = String::new();
842    let mut previous_dash = false;
843
844    for character in value.chars().flat_map(char::to_lowercase) {
845        if character.is_ascii_alphanumeric() {
846            slug.push(character);
847            previous_dash = false;
848        } else if !previous_dash && !slug.is_empty() {
849            slug.push('-');
850            previous_dash = true;
851        }
852    }
853
854    if slug.ends_with('-') {
855        slug.pop();
856    }
857
858    slug
859}