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