aperture_cli/engine/
generator.rs

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