aperture-cli 0.1.9

Dynamic CLI generator for OpenAPI specifications
Documentation
//! Dynamic clap command tree generator from cached `OpenAPI` specifications.
//!
//! # `Box::leak` and `'static` lifetimes
//!
//! Clap requires `'static` strings for command and argument names. Since
//! operation IDs, parameter names, and tag names are determined at runtime
//! from the `OpenAPI` spec, we use [`Box::leak`] via [`to_static_str`] to
//! convert owned `String`s into `&'static str`.
//!
//! This is the standard pattern for dynamic clap usage and is safe because:
//! - The CLI binary runs once and exits — leaked memory is reclaimed by the OS.
//! - Total leaked memory is bounded by the spec size (typically <100KB).
//! - No long-running process or repeated allocation occurs.

use crate::cache::models::{CachedCommand, CachedParameter, CachedSpec};
use crate::constants;
use crate::utils::to_kebab_case;
use clap::{Arg, ArgAction, Command};
use std::collections::HashMap;
use std::fmt::Write;

/// Converts a String to a 'static str by leaking it
///
/// This is necessary for clap's API which requires 'static strings.
/// In a CLI context, this is acceptable as the program runs once and exits.
fn to_static_str(s: String) -> &'static str {
    Box::leak(s.into_boxed_str())
}

/// Builds help text with examples for a command
fn build_help_text_with_examples(cached_command: &CachedCommand) -> String {
    let mut help_text = cached_command.description.clone().unwrap_or_default();

    if cached_command.examples.is_empty() {
        return help_text;
    }

    help_text.push_str("\n\nExamples:");
    for example in &cached_command.examples {
        write!(
            &mut help_text,
            "\n  {}\n    {}",
            example.description, example.command_line
        )
        .expect("writing to String buffer cannot fail");

        // Add explanation if present
        if let Some(explanation) = example.explanation.as_ref() {
            write!(&mut help_text, "\n    ({explanation})")
                .expect("writing to String buffer cannot fail");
        }
    }

    help_text
}

/// Generates a dynamic clap command tree from a cached `OpenAPI` specification.
///
/// This function creates a hierarchical command structure based on the `OpenAPI` spec:
/// - Root command: "api" (`CLI_ROOT_COMMAND`)
/// - Tag groups: Operations are grouped by their tags (e.g., "users", "posts")
/// - Operations: Individual API operations as subcommands under their tag group
///
/// # Arguments
/// * `spec` - The cached `OpenAPI` specification
/// * `experimental_flags` - Whether to use flag-based syntax for all parameters
///
/// # Returns
/// A clap Command configured with all operations from the spec
///
/// # Example
/// For an API with a "users" tag containing "getUser" and "createUser" operations:
/// ```text
/// api users get-user <args>
/// api users create-user <args>
/// ```
#[must_use]
pub fn generate_command_tree(spec: &CachedSpec) -> Command {
    generate_command_tree_with_flags(spec, false)
}

/// Generates a dynamic clap command tree with optional legacy positional parameter syntax.
#[must_use]
pub fn generate_command_tree_with_flags(spec: &CachedSpec, use_positional_args: bool) -> Command {
    let mut root_command = Command::new(constants::CLI_ROOT_COMMAND)
        .version(to_static_str(spec.version.clone()))
        .about(format!("CLI for {} API", spec.name))
        // Add global flags that should be available to all operations
        // These are hidden from subcommand help to reduce noise - they're documented in `aperture --help`
        .arg(
            Arg::new("jq")
                .long("jq")
                .global(true)
                .hide(true)
                .help("Apply JQ filter to response data (e.g., '.name', '.[] | select(.active)')")
                .value_name("FILTER")
                .action(ArgAction::Set),
        )
        .arg(
            Arg::new("format")
                .long("format")
                .global(true)
                .hide(true)
                .help("Output format for response data")
                .value_name("FORMAT")
                .value_parser(["json", "yaml", "table"])
                .default_value("json")
                .action(ArgAction::Set),
        )
        .arg(
            Arg::new("server-var")
                .long("server-var")
                .global(true)
                .hide(true)
                .help("Set server template variable (e.g., --server-var region=us --server-var env=prod)")
                .value_name("KEY=VALUE")
                .action(ArgAction::Append),
        );

    // Group commands by their effective group name (display_group override or tag)
    let mut command_groups: HashMap<String, Vec<&CachedCommand>> = HashMap::new();

    for command in &spec.commands {
        let group_name = effective_group_name(command);
        command_groups.entry(group_name).or_default().push(command);
    }

    // Build subcommands for each group
    for (group_name, commands) in command_groups {
        let group_name_kebab = to_kebab_case(&group_name);
        let group_name_static = to_static_str(group_name_kebab);
        let mut group_command = Command::new(group_name_static)
            .about(format!("{} operations", capitalize_first(&group_name)));

        // Add operations as subcommands
        for cached_command in commands {
            let subcommand_name = effective_subcommand_name(cached_command);
            let subcommand_name_static = to_static_str(subcommand_name);

            // Build help text with examples
            let help_text = build_help_text_with_examples(cached_command);

            let mut operation_command = Command::new(subcommand_name_static).about(help_text);

            // Add parameters as CLI arguments
            for param in &cached_command.parameters {
                let arg = create_arg_from_parameter(param, use_positional_args);
                operation_command = operation_command.arg(arg);
            }

            // Add request body argument if present
            if let Some(request_body) = &cached_command.request_body {
                operation_command = add_body_args(operation_command, request_body.required);
            }

            // Add custom header support
            operation_command = operation_command.arg(
                Arg::new("header")
                    .long("header")
                    .short('H')
                    .help("Pass custom header(s) to the request. Format: 'Name: Value'. Can be used multiple times.")
                    .value_name("HEADER")
                    .action(ArgAction::Append),
            );

            // Add examples flag for showing extended examples
            operation_command = operation_command.arg(
                Arg::new("show-examples")
                    .long("show-examples")
                    .help("Show extended usage examples for this command")
                    .action(ArgAction::SetTrue),
            );

            // Apply command mapping: aliases
            if !cached_command.aliases.is_empty() {
                let alias_strs: Vec<&'static str> = cached_command
                    .aliases
                    .iter()
                    .map(|a| to_static_str(to_kebab_case(a)))
                    .collect();
                operation_command = operation_command.visible_aliases(alias_strs);
            }

            // Apply command mapping: hidden
            if cached_command.hidden {
                operation_command = operation_command.hide(true);
            }

            group_command = group_command.subcommand(operation_command);
        }

        root_command = root_command.subcommand(group_command);
    }

    root_command
}

/// Attaches `--body` and `--body-file` args to a command that accepts a request body.
///
/// When the spec marks the body as required, `--body-file` is an equally valid way to
/// satisfy that requirement. `required_unless_present` lets clap enforce "at least one
/// of the two" without rejecting `--body-file` on its own.
fn add_body_args(cmd: Command, required: bool) -> Command {
    let body_arg = Arg::new("body")
        .long("body")
        .help("Request body as JSON")
        .value_name("JSON")
        .conflicts_with("body-file")
        .action(ArgAction::Set);

    // required_unless_present: --body is required UNLESS --body-file is present.
    // Without this, clap enforces required(true) on --body independently of the
    // conflicts_with guard, causing --body-file-only invocations to be rejected.
    let body_arg = if required {
        body_arg.required_unless_present("body-file")
    } else {
        body_arg
    };

    cmd.arg(body_arg).arg(
        Arg::new("body-file")
            .long("body-file")
            .help("Read request body from a file path, or - for stdin")
            .value_name("PATH")
            .conflicts_with("body")
            .action(ArgAction::Set),
    )
}

/// Returns the effective group name for a command, using `display_group` override if present.
fn effective_group_name(command: &CachedCommand) -> String {
    command.display_group.as_ref().map_or_else(
        || {
            if command.name.is_empty() {
                constants::DEFAULT_GROUP.to_string()
            } else {
                command.name.clone()
            }
        },
        Clone::clone,
    )
}

/// Returns the effective subcommand name for a command, using `display_name` override if present.
fn effective_subcommand_name(command: &CachedCommand) -> String {
    command.display_name.as_ref().map_or_else(
        || {
            if command.operation_id.is_empty() {
                command.method.to_lowercase()
            } else {
                to_kebab_case(&command.operation_id)
            }
        },
        |n| to_kebab_case(n),
    )
}

/// Creates a clap Arg from a `CachedParameter`
///
/// # Boolean Parameter Handling
///
/// Boolean parameters use `ArgAction::SetTrue`, treating them as flags:
///
/// **Path Parameters:**
/// - Always optional regardless of `OpenAPI` `required` field
/// - Flag presence = true (substitutes "true" in path), absence = false (substitutes "false")
/// - Example: `/items/{active}` with `--active` → `/items/true`, without → `/items/false`
///
/// **Query/Header Parameters:**
/// - **Optional booleans** (`required: false`): Flag presence = true, absence = false
/// - **Required booleans** (`required: true`): Flag MUST be provided, presence = true
/// - Example: `--verbose` (optional) omitted means `verbose=false`
///
/// This differs from non-boolean parameters which require explicit values (e.g., `--id 123`).
fn create_arg_from_parameter(param: &CachedParameter, use_positional_args: bool) -> Arg {
    let param_name_static = to_static_str(param.name.clone());
    let mut arg = Arg::new(param_name_static);

    // Check if this is a boolean parameter (type: "boolean" in OpenAPI schema)
    let is_boolean = param.schema_type.as_ref().is_some_and(|t| t == "boolean");

    match param.location.as_str() {
        "path" => {
            match (use_positional_args, is_boolean) {
                (true, true) => {
                    // Boolean path parameters must use SetTrue even in positional mode
                    // because executor always reads them via get_flag()
                    // They remain as flags, not positional args, to avoid clap panic
                    let long_name = to_static_str(to_kebab_case(&param.name));
                    arg = arg
                        .long(long_name)
                        .help(format!("Path parameter: {}", param.name))
                        .required(false)
                        .action(ArgAction::SetTrue);
                }
                (true, false) => {
                    // Legacy mode: path parameters are positional arguments (non-boolean)
                    let value_name = to_static_str(param.name.to_uppercase());
                    arg = arg
                        .help(format!("{} parameter", param.name))
                        .value_name(value_name)
                        .required(param.required)
                        .action(ArgAction::Set);
                }
                (false, true) => {
                    // Default mode: Boolean path parameters are treated as flags
                    // Always optional: flag presence = true, absence = false (substituted in path)
                    // This provides consistent UX regardless of OpenAPI required field
                    let long_name = to_static_str(to_kebab_case(&param.name));
                    arg = arg
                        .long(long_name)
                        .help(format!("Path parameter: {}", param.name))
                        .required(false)
                        .action(ArgAction::SetTrue);
                }
                (false, false) => {
                    // Default mode: non-boolean path parameters
                    let long_name = to_static_str(to_kebab_case(&param.name));
                    let value_name = to_static_str(param.name.to_uppercase());
                    arg = arg
                        .long(long_name)
                        .help(format!("Path parameter: {}", param.name))
                        .value_name(value_name)
                        .required(param.required)
                        .action(ArgAction::Set);
                }
            }
        }
        "query" | "header" => {
            // Query and header parameters are flags
            let long_name = to_static_str(to_kebab_case(&param.name));

            if is_boolean {
                // Boolean parameters are proper flags
                // Required booleans must be provided; optional booleans default to false when absent
                arg = arg
                    .long(long_name)
                    .help(format!(
                        "{} {} parameter",
                        capitalize_first(&param.location),
                        param.name
                    ))
                    .required(param.required)
                    .action(ArgAction::SetTrue);
                return arg;
            }

            let value_name = to_static_str(param.name.to_uppercase());
            arg = arg
                .long(long_name)
                .help(format!(
                    "{} {} parameter",
                    capitalize_first(&param.location),
                    param.name
                ))
                .value_name(value_name)
                .required(param.required)
                .action(ArgAction::Set);
        }
        _ => {
            // Unknown location, treat as flag
            let long_name = to_static_str(to_kebab_case(&param.name));

            if is_boolean {
                // Boolean parameters are proper flags
                // Required booleans must be provided; optional booleans default to false when absent
                arg = arg
                    .long(long_name)
                    .help(format!("{} parameter", param.name))
                    .required(param.required)
                    .action(ArgAction::SetTrue);
                return arg;
            }

            let value_name = to_static_str(param.name.to_uppercase());
            arg = arg
                .long(long_name)
                .help(format!("{} parameter", param.name))
                .value_name(value_name)
                .required(param.required)
                .action(ArgAction::Set);
        }
    }

    arg
}

/// Capitalizes the first letter of a string
fn capitalize_first(s: &str) -> String {
    let mut chars = s.chars();
    chars.next().map_or_else(String::new, |first| {
        first.to_uppercase().chain(chars).collect()
    })
}