Skip to main content

aperture_cli/cli/
translate.rs

1//! CLI translation layer: converts clap `ArgMatches` into domain types.
2//!
3//! This module bridges the clap-specific parsing world with the
4//! CLI-agnostic [`OperationCall`] and [`ExecutionContext`] types used
5//! by the execution engine.
6
7use crate::cache::models::{CachedCommand, CachedParameter, CachedSpec};
8use crate::cli::Cli;
9use crate::config::models::GlobalConfig;
10use crate::constants;
11use crate::duration::parse_duration;
12use crate::engine::executor::RetryContext;
13use crate::error::Error;
14use crate::invocation::{ExecutionContext, OperationCall};
15use crate::response_cache::CacheConfig;
16use crate::utils::to_kebab_case;
17use clap::ArgMatches;
18use std::collections::HashMap;
19use std::path::PathBuf;
20use std::time::Duration;
21
22/// Converts clap `ArgMatches` (from a dynamically generated command tree)
23/// into a CLI-agnostic [`OperationCall`].
24///
25/// Walks the subcommand hierarchy to identify the operation, then extracts
26/// path, query, and header parameters, the request body, and any custom
27/// headers.
28///
29/// # Errors
30///
31/// Returns an error if the operation cannot be found in the spec.
32pub fn matches_to_operation_call(
33    spec: &CachedSpec,
34    matches: &ArgMatches,
35) -> Result<OperationCall, Error> {
36    let (operation, current_matches) = find_operation_from_matches(spec, matches)?;
37
38    // Extract parameters by location
39    let mut path_params = HashMap::new();
40    let mut query_params = HashMap::new();
41    let mut header_params = HashMap::new();
42
43    for param in &operation.parameters {
44        extract_param(
45            param,
46            current_matches,
47            &mut path_params,
48            &mut query_params,
49            &mut header_params,
50        );
51    }
52
53    // Extract request body
54    let body = extract_body(operation.request_body.is_some(), current_matches)?;
55
56    // Extract custom headers from --header/-H flags
57    let custom_headers = current_matches
58        .try_get_many::<String>("header")
59        .ok()
60        .flatten()
61        .map(|values| values.cloned().collect())
62        .unwrap_or_default();
63
64    Ok(OperationCall {
65        operation_id: operation.operation_id.clone(),
66        path_params,
67        query_params,
68        header_params,
69        body,
70        custom_headers,
71    })
72}
73
74/// Resolves the matched operation and returns its `operation_id`.
75///
76/// Unlike [`matches_to_operation_call`], this does not attempt to parse or
77/// validate request body content, making it suitable for purely metadata-driven
78/// flows like `--show-examples`.
79///
80/// # Errors
81///
82/// Returns an error if no matching operation can be resolved from the
83/// subcommand hierarchy.
84pub fn matches_to_operation_id(spec: &CachedSpec, matches: &ArgMatches) -> Result<String, Error> {
85    let (operation, _) = find_operation_from_matches(spec, matches)?;
86    Ok(operation.operation_id.clone())
87}
88
89/// Walks the clap hierarchy and resolves the target operation plus deepest matches.
90fn find_operation_from_matches<'a>(
91    spec: &'a CachedSpec,
92    matches: &'a ArgMatches,
93) -> Result<(&'a CachedCommand, &'a ArgMatches), Error> {
94    let mut current_matches = matches;
95    let mut subcommand_path = Vec::new();
96
97    while let Some((name, sub_matches)) = current_matches.subcommand() {
98        subcommand_path.push(name.to_string());
99        current_matches = sub_matches;
100    }
101
102    let operation_name = subcommand_path.last().ok_or_else(|| {
103        let name = "unknown".to_string();
104        let suggestions = crate::suggestions::suggest_similar_operations(spec, &name);
105        Error::operation_not_found_with_suggestions(name, &suggestions)
106    })?;
107
108    // Dynamic tree shape is: <group> <operation>
109    let group_name = subcommand_path
110        .len()
111        .checked_sub(2)
112        .and_then(|idx| subcommand_path.get(idx));
113
114    let operation = spec
115        .commands
116        .iter()
117        .find(|cmd| matches_effective_command_path(cmd, group_name, operation_name))
118        // Backward-compatible fallback: resolve by operation name only.
119        // This supports existing tests and any callers that manually construct
120        // `ArgMatches` without a generator-consistent group segment.
121        .or_else(|| {
122            spec.commands
123                .iter()
124                .find(|cmd| matches_effective_command_path(cmd, None, operation_name))
125        })
126        .ok_or_else(|| {
127            let suggestions = crate::suggestions::suggest_similar_operations(spec, operation_name);
128            Error::operation_not_found_with_suggestions(operation_name.clone(), &suggestions)
129        })?;
130
131    Ok((operation, current_matches))
132}
133
134/// Returns true when a command matches a parsed group/operation subcommand path.
135fn matches_effective_command_path(
136    command: &CachedCommand,
137    group_name: Option<&String>,
138    operation_name: &str,
139) -> bool {
140    let operation_matches = effective_operation_name(command) == operation_name
141        || command
142            .aliases
143            .iter()
144            .any(|alias| to_kebab_case(alias) == operation_name);
145
146    if !operation_matches {
147        return false;
148    }
149
150    group_name.is_none_or(|group| effective_group_name(command) == *group)
151}
152
153/// Computes the effective group name used by the command tree generator.
154fn effective_group_name(command: &CachedCommand) -> String {
155    command.display_group.as_ref().map_or_else(
156        || {
157            if command.name.is_empty() {
158                constants::DEFAULT_GROUP.to_string()
159            } else {
160                to_kebab_case(&command.name)
161            }
162        },
163        |group| to_kebab_case(group),
164    )
165}
166
167/// Computes the effective operation name used by the command tree generator.
168fn effective_operation_name(command: &CachedCommand) -> String {
169    command.display_name.as_ref().map_or_else(
170        || {
171            if command.operation_id.is_empty() {
172                command.method.to_lowercase()
173            } else {
174                to_kebab_case(&command.operation_id)
175            }
176        },
177        |name| to_kebab_case(name),
178    )
179}
180
181/// Extracts a single parameter value from matches and inserts it into the
182/// appropriate map based on its `OpenAPI` location.
183fn extract_param(
184    param: &CachedParameter,
185    matches: &ArgMatches,
186    path_params: &mut HashMap<String, String>,
187    query_params: &mut HashMap<String, String>,
188    header_params: &mut HashMap<String, String>,
189) {
190    let target = match param.location.as_str() {
191        "path" => path_params,
192        "query" => query_params,
193        "header" => header_params,
194        _ => return,
195    };
196
197    let is_boolean = param.schema_type.as_ref().is_some_and(|t| t == "boolean");
198
199    if !is_boolean {
200        // Non-boolean: extract string value (must be checked first to avoid
201        // get_flag panic on non-boolean args)
202        let Some(value) = matches.try_get_one::<String>(&param.name).ok().flatten() else {
203            return;
204        };
205        target.insert(param.name.clone(), value.clone());
206        return;
207    }
208
209    // Boolean parameters are flags (SetTrue action in clap)
210    // Path booleans always need a value (true/false); query/header only when true
211    let flag_set = matches.get_flag(&param.name);
212    if flag_set || param.location == "path" {
213        target.insert(param.name.clone(), flag_set.to_string());
214    }
215}
216
217/// Extracts the request body from matches.
218fn extract_body(has_request_body: bool, matches: &ArgMatches) -> Result<Option<String>, Error> {
219    if !has_request_body {
220        return Ok(None);
221    }
222
223    matches
224        .get_one::<String>("body")
225        .map(|body_value| {
226            // Validate JSON
227            let _: serde_json::Value = serde_json::from_str(body_value)
228                .map_err(|e| Error::invalid_json_body(e.to_string()))?;
229            Ok(body_value.clone())
230        })
231        .transpose()
232}
233
234/// Extracts server variable arguments from CLI matches.
235#[must_use]
236pub fn extract_server_var_args(matches: &ArgMatches) -> Vec<String> {
237    matches
238        .try_get_many::<String>("server-var")
239        .ok()
240        .flatten()
241        .map(|values| values.cloned().collect())
242        .unwrap_or_default()
243}
244
245/// Returns true if the `--show-examples` flag is set in the operation's matches.
246#[must_use]
247pub fn has_show_examples_flag(matches: &ArgMatches) -> bool {
248    // Walk to the deepest subcommand
249    let mut current = matches;
250    while let Some((_name, sub)) = current.subcommand() {
251        current = sub;
252    }
253
254    current.try_contains_id("show-examples").unwrap_or(false) && current.get_flag("show-examples")
255}
256
257/// Builds an [`ExecutionContext`] from CLI flags and optional global config.
258///
259/// # Errors
260///
261/// Returns an error if duration parsing fails for retry delay values.
262#[allow(clippy::cast_possible_truncation)]
263pub fn cli_to_execution_context(
264    cli: &Cli,
265    global_config: Option<GlobalConfig>,
266) -> Result<ExecutionContext, Error> {
267    let config_dir = if let Ok(dir) = std::env::var(crate::constants::ENV_APERTURE_CONFIG_DIR) {
268        PathBuf::from(dir)
269    } else {
270        crate::config::manager::get_config_dir()?
271    };
272
273    // Build cache config from CLI flags
274    let cache_config = if cli.no_cache {
275        None
276    } else {
277        Some(CacheConfig {
278            cache_dir: config_dir
279                .join(crate::constants::DIR_CACHE)
280                .join(crate::constants::DIR_RESPONSES),
281            default_ttl: Duration::from_secs(cli.cache_ttl.unwrap_or(300)),
282            max_entries: 1000,
283            enabled: cli.cache || cli.cache_ttl.is_some(),
284            allow_authenticated: false,
285        })
286    };
287
288    // Build retry context
289    let retry_context = build_retry_context(cli, global_config.as_ref())?;
290
291    Ok(ExecutionContext {
292        dry_run: cli.dry_run,
293        idempotency_key: cli.idempotency_key.clone(),
294        cache_config,
295        retry_context,
296        base_url: None, // Resolved by BaseUrlResolver
297        global_config,
298        server_var_args: Vec::new(), // Populated from dynamic matches in the caller
299    })
300}
301
302/// Builds a [`RetryContext`] from CLI flags and global configuration.
303///
304/// CLI flags take precedence over global config defaults.
305#[allow(clippy::cast_possible_truncation)]
306fn build_retry_context(
307    cli: &Cli,
308    global_config: Option<&GlobalConfig>,
309) -> Result<Option<RetryContext>, Error> {
310    let defaults = global_config.map(|c| &c.retry_defaults);
311
312    let max_attempts = cli
313        .retry
314        .or_else(|| defaults.map(|d| d.max_attempts))
315        .unwrap_or(0);
316
317    if max_attempts == 0 {
318        return Ok(None);
319    }
320
321    // Truncation is safe: delay values in practice are well under u64::MAX milliseconds
322    let initial_delay_ms = if let Some(ref delay_str) = cli.retry_delay {
323        parse_duration(delay_str)?.as_millis() as u64
324    } else {
325        defaults.map_or(500, |d| d.initial_delay_ms)
326    };
327
328    let max_delay_ms = if let Some(ref delay_str) = cli.retry_max_delay {
329        parse_duration(delay_str)?.as_millis() as u64
330    } else {
331        defaults.map_or(30_000, |d| d.max_delay_ms)
332    };
333
334    let has_idempotency_key = cli.idempotency_key.is_some();
335
336    Ok(Some(RetryContext {
337        max_attempts,
338        initial_delay_ms,
339        max_delay_ms,
340        force_retry: cli.force_retry,
341        method: None, // Determined by executor at execution time
342        has_idempotency_key,
343    }))
344}