Skip to main content

aperture_cli/engine/
generator.rs

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