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 = add_body_args(operation_command, request_body.required);
154            }
155
156            // Add custom header support
157            operation_command = operation_command.arg(
158                Arg::new("header")
159                    .long("header")
160                    .short('H')
161                    .help("Pass custom header(s) to the request. Format: 'Name: Value'. Can be used multiple times.")
162                    .value_name("HEADER")
163                    .action(ArgAction::Append),
164            );
165
166            // Add examples flag for showing extended examples
167            operation_command = operation_command.arg(
168                Arg::new("show-examples")
169                    .long("show-examples")
170                    .help("Show extended usage examples for this command")
171                    .action(ArgAction::SetTrue),
172            );
173
174            // Apply command mapping: aliases
175            if !cached_command.aliases.is_empty() {
176                let alias_strs: Vec<&'static str> = cached_command
177                    .aliases
178                    .iter()
179                    .map(|a| to_static_str(to_kebab_case(a)))
180                    .collect();
181                operation_command = operation_command.visible_aliases(alias_strs);
182            }
183
184            // Apply command mapping: hidden
185            if cached_command.hidden {
186                operation_command = operation_command.hide(true);
187            }
188
189            group_command = group_command.subcommand(operation_command);
190        }
191
192        root_command = root_command.subcommand(group_command);
193    }
194
195    root_command
196}
197
198/// Attaches `--body` and `--body-file` args to a command that accepts a request body.
199///
200/// When the spec marks the body as required, `--body-file` is an equally valid way to
201/// satisfy that requirement. `required_unless_present` lets clap enforce "at least one
202/// of the two" without rejecting `--body-file` on its own.
203fn add_body_args(cmd: Command, required: bool) -> Command {
204    let body_arg = Arg::new("body")
205        .long("body")
206        .help("Request body as JSON")
207        .value_name("JSON")
208        .conflicts_with("body-file")
209        .action(ArgAction::Set);
210
211    // required_unless_present: --body is required UNLESS --body-file is present.
212    // Without this, clap enforces required(true) on --body independently of the
213    // conflicts_with guard, causing --body-file-only invocations to be rejected.
214    let body_arg = if required {
215        body_arg.required_unless_present("body-file")
216    } else {
217        body_arg
218    };
219
220    cmd.arg(body_arg).arg(
221        Arg::new("body-file")
222            .long("body-file")
223            .help("Read request body from a file path, or - for stdin")
224            .value_name("PATH")
225            .conflicts_with("body")
226            .action(ArgAction::Set),
227    )
228}
229
230/// Returns the effective group name for a command, using `display_group` override if present.
231fn effective_group_name(command: &CachedCommand) -> String {
232    command.display_group.as_ref().map_or_else(
233        || {
234            if command.name.is_empty() {
235                constants::DEFAULT_GROUP.to_string()
236            } else {
237                command.name.clone()
238            }
239        },
240        Clone::clone,
241    )
242}
243
244/// Returns the effective subcommand name for a command, using `display_name` override if present.
245fn effective_subcommand_name(command: &CachedCommand) -> String {
246    command.display_name.as_ref().map_or_else(
247        || {
248            if command.operation_id.is_empty() {
249                command.method.to_lowercase()
250            } else {
251                to_kebab_case(&command.operation_id)
252            }
253        },
254        |n| to_kebab_case(n),
255    )
256}
257
258/// Creates a clap Arg from a `CachedParameter`
259///
260/// # Boolean Parameter Handling
261///
262/// Boolean parameters use `ArgAction::SetTrue`, treating them as flags:
263///
264/// **Path Parameters:**
265/// - Always optional regardless of `OpenAPI` `required` field
266/// - Flag presence = true (substitutes "true" in path), absence = false (substitutes "false")
267/// - Example: `/items/{active}` with `--active` → `/items/true`, without → `/items/false`
268///
269/// **Query/Header Parameters:**
270/// - **Optional booleans** (`required: false`): Flag presence = true, absence = false
271/// - **Required booleans** (`required: true`): Flag MUST be provided, presence = true
272/// - Example: `--verbose` (optional) omitted means `verbose=false`
273///
274/// This differs from non-boolean parameters which require explicit values (e.g., `--id 123`).
275fn create_arg_from_parameter(param: &CachedParameter, use_positional_args: bool) -> Arg {
276    let param_name_static = to_static_str(param.name.clone());
277    let mut arg = Arg::new(param_name_static);
278
279    // Check if this is a boolean parameter (type: "boolean" in OpenAPI schema)
280    let is_boolean = param.schema_type.as_ref().is_some_and(|t| t == "boolean");
281
282    match param.location.as_str() {
283        "path" => {
284            match (use_positional_args, is_boolean) {
285                (true, true) => {
286                    // Boolean path parameters must use SetTrue even in positional mode
287                    // because executor always reads them via get_flag()
288                    // They remain as flags, not positional args, to avoid clap panic
289                    let long_name = to_static_str(to_kebab_case(&param.name));
290                    arg = arg
291                        .long(long_name)
292                        .help(format!("Path parameter: {}", param.name))
293                        .required(false)
294                        .action(ArgAction::SetTrue);
295                }
296                (true, false) => {
297                    // Legacy mode: path parameters are positional arguments (non-boolean)
298                    let value_name = to_static_str(param.name.to_uppercase());
299                    arg = arg
300                        .help(format!("{} parameter", param.name))
301                        .value_name(value_name)
302                        .required(param.required)
303                        .action(ArgAction::Set);
304                }
305                (false, true) => {
306                    // Default mode: Boolean path parameters are treated as flags
307                    // Always optional: flag presence = true, absence = false (substituted in path)
308                    // This provides consistent UX regardless of OpenAPI required field
309                    let long_name = to_static_str(to_kebab_case(&param.name));
310                    arg = arg
311                        .long(long_name)
312                        .help(format!("Path parameter: {}", param.name))
313                        .required(false)
314                        .action(ArgAction::SetTrue);
315                }
316                (false, false) => {
317                    // Default mode: non-boolean path parameters
318                    let long_name = to_static_str(to_kebab_case(&param.name));
319                    let value_name = to_static_str(param.name.to_uppercase());
320                    arg = arg
321                        .long(long_name)
322                        .help(format!("Path parameter: {}", param.name))
323                        .value_name(value_name)
324                        .required(param.required)
325                        .action(ArgAction::Set);
326                }
327            }
328        }
329        "query" | "header" => {
330            // Query and header parameters are flags
331            let long_name = to_static_str(to_kebab_case(&param.name));
332
333            if is_boolean {
334                // Boolean parameters are proper flags
335                // Required booleans must be provided; optional booleans default to false when absent
336                arg = arg
337                    .long(long_name)
338                    .help(format!(
339                        "{} {} parameter",
340                        capitalize_first(&param.location),
341                        param.name
342                    ))
343                    .required(param.required)
344                    .action(ArgAction::SetTrue);
345                return arg;
346            }
347
348            let value_name = to_static_str(param.name.to_uppercase());
349            arg = arg
350                .long(long_name)
351                .help(format!(
352                    "{} {} parameter",
353                    capitalize_first(&param.location),
354                    param.name
355                ))
356                .value_name(value_name)
357                .required(param.required)
358                .action(ArgAction::Set);
359        }
360        _ => {
361            // Unknown location, treat as flag
362            let long_name = to_static_str(to_kebab_case(&param.name));
363
364            if is_boolean {
365                // Boolean parameters are proper flags
366                // Required booleans must be provided; optional booleans default to false when absent
367                arg = arg
368                    .long(long_name)
369                    .help(format!("{} parameter", param.name))
370                    .required(param.required)
371                    .action(ArgAction::SetTrue);
372                return arg;
373            }
374
375            let value_name = to_static_str(param.name.to_uppercase());
376            arg = arg
377                .long(long_name)
378                .help(format!("{} parameter", param.name))
379                .value_name(value_name)
380                .required(param.required)
381                .action(ArgAction::Set);
382        }
383    }
384
385    arg
386}
387
388/// Capitalizes the first letter of a string
389fn capitalize_first(s: &str) -> String {
390    let mut chars = s.chars();
391    chars.next().map_or_else(String::new, |first| {
392        first.to_uppercase().chain(chars).collect()
393    })
394}