aperture_cli/engine/
generator.rs

1use crate::cache::models::{CachedCommand, CachedParameter, CachedSpec};
2use crate::constants;
3use crate::utils::to_kebab_case;
4use clap::{Arg, ArgAction, Command};
5use std::collections::HashMap;
6
7/// Converts a String to a 'static str by leaking it
8///
9/// This is necessary for clap's API which requires 'static strings.
10/// In a CLI context, this is acceptable as the program runs once and exits.
11fn to_static_str(s: String) -> &'static str {
12    Box::leak(s.into_boxed_str())
13}
14
15/// Generates a dynamic clap command tree from a cached `OpenAPI` specification.
16///
17/// This function creates a hierarchical command structure based on the `OpenAPI` spec:
18/// - Root command: "api" (`CLI_ROOT_COMMAND`)
19/// - Tag groups: Operations are grouped by their tags (e.g., "users", "posts")
20/// - Operations: Individual API operations as subcommands under their tag group
21///
22/// # Arguments
23/// * `spec` - The cached `OpenAPI` specification
24/// * `experimental_flags` - Whether to use flag-based syntax for all parameters
25///
26/// # Returns
27/// A clap Command configured with all operations from the spec
28///
29/// # Example
30/// For an API with a "users" tag containing "getUser" and "createUser" operations:
31/// ```text
32/// api users get-user <args>
33/// api users create-user <args>
34/// ```
35#[must_use]
36pub fn generate_command_tree(spec: &CachedSpec) -> Command {
37    generate_command_tree_with_flags(spec, false)
38}
39
40/// Generates a dynamic clap command tree with optional legacy positional parameter syntax.
41#[must_use]
42pub fn generate_command_tree_with_flags(spec: &CachedSpec, use_positional_args: bool) -> Command {
43    let mut root_command = Command::new(constants::CLI_ROOT_COMMAND)
44        .version(to_static_str(spec.version.clone()))
45        .about(format!("CLI for {} API", spec.name))
46        // Add global flags that should be available to all operations
47        .arg(
48            Arg::new("jq")
49                .long("jq")
50                .global(true)
51                .help("Apply JQ filter to response data (e.g., '.name', '.[] | select(.active)')")
52                .value_name("FILTER")
53                .action(ArgAction::Set),
54        )
55        .arg(
56            Arg::new("format")
57                .long("format")
58                .global(true)
59                .help("Output format for response data")
60                .value_name("FORMAT")
61                .value_parser(["json", "yaml", "table"])
62                .default_value("json")
63                .action(ArgAction::Set),
64        )
65        .arg(
66            Arg::new("server-var")
67                .long("server-var")
68                .global(true)
69                .help("Set server template variable (e.g., --server-var region=us --server-var env=prod)")
70                .value_name("KEY=VALUE")
71                .action(ArgAction::Append),
72        );
73
74    // Group commands by their tag (namespace)
75    let mut command_groups: HashMap<String, Vec<&CachedCommand>> = HashMap::new();
76
77    for command in &spec.commands {
78        // Use the command name (first tag) or "default" as fallback
79        let group_name = if command.name.is_empty() {
80            constants::DEFAULT_GROUP.to_string()
81        } else {
82            command.name.clone()
83        };
84
85        command_groups.entry(group_name).or_default().push(command);
86    }
87
88    // Build subcommands for each group
89    for (group_name, commands) in command_groups {
90        let group_name_static = to_static_str(group_name.to_lowercase());
91        let mut group_command = Command::new(group_name_static)
92            .about(format!("{} operations", capitalize_first(&group_name)));
93
94        // Add operations as subcommands
95        for cached_command in commands {
96            let subcommand_name = if cached_command.operation_id.is_empty() {
97                // Fallback to HTTP method if no operationId
98                cached_command.method.to_lowercase()
99            } else {
100                to_kebab_case(&cached_command.operation_id)
101            };
102
103            let subcommand_name_static = to_static_str(subcommand_name);
104            let mut operation_command = Command::new(subcommand_name_static)
105                .about(cached_command.description.clone().unwrap_or_default());
106
107            // Add parameters as CLI arguments
108            for param in &cached_command.parameters {
109                let arg = create_arg_from_parameter(param, use_positional_args);
110                operation_command = operation_command.arg(arg);
111            }
112
113            // Add request body argument if present
114            if let Some(request_body) = &cached_command.request_body {
115                operation_command = operation_command.arg(
116                    Arg::new("body")
117                        .long("body")
118                        .help("Request body as JSON")
119                        .value_name("JSON")
120                        .required(request_body.required)
121                        .action(ArgAction::Set),
122                );
123            }
124
125            // Add custom header support
126            operation_command = operation_command.arg(
127                Arg::new("header")
128                    .long("header")
129                    .short('H')
130                    .help("Pass custom header(s) to the request. Format: 'Name: Value'. Can be used multiple times.")
131                    .value_name("HEADER")
132                    .action(ArgAction::Append),
133            );
134
135            group_command = group_command.subcommand(operation_command);
136        }
137
138        root_command = root_command.subcommand(group_command);
139    }
140
141    root_command
142}
143
144/// Creates a clap Arg from a `CachedParameter`
145fn create_arg_from_parameter(param: &CachedParameter, use_positional_args: bool) -> Arg {
146    let param_name_static = to_static_str(param.name.clone());
147    let mut arg = Arg::new(param_name_static);
148
149    match param.location.as_str() {
150        "path" => {
151            if use_positional_args {
152                // Legacy mode: path parameters are positional arguments
153                let value_name = to_static_str(param.name.to_uppercase());
154                arg = arg
155                    .help(format!("{} parameter", param.name))
156                    .value_name(value_name)
157                    .required(param.required)
158                    .action(ArgAction::Set);
159            } else {
160                // Default mode: path parameters become flags too
161                let long_name = to_static_str(to_kebab_case(&param.name));
162                let value_name = to_static_str(param.name.to_uppercase());
163                arg = arg
164                    .long(long_name)
165                    .help(format!("Path parameter: {}", param.name))
166                    .value_name(value_name)
167                    .required(param.required)
168                    .action(ArgAction::Set);
169            }
170        }
171        "query" | "header" => {
172            // Query and header parameters are flags
173            let long_name = to_static_str(to_kebab_case(&param.name));
174            let value_name = to_static_str(param.name.to_uppercase());
175            arg = arg
176                .long(long_name)
177                .help(format!(
178                    "{} {} parameter",
179                    capitalize_first(&param.location),
180                    param.name
181                ))
182                .value_name(value_name)
183                .required(param.required)
184                .action(ArgAction::Set);
185        }
186        _ => {
187            // Unknown location, treat as flag
188            let long_name = to_static_str(to_kebab_case(&param.name));
189            let value_name = to_static_str(param.name.to_uppercase());
190            arg = arg
191                .long(long_name)
192                .help(format!("{} parameter", param.name))
193                .value_name(value_name)
194                .required(param.required)
195                .action(ArgAction::Set);
196        }
197    }
198
199    arg
200}
201
202/// Capitalizes the first letter of a string
203fn capitalize_first(s: &str) -> String {
204    let mut chars = s.chars();
205    chars.next().map_or_else(String::new, |first| {
206        first.to_uppercase().chain(chars).collect()
207    })
208}