Skip to main content

aperture_cli/cli/commands/
api.rs

1//! Handlers for `aperture api`, `aperture exec`, and batch operations.
2
3use crate::batch::{BatchConfig, BatchProcessor};
4use crate::cache::models::CachedSpec;
5use crate::cli::Cli;
6use crate::config::manager::{get_config_dir, ConfigManager};
7use crate::config::models::GlobalConfig;
8use crate::constants;
9use crate::engine::{executor, generator, loader};
10use crate::error::Error;
11use crate::fs::OsFileSystem;
12use crate::output::Output;
13use crate::shortcuts::{ResolutionResult, ShortcutResolver};
14use std::path::PathBuf;
15
16/// Resolves the output format from dynamic matches vs CLI global flag.
17fn resolve_output_format(
18    matches: &clap::ArgMatches,
19    cli_format: &crate::cli::OutputFormat,
20) -> crate::cli::OutputFormat {
21    use clap::parser::ValueSource;
22
23    let Some(format_str) = matches.get_one::<String>("format") else {
24        return cli_format.clone();
25    };
26
27    // The dynamic command tree always sets a default of "json".
28    // If clap reports this value came from a default (not user input),
29    // preserve the top-level CLI format parsed by `Cli`.
30    if matches.value_source("format") == Some(ValueSource::DefaultValue) {
31        return cli_format.clone();
32    }
33
34    match format_str.as_str() {
35        "json" => crate::cli::OutputFormat::Json,
36        "yaml" => crate::cli::OutputFormat::Yaml,
37        "table" => crate::cli::OutputFormat::Table,
38        _ => cli_format.clone(),
39    }
40}
41
42#[allow(clippy::too_many_lines)]
43pub async fn execute_api_command(context: &str, args: Vec<String>, cli: &Cli) -> Result<(), Error> {
44    let config_dir = if let Ok(dir) = std::env::var(constants::ENV_APERTURE_CONFIG_DIR) {
45        PathBuf::from(dir)
46    } else {
47        get_config_dir()?
48    };
49    let cache_dir = config_dir.join(constants::DIR_CACHE);
50
51    let manager = ConfigManager::with_fs(OsFileSystem, config_dir.clone());
52    let global_config = manager.load_global_config().ok();
53
54    let spec = loader::load_cached_spec(&cache_dir, context).map_err(|e| match e {
55        Error::Io(_) => Error::spec_not_found(context),
56        _ => e,
57    })?;
58
59    // Handle --describe-json flag
60    if cli.describe_json {
61        let specs_dir = config_dir.join(constants::DIR_SPECS);
62        let spec_path = specs_dir.join(format!("{context}.yaml"));
63        // ast-grep-ignore: no-nested-if
64        if !spec_path.exists() {
65            return Err(Error::spec_not_found(context));
66        }
67        let spec_content = std::fs::read_to_string(&spec_path)?;
68        let openapi_spec = crate::spec::parse_openapi(&spec_content)
69            .map_err(|e| Error::invalid_config(format!("Failed to parse OpenAPI spec: {e}")))?;
70        let manifest = crate::agent::generate_capability_manifest_from_openapi(
71            context,
72            &openapi_spec,
73            &spec,
74            global_config.as_ref(),
75        )?;
76        let output = match &cli.jq {
77            Some(jq_filter) => executor::apply_jq_filter(&manifest, jq_filter)?,
78            None => manifest,
79        };
80        // ast-grep-ignore: no-println
81        println!("{output}");
82        return Ok(());
83    }
84
85    // Handle --batch-file flag
86    if let Some(batch_file_path) = &cli.batch_file {
87        return execute_batch_operations(
88            context,
89            batch_file_path,
90            &spec,
91            global_config.as_ref(),
92            cli,
93        )
94        .await;
95    }
96
97    // Generate the dynamic command tree and parse arguments
98    let command = generator::generate_command_tree_with_flags(&spec, cli.positional_args);
99    let matches = command
100        .try_get_matches_from(std::iter::once(constants::CLI_ROOT_COMMAND.to_string()).chain(args))
101        .map_err(|e| Error::invalid_command(context, e.to_string()))?;
102
103    // Check --show-examples flag
104    if crate::cli::translate::has_show_examples_flag(&matches) {
105        let operation_id = crate::cli::translate::matches_to_operation_id(&spec, &matches)?;
106        let operation = spec
107            .commands
108            .iter()
109            .find(|cmd| cmd.operation_id == operation_id)
110            .ok_or_else(|| Error::spec_not_found(context))?;
111        crate::cli::render::render_examples(operation);
112        return Ok(());
113    }
114
115    let jq_filter = matches
116        .get_one::<String>("jq")
117        .map(String::as_str)
118        .or(cli.jq.as_deref());
119    let output_format = resolve_output_format(&matches, &cli.format);
120
121    // Translate ArgMatches → domain types
122    let call = crate::cli::translate::matches_to_operation_call(&spec, &matches)?;
123    let mut ctx = crate::cli::translate::cli_to_execution_context(cli, global_config)?;
124    ctx.server_var_args = crate::cli::translate::extract_server_var_args(&matches);
125
126    // Execute
127    let result = executor::execute(&spec, call, ctx).await.map_err(|e| {
128        let Error::Network(req_err) = &e else {
129            return e;
130        };
131        if req_err.is_connect() {
132            return e.with_context("Failed to connect to API server");
133        }
134        if req_err.is_timeout() {
135            return e.with_context("Request timed out");
136        }
137        e
138    })?;
139
140    crate::cli::render::render_result(&result, &output_format, jq_filter)?;
141    Ok(())
142}
143
144/// Executes batch operations from a batch file
145#[allow(clippy::too_many_lines)]
146pub async fn execute_batch_operations(
147    _context: &str,
148    batch_file_path: &str,
149    spec: &CachedSpec,
150    global_config: Option<&GlobalConfig>,
151    cli: &Cli,
152) -> Result<(), Error> {
153    let batch_file =
154        BatchProcessor::parse_batch_file(std::path::Path::new(batch_file_path)).await?;
155    let batch_config = BatchConfig {
156        max_concurrency: cli.batch_concurrency,
157        rate_limit: cli.batch_rate_limit,
158        continue_on_error: true,
159        show_progress: !cli.quiet && !cli.json_errors,
160        suppress_output: cli.json_errors,
161    };
162    let processor = BatchProcessor::new(batch_config);
163    let result = processor
164        .execute_batch(
165            spec,
166            batch_file,
167            global_config,
168            None,
169            cli.dry_run,
170            &cli.format,
171            None,
172        )
173        .await?;
174
175    let output = Output::new(cli.quiet, cli.json_errors);
176
177    if cli.json_errors {
178        let summary = serde_json::json!({
179            "batch_execution_summary": {
180                "total_operations": result.results.len(),
181                "successful_operations": result.success_count,
182                "failed_operations": result.failure_count,
183                "total_duration_seconds": result.total_duration.as_secs_f64(),
184                "operations": result.results.iter().map(|r| serde_json::json!({
185                    "operation_id": r.operation.id,
186                    "args": r.operation.args,
187                    "success": r.success,
188                    "duration_seconds": r.duration.as_secs_f64(),
189                    "error": r.error
190                })).collect::<Vec<_>>()
191            }
192        });
193        let json_output = match &cli.jq {
194            Some(jq_filter) => {
195                let summary_json = serde_json::to_string(&summary)
196                    .expect("JSON serialization of valid structure cannot fail");
197                executor::apply_jq_filter(&summary_json, jq_filter)?
198            }
199            None => serde_json::to_string_pretty(&summary)
200                .expect("JSON serialization of valid structure cannot fail"),
201        };
202        // ast-grep-ignore: no-println
203        println!("{json_output}");
204        // ast-grep-ignore: no-nested-if
205        if result.failure_count > 0 {
206            std::process::exit(1);
207        }
208        return Ok(());
209    }
210
211    output.info("\n=== Batch Execution Summary ===");
212    // ast-grep-ignore: no-println
213    println!("Total operations: {}", result.results.len());
214    // ast-grep-ignore: no-println
215    println!("Successful: {}", result.success_count);
216    // ast-grep-ignore: no-println
217    println!("Failed: {}", result.failure_count);
218    // ast-grep-ignore: no-println
219    println!("Total time: {:.2}s", result.total_duration.as_secs_f64());
220
221    if result.failure_count == 0 {
222        return Ok(());
223    }
224
225    output.info("\nFailed operations:");
226    for (i, op_result) in result.results.iter().enumerate() {
227        if op_result.success {
228            continue;
229        }
230        // ast-grep-ignore: no-println
231        println!(
232            "  {} - {}: {}",
233            i + 1,
234            op_result.operation.args.join(" "),
235            op_result.error.as_deref().unwrap_or("Unknown error")
236        );
237    }
238
239    if result.failure_count > 0 {
240        std::process::exit(1);
241    }
242    Ok(())
243}
244
245/// Execute a command using shortcut resolution
246pub async fn execute_shortcut_command(
247    manager: &ConfigManager<OsFileSystem>,
248    args: Vec<String>,
249    cli: &Cli,
250) -> Result<(), Error> {
251    let output = Output::new(cli.quiet, cli.json_errors);
252
253    if args.is_empty() {
254        eprintln!("Error: No command specified");
255        eprintln!("Usage: aperture exec <shortcut> [args...]");
256        eprintln!("Examples:");
257        eprintln!("  aperture exec getUserById --id 123");
258        eprintln!("  aperture exec GET /users/123");
259        eprintln!("  aperture exec users list");
260        std::process::exit(1);
261    }
262
263    let specs = manager.list_specs()?;
264    if specs.is_empty() {
265        output.info("No API specifications found. Use 'aperture config add' to register APIs.");
266        return Ok(());
267    }
268
269    let cache_dir = manager.config_dir().join(constants::DIR_CACHE);
270    let mut all_specs = std::collections::BTreeMap::new();
271    for spec_name in &specs {
272        match loader::load_cached_spec(&cache_dir, spec_name) {
273            Ok(spec) => {
274                all_specs.insert(spec_name.clone(), spec);
275            }
276            Err(e) => eprintln!("Warning: Could not load spec '{spec_name}': {e}"),
277        }
278    }
279    if all_specs.is_empty() {
280        output.info("No valid API specifications found.");
281        return Ok(());
282    }
283
284    let mut resolver = ShortcutResolver::new();
285    resolver.index_specs(&all_specs);
286
287    match resolver.resolve_shortcut(&args) {
288        ResolutionResult::Resolved(shortcut) => {
289            output.info(format!(
290                "Resolved shortcut to: aperture {}",
291                shortcut.full_command.join(" ")
292            ));
293            let context = &shortcut.full_command[1];
294            let operation_args = shortcut.full_command[2..].to_vec();
295            let user_args = if args.len() > count_shortcut_args(&args) {
296                args[count_shortcut_args(&args)..].to_vec()
297            } else {
298                Vec::new()
299            };
300            let final_args = [operation_args, user_args].concat();
301            execute_api_command(context, final_args, cli).await
302        }
303        ResolutionResult::Ambiguous(matches) => {
304            eprintln!("Ambiguous shortcut. Multiple commands match:");
305            eprintln!("{}", resolver.format_ambiguous_suggestions(&matches));
306            eprintln!("\nTip: Use 'aperture search <term>' to explore available commands");
307            std::process::exit(1);
308        }
309        ResolutionResult::NotFound => {
310            eprintln!("No command found for shortcut: {}", args.join(" "));
311            eprintln!("Try one of these:");
312            eprintln!(
313                "  aperture search '{}'    # Search for similar commands",
314                args[0]
315            );
316            eprintln!("  aperture list-commands <api>  # List available commands for an API");
317            eprintln!("  aperture api <api> --help     # Show help for an API");
318            std::process::exit(1);
319        }
320    }
321}
322
323fn count_shortcut_args(args: &[String]) -> usize {
324    for (i, arg) in args.iter().enumerate() {
325        if arg.starts_with('-') || arg.contains('=') {
326            return i;
327        }
328    }
329    std::cmp::min(args.len(), 3)
330}
331
332#[cfg(test)]
333mod tests {
334    use super::resolve_output_format;
335    use crate::cli::OutputFormat;
336    use clap::{Arg, Command};
337
338    fn matches_from(args: &[&str]) -> clap::ArgMatches {
339        Command::new("api")
340            .arg(
341                Arg::new("format")
342                    .long("format")
343                    .value_parser(["json", "yaml", "table"])
344                    .default_value("json"),
345            )
346            .get_matches_from(args)
347    }
348
349    #[test]
350    fn resolve_output_format_prefers_cli_value_when_dynamic_match_is_default() {
351        let matches = matches_from(&["api"]);
352        let resolved = resolve_output_format(&matches, &OutputFormat::Yaml);
353
354        assert!(matches!(resolved, OutputFormat::Yaml));
355    }
356
357    #[test]
358    fn resolve_output_format_honors_explicit_json_override() {
359        let matches = matches_from(&["api", "--format", "json"]);
360        let resolved = resolve_output_format(&matches, &OutputFormat::Yaml);
361
362        assert!(matches!(resolved, OutputFormat::Json));
363    }
364}