Skip to main content

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