Skip to content

build() causes duplicate command paths in generated markdown when using subcommands #54

@ChanTsune

Description

@ChanTsune

Description

When using clap-markdown with a CLI that has subcommands, calling Command::build() before generating markdown results in duplicate command paths in:

  1. Section headers (e.g., ## myapp myapp-create instead of ## myapp create)
  2. Usage lines (e.g., Usage: myapp myapp create instead of Usage: myapp create)
  3. Table of Contents entries

Steps to Reproduce

use clap::{CommandFactory, Parser, Subcommand};

#[derive(Parser)]
#[command(name = "myapp")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Create something
    Create,
    /// List items
    List,
}

fn main() {
    let mut cmd = Cli::command();

    // build() is often needed to propagate global arguments
    cmd.build();

    let markdown = clap_markdown::help_markdown_command(&cmd);
    println!("{}", markdown);
}

Expected Behavior

## `myapp create`

Create something

**Usage:** `myapp create`

Actual Behavior

## `myapp myapp-create`

Create something

**Usage:** `myapp myapp create`

Root Cause Analysis

After investigating, the issue stems from how clap and clap-markdown interact:

  1. clap's build() behavior: When build() is called, clap sets display_name and bin_name on subcommands to include the parent command path:

    • display_name = "myapp-create" (hyphen-separated)
    • bin_name = "myapp create" (space-separated)
  2. clap-markdown's get_canonical_name() function (in src/lib.rs):

    fn get_canonical_name(command: &clap::Command) -> String {
        command
            .get_display_name()
            .or_else(|| command.get_bin_name())
            .map(|name| name.to_owned())
            .unwrap_or_else(|| command.get_name().to_owned())
    }

    This returns "myapp-create" (from display_name) instead of just "create".

  3. Path building in build_command_markdown(): The code builds paths by accumulating:

    let command_path = {
        let mut command_path = parent_command_path.clone();
        command_path.push(title_name);  // title_name = "myapp-create"
        command_path
    };

    Result: ["myapp", "myapp-create"] → displayed as "myapp myapp-create"

  4. Usage line generation:

    writeln!(
        buffer,
        "**Usage:** `{}{}`\n",
        if parent_command_path.is_empty() {
            String::new()
        } else {
            let mut s = parent_command_path.join(" ");
            s.push_str(" ");
            s
        },
        command.clone().render_usage().to_string().replace("Usage: ", "")
    )?;
    • render_usage() returns "Usage: myapp create ..." (uses bin_name)
    • After stripping prefix: "myapp create ..."
    • Prepending parent path: "myapp " + "myapp create ..." = "myapp myapp create ..."

Why build() is Often Necessary

Calling build() is required when:

  • Using global arguments that need to be propagated to subcommands
  • Subcommands reference arguments defined at the parent level (e.g., #[arg(requires = "some_global_arg")])

Without build(), clap-markdown may panic with errors like:

Command create: Argument or group 'some_global_arg' specified in 'requires*' for 'some_flag' does not exist

Suggested Solutions

Option 1: Use get_name() instead of get_canonical_name() for building command paths

fn get_canonical_name(command: &clap::Command) -> String {
    // Always use the simple command name for path building
    command.get_name().to_owned()
}

Option 2: Check if display_name/bin_name already contains the parent path and avoid double-adding

Option 3: Don't prepend parent_command_path to the usage string since render_usage() already includes the full path after build()

Option 4: Clear display_name and bin_name internally before processing

Current Workaround

We currently work around this by:

  1. Clearing display_name on all subcommands after build():

    fn clear_display_names(cmd: &mut clap::Command) {
        for sub in cmd.get_subcommands_mut() {
            let mut owned = std::mem::take(sub);
            owned = owned.display_name(clap::builder::Resettable::Reset);
            clear_display_names(&mut owned);
            *sub = owned;
        }
    }
  2. Post-processing the generated markdown to remove duplicate patterns like "myapp myapp ""myapp "

Environment

  • clap version: 4.5.x
  • clap-markdown version: 0.1.4
  • Rust version: 1.85

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions