xbp 10.40.1

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use clap::{Arg, ArgAction, Command, CommandFactory};
use serde::Serialize;

use crate::cli::commands::Cli;

#[derive(Debug, Clone, Serialize)]
pub struct CommandReferenceExport {
    pub generated_by: String,
    pub command_path: Vec<String>,
    pub command: CommandNode,
}

#[derive(Debug, Clone, Serialize)]
pub struct CommandNode {
    pub name: String,
    pub display_name: String,
    pub usage: String,
    pub about: Option<String>,
    pub long_about: Option<String>,
    pub after_help: Option<String>,
    pub aliases: Vec<String>,
    pub visible_aliases: Vec<String>,
    pub arguments: Vec<ArgumentNode>,
    pub subcommands: Vec<CommandNode>,
}

#[derive(Debug, Clone, Serialize)]
pub struct ArgumentNode {
    pub id: String,
    pub kind: String,
    pub display: String,
    pub short: Option<String>,
    pub long: Option<String>,
    pub help: Option<String>,
    pub long_help: Option<String>,
    pub required: bool,
    pub global: bool,
    pub repeatable: bool,
    pub positional_index: Option<usize>,
    pub default_values: Vec<String>,
    pub possible_values: Vec<String>,
}

pub fn export_command_reference(path: &[String]) -> Result<CommandReferenceExport, String> {
    let root = sanitize_command_tree(Cli::command());
    let command = select_command(root, path)?;

    Ok(CommandReferenceExport {
        generated_by: "xbp-docs-export".to_string(),
        command_path: path.to_vec(),
        command: export_command_node(&command, path),
    })
}

fn sanitize_command_tree(command: Command) -> Command {
    command
        .disable_help_flag(true)
        .mut_subcommands(sanitize_command_tree)
}

fn select_command(mut command: Command, path: &[String]) -> Result<Command, String> {
    for segment in path {
        let next = command
            .get_subcommands()
            .find(|candidate| command_matches(candidate, segment))
            .cloned()
            .ok_or_else(|| {
                let available = command
                    .get_subcommands()
                    .map(|candidate| candidate.get_name().to_string())
                    .collect::<Vec<_>>()
                    .join(", ");
                format!(
                    "Unknown command path segment `{segment}` under `{}`. Available subcommands: {}",
                    command.get_name(),
                    if available.is_empty() { "<none>" } else { &available }
                )
            })?;
        command = next;
    }

    Ok(command)
}

fn command_matches(command: &Command, segment: &str) -> bool {
    command.get_name() == segment
        || command.get_all_aliases().any(|alias| alias == segment)
        || command.get_visible_aliases().any(|alias| alias == segment)
}

fn export_command_node(command: &Command, path: &[String]) -> CommandNode {
    let display_name = if path.is_empty() {
        "xbp".to_string()
    } else {
        format!("xbp {}", path.join(" "))
    };
    let usage = {
        let mut usage_command = command.clone();
        usage_command
            .render_usage()
            .to_string()
            .replace(
                &format!("Usage: {}", command.get_name()),
                &format!("Usage: {display_name}"),
            )
    };

    CommandNode {
        name: command.get_name().to_string(),
        display_name,
        usage,
        about: command.get_about().map(|value| value.to_string()),
        long_about: command.get_long_about().map(|value| value.to_string()),
        after_help: command.get_after_help().map(|value| value.to_string()),
        aliases: command.get_all_aliases().map(str::to_string).collect(),
        visible_aliases: command
            .get_visible_aliases()
            .map(str::to_string)
            .collect(),
        arguments: command
            .get_arguments()
            .filter(|arg| !arg.is_hide_set())
            .map(export_argument_node)
            .collect(),
        subcommands: command
            .get_subcommands()
            .filter(|subcommand| !subcommand.is_hide_set())
            .map(|subcommand| {
                let mut next_path = path.to_vec();
                next_path.push(subcommand.get_name().to_string());
                export_command_node(subcommand, &next_path)
            })
            .collect(),
    }
}

fn export_argument_node(arg: &Arg) -> ArgumentNode {
    let default_values = arg
        .get_default_values()
        .iter()
        .map(|value| value.to_string_lossy().to_string())
        .collect();
    let possible_values = arg
        .get_possible_values()
        .into_iter()
        .map(|value| value.get_name().to_string())
        .collect();
    let short = arg.get_short().map(|value| format!("-{value}"));
    let long = arg.get_long().map(|value| format!("--{value}"));
    let positional_index = arg.get_index().map(|value| value as usize);
    let value_suffix = if argument_takes_value(arg) {
        format!(" {}", argument_value_placeholder(arg))
    } else {
        String::new()
    };

    let display = if arg.is_positional() {
        argument_value_placeholder(arg)
    } else if let Some(long) = long.as_deref() {
        format!("{long}{value_suffix}")
    } else if let Some(short) = short.as_deref() {
        format!("{short}{value_suffix}")
    } else {
        arg.get_id().as_str().to_string()
    };

    ArgumentNode {
        id: arg.get_id().as_str().to_string(),
        kind: if arg.is_positional() {
            "positional".to_string()
        } else {
            "option".to_string()
        },
        display,
        short,
        long,
        help: arg.get_help().map(|value| value.to_string()),
        long_help: arg.get_long_help().map(|value| value.to_string()),
        required: arg.is_required_set(),
        global: arg.is_global_set(),
        repeatable: matches!(arg.get_action(), ArgAction::Append | ArgAction::Count),
        positional_index,
        default_values,
        possible_values,
    }
}

fn argument_takes_value(arg: &Arg) -> bool {
    matches!(arg.get_action(), ArgAction::Set | ArgAction::Append)
}

fn argument_value_placeholder(arg: &Arg) -> String {
    if let Some(names) = arg.get_value_names() {
        let rendered = names
            .iter()
            .map(|name| format!("<{}>", name.to_ascii_uppercase()))
            .collect::<Vec<_>>();
        if !rendered.is_empty() {
            return rendered.join(" ");
        }
    }

    format!("<{}>", arg.get_id().as_str().replace('-', "_").to_ascii_uppercase())
}

#[cfg(test)]
mod tests {
    use super::export_command_reference;

    #[cfg(feature = "openapi-gen")]
    #[test]
    fn exports_generate_openapi_reference() {
        let export = export_command_reference(&["generate".into(), "openapi".into()])
            .expect("generate openapi export");

        assert_eq!(export.command.name, "openapi");
        assert!(
            export
                .command
                .arguments
                .iter()
                .any(|argument| argument.id == "service" && argument.display == "--service <SERVICE>")
        );
        assert!(
            export
                .command
                .arguments
                .iter()
                .any(|argument| argument.id == "all" && argument.display == "--all")
        );
    }
}