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///
39/// # Returns
40/// A clap Command configured with all operations from the spec
41///
42/// # Example
43/// For an API with a "users" tag containing "getUser" and "createUser" operations:
44/// ```text
45/// api users get-user <args>
46/// api users create-user <args>
47/// ```
48#[must_use]
49pub fn generate_command_tree(spec: &CachedSpec) -> Command {
50    let mut root_command = Command::new("api")
51        .version(to_static_str(spec.version.clone()))
52        .about(format!("CLI for {} API", spec.name));
53
54    // Group commands by their tag (namespace)
55    let mut command_groups: HashMap<String, Vec<&CachedCommand>> = HashMap::new();
56
57    for command in &spec.commands {
58        // Use the command name (first tag) or "default" as fallback
59        let group_name = if command.name.is_empty() {
60            "default".to_string()
61        } else {
62            command.name.clone()
63        };
64
65        command_groups.entry(group_name).or_default().push(command);
66    }
67
68    // Build subcommands for each group
69    for (group_name, commands) in command_groups {
70        let group_name_static = to_static_str(group_name.clone());
71        let mut group_command = Command::new(group_name_static)
72            .about(format!("{} operations", capitalize_first(&group_name)));
73
74        // Add operations as subcommands
75        for cached_command in commands {
76            let subcommand_name = if cached_command.operation_id.is_empty() {
77                // Fallback to HTTP method if no operationId
78                cached_command.method.to_lowercase()
79            } else {
80                to_kebab_case(&cached_command.operation_id)
81            };
82
83            let subcommand_name_static = to_static_str(subcommand_name);
84            let mut operation_command = Command::new(subcommand_name_static)
85                .about(cached_command.description.clone().unwrap_or_default());
86
87            // Add parameters as CLI arguments
88            for param in &cached_command.parameters {
89                let arg = create_arg_from_parameter(param);
90                operation_command = operation_command.arg(arg);
91            }
92
93            // Add request body argument if present
94            if let Some(request_body) = &cached_command.request_body {
95                operation_command = operation_command.arg(
96                    Arg::new("body")
97                        .long("body")
98                        .help("Request body as JSON")
99                        .value_name("JSON")
100                        .required(request_body.required)
101                        .action(ArgAction::Set),
102                );
103            }
104
105            // Add custom header support
106            operation_command = operation_command.arg(
107                Arg::new("header")
108                    .long("header")
109                    .short('H')
110                    .help("Pass custom header(s) to the request. Format: 'Name: Value'. Can be used multiple times.")
111                    .value_name("HEADER")
112                    .action(ArgAction::Append),
113            );
114
115            group_command = group_command.subcommand(operation_command);
116        }
117
118        root_command = root_command.subcommand(group_command);
119    }
120
121    root_command
122}
123
124/// Creates a clap Arg from a `CachedParameter`
125fn create_arg_from_parameter(param: &CachedParameter) -> Arg {
126    let param_name_static = to_static_str(param.name.clone());
127    let mut arg = Arg::new(param_name_static);
128
129    match param.location.as_str() {
130        "path" => {
131            // Path parameters are positional arguments
132            let value_name = to_static_str(param.name.to_uppercase());
133            arg = arg
134                .help(format!("{} parameter", param.name))
135                .value_name(value_name)
136                .required(param.required)
137                .action(ArgAction::Set);
138        }
139        "query" | "header" => {
140            // Query and header parameters are flags
141            let long_name = to_static_str(param.name.clone());
142            let value_name = to_static_str(param.name.to_uppercase());
143            arg = arg
144                .long(long_name)
145                .help(format!(
146                    "{} {} parameter",
147                    capitalize_first(&param.location),
148                    param.name
149                ))
150                .value_name(value_name)
151                .required(param.required)
152                .action(ArgAction::Set);
153        }
154        _ => {
155            // Unknown location, treat as flag
156            let long_name = to_static_str(param.name.clone());
157            let value_name = to_static_str(param.name.to_uppercase());
158            arg = arg
159                .long(long_name)
160                .help(format!("{} parameter", param.name))
161                .value_name(value_name)
162                .required(param.required)
163                .action(ArgAction::Set);
164        }
165    }
166
167    arg
168}
169
170/// Capitalizes the first letter of a string
171fn capitalize_first(s: &str) -> String {
172    let mut chars = s.chars();
173    chars.next().map_or_else(String::new, |first| {
174        first.to_uppercase().chain(chars).collect()
175    })
176}