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(¶m.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(¶m.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(¶m.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(¶m.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(¶m.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(¶m.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(¶m.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}