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