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;
6use std::fmt::Write;
7
8/// Converts a String to a 'static str by leaking it
9///
10/// This is necessary for clap's API which requires 'static strings.
11/// In a CLI context, this is acceptable as the program runs once and exits.
12fn to_static_str(s: String) -> &'static str {
13    Box::leak(s.into_boxed_str())
14}
15
16/// Generates a dynamic clap command tree from a cached `OpenAPI` specification.
17///
18/// This function creates a hierarchical command structure based on the `OpenAPI` spec:
19/// - Root command: "api" (`CLI_ROOT_COMMAND`)
20/// - Tag groups: Operations are grouped by their tags (e.g., "users", "posts")
21/// - Operations: Individual API operations as subcommands under their tag group
22///
23/// # Arguments
24/// * `spec` - The cached `OpenAPI` specification
25/// * `experimental_flags` - Whether to use flag-based syntax for all parameters
26///
27/// # Returns
28/// A clap Command configured with all operations from the spec
29///
30/// # Example
31/// For an API with a "users" tag containing "getUser" and "createUser" operations:
32/// ```text
33/// api users get-user <args>
34/// api users create-user <args>
35/// ```
36#[must_use]
37pub fn generate_command_tree(spec: &CachedSpec) -> Command {
38    generate_command_tree_with_flags(spec, false)
39}
40
41/// Generates a dynamic clap command tree with optional legacy positional parameter syntax.
42#[must_use]
43pub fn generate_command_tree_with_flags(spec: &CachedSpec, use_positional_args: bool) -> Command {
44    let mut root_command = Command::new(constants::CLI_ROOT_COMMAND)
45        .version(to_static_str(spec.version.clone()))
46        .about(format!("CLI for {} API", spec.name))
47        // Add global flags that should be available to all operations
48        .arg(
49            Arg::new("jq")
50                .long("jq")
51                .global(true)
52                .help("Apply JQ filter to response data (e.g., '.name', '.[] | select(.active)')")
53                .value_name("FILTER")
54                .action(ArgAction::Set),
55        )
56        .arg(
57            Arg::new("format")
58                .long("format")
59                .global(true)
60                .help("Output format for response data")
61                .value_name("FORMAT")
62                .value_parser(["json", "yaml", "table"])
63                .default_value("json")
64                .action(ArgAction::Set),
65        )
66        .arg(
67            Arg::new("server-var")
68                .long("server-var")
69                .global(true)
70                .help("Set server template variable (e.g., --server-var region=us --server-var env=prod)")
71                .value_name("KEY=VALUE")
72                .action(ArgAction::Append),
73        );
74
75    // Group commands by their tag (namespace)
76    let mut command_groups: HashMap<String, Vec<&CachedCommand>> = HashMap::new();
77
78    for command in &spec.commands {
79        // Use the command name (first tag) or "default" as fallback
80        let group_name = if command.name.is_empty() {
81            constants::DEFAULT_GROUP.to_string()
82        } else {
83            command.name.clone()
84        };
85
86        command_groups.entry(group_name).or_default().push(command);
87    }
88
89    // Build subcommands for each group
90    for (group_name, commands) in command_groups {
91        let group_name_kebab = to_kebab_case(&group_name);
92        let group_name_static = to_static_str(group_name_kebab);
93        let mut group_command = Command::new(group_name_static)
94            .about(format!("{} operations", capitalize_first(&group_name)));
95
96        // Add operations as subcommands
97        for cached_command in commands {
98            let subcommand_name = if cached_command.operation_id.is_empty() {
99                // Fallback to HTTP method if no operationId
100                cached_command.method.to_lowercase()
101            } else {
102                to_kebab_case(&cached_command.operation_id)
103            };
104
105            let subcommand_name_static = to_static_str(subcommand_name);
106
107            // Build help text with examples
108            let mut help_text = cached_command.description.clone().unwrap_or_default();
109            if !cached_command.examples.is_empty() {
110                help_text.push_str("\n\nExamples:");
111                for example in &cached_command.examples {
112                    write!(
113                        &mut help_text,
114                        "\n  {}\n    {}",
115                        example.description, example.command_line
116                    )
117                    .unwrap();
118                    if let Some(ref explanation) = example.explanation {
119                        write!(&mut help_text, "\n    ({explanation})").unwrap();
120                    }
121                }
122            }
123
124            let mut operation_command = Command::new(subcommand_name_static).about(help_text);
125
126            // Add parameters as CLI arguments
127            for param in &cached_command.parameters {
128                let arg = create_arg_from_parameter(param, use_positional_args);
129                operation_command = operation_command.arg(arg);
130            }
131
132            // Add request body argument if present
133            if let Some(request_body) = &cached_command.request_body {
134                operation_command = operation_command.arg(
135                    Arg::new("body")
136                        .long("body")
137                        .help("Request body as JSON")
138                        .value_name("JSON")
139                        .required(request_body.required)
140                        .action(ArgAction::Set),
141                );
142            }
143
144            // Add custom header support
145            operation_command = operation_command.arg(
146                Arg::new("header")
147                    .long("header")
148                    .short('H')
149                    .help("Pass custom header(s) to the request. Format: 'Name: Value'. Can be used multiple times.")
150                    .value_name("HEADER")
151                    .action(ArgAction::Append),
152            );
153
154            // Add examples flag for showing extended examples
155            operation_command = operation_command.arg(
156                Arg::new("show-examples")
157                    .long("show-examples")
158                    .help("Show extended usage examples for this command")
159                    .action(ArgAction::SetTrue),
160            );
161
162            group_command = group_command.subcommand(operation_command);
163        }
164
165        root_command = root_command.subcommand(group_command);
166    }
167
168    root_command
169}
170
171/// Creates a clap Arg from a `CachedParameter`
172///
173/// # Boolean Parameter Handling
174///
175/// Boolean parameters use `ArgAction::SetTrue`, treating them as flags:
176///
177/// **Path Parameters:**
178/// - Always optional regardless of `OpenAPI` `required` field
179/// - Flag presence = true (substitutes "true" in path), absence = false (substitutes "false")
180/// - Example: `/items/{active}` with `--active` → `/items/true`, without → `/items/false`
181///
182/// **Query/Header Parameters:**
183/// - **Optional booleans** (`required: false`): Flag presence = true, absence = false
184/// - **Required booleans** (`required: true`): Flag MUST be provided, presence = true
185/// - Example: `--verbose` (optional) omitted means `verbose=false`
186///
187/// This differs from non-boolean parameters which require explicit values (e.g., `--id 123`).
188fn create_arg_from_parameter(param: &CachedParameter, use_positional_args: bool) -> Arg {
189    let param_name_static = to_static_str(param.name.clone());
190    let mut arg = Arg::new(param_name_static);
191
192    // Check if this is a boolean parameter (type: "boolean" in OpenAPI schema)
193    let is_boolean = param.schema_type.as_ref().is_some_and(|t| t == "boolean");
194
195    match param.location.as_str() {
196        "path" => {
197            if use_positional_args {
198                // Legacy mode: path parameters are positional arguments
199                if is_boolean {
200                    // Boolean path parameters must use SetTrue even in positional mode
201                    // because executor always reads them via get_flag()
202                    // They remain as flags, not positional args, to avoid clap panic
203                    let long_name = to_static_str(to_kebab_case(&param.name));
204                    arg = arg
205                        .long(long_name)
206                        .help(format!("Path parameter: {}", param.name))
207                        .required(false)
208                        .action(ArgAction::SetTrue);
209                } else {
210                    let value_name = to_static_str(param.name.to_uppercase());
211                    arg = arg
212                        .help(format!("{} parameter", param.name))
213                        .value_name(value_name)
214                        .required(param.required)
215                        .action(ArgAction::Set);
216                }
217            } else {
218                // Default mode: path parameters become flags too
219                let long_name = to_static_str(to_kebab_case(&param.name));
220
221                if is_boolean {
222                    // Boolean path parameters are treated as flags
223                    // Always optional: flag presence = true, absence = false (substituted in path)
224                    // This provides consistent UX regardless of OpenAPI required field
225                    arg = arg
226                        .long(long_name)
227                        .help(format!("Path parameter: {}", param.name))
228                        .required(false)
229                        .action(ArgAction::SetTrue);
230                } else {
231                    let value_name = to_static_str(param.name.to_uppercase());
232                    arg = arg
233                        .long(long_name)
234                        .help(format!("Path parameter: {}", param.name))
235                        .value_name(value_name)
236                        .required(param.required)
237                        .action(ArgAction::Set);
238                }
239            }
240        }
241        "query" | "header" => {
242            // Query and header parameters are flags
243            let long_name = to_static_str(to_kebab_case(&param.name));
244
245            if is_boolean {
246                // Boolean parameters are proper flags
247                // Required booleans must be provided; optional booleans default to false when absent
248                arg = arg
249                    .long(long_name)
250                    .help(format!(
251                        "{} {} parameter",
252                        capitalize_first(&param.location),
253                        param.name
254                    ))
255                    .required(param.required)
256                    .action(ArgAction::SetTrue);
257            } else {
258                let value_name = to_static_str(param.name.to_uppercase());
259                arg = arg
260                    .long(long_name)
261                    .help(format!(
262                        "{} {} parameter",
263                        capitalize_first(&param.location),
264                        param.name
265                    ))
266                    .value_name(value_name)
267                    .required(param.required)
268                    .action(ArgAction::Set);
269            }
270        }
271        _ => {
272            // Unknown location, treat as flag
273            let long_name = to_static_str(to_kebab_case(&param.name));
274
275            if is_boolean {
276                // Boolean parameters are proper flags
277                // Required booleans must be provided; optional booleans default to false when absent
278                arg = arg
279                    .long(long_name)
280                    .help(format!("{} parameter", param.name))
281                    .required(param.required)
282                    .action(ArgAction::SetTrue);
283            } else {
284                let value_name = to_static_str(param.name.to_uppercase());
285                arg = arg
286                    .long(long_name)
287                    .help(format!("{} parameter", param.name))
288                    .value_name(value_name)
289                    .required(param.required)
290                    .action(ArgAction::Set);
291            }
292        }
293    }
294
295    arg
296}
297
298/// Capitalizes the first letter of a string
299fn capitalize_first(s: &str) -> String {
300    let mut chars = s.chars();
301    chars.next().map_or_else(String::new, |first| {
302        first.to_uppercase().chain(chars).collect()
303    })
304}