aperture_cli/engine/
generator.rs

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