Skip to main content

aperture_cli/engine/
executor.rs

1use crate::cache::models::{CachedCommand, CachedParameter, CachedSecurityScheme, CachedSpec};
2use crate::cli::OutputFormat;
3use crate::config::models::GlobalConfig;
4use crate::config::url_resolver::BaseUrlResolver;
5use crate::constants;
6use crate::error::Error;
7use crate::resilience::{
8    calculate_retry_delay_with_header, is_retryable_status, parse_retry_after_value,
9};
10use crate::response_cache::{
11    CacheConfig, CacheKey, CachedRequestInfo, CachedResponse, ResponseCache,
12};
13use crate::utils::to_kebab_case;
14use base64::{engine::general_purpose, Engine as _};
15use clap::ArgMatches;
16use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
17use reqwest::Method;
18use serde_json::Value;
19use sha2::{Digest, Sha256};
20use std::collections::{BTreeMap, HashMap};
21use std::fmt::Write;
22use std::str::FromStr;
23use tabled::Table;
24use tokio::time::sleep;
25
26#[cfg(feature = "jq")]
27use jaq_core::{Ctx, RcIter};
28#[cfg(feature = "jq")]
29use jaq_json::Val;
30
31/// Represents supported authentication schemes
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum AuthScheme {
34    Bearer,
35    Basic,
36    Token,
37    DSN,
38    ApiKey,
39    Custom(String),
40}
41
42impl From<&str> for AuthScheme {
43    fn from(s: &str) -> Self {
44        match s.to_lowercase().as_str() {
45            constants::AUTH_SCHEME_BEARER => Self::Bearer,
46            constants::AUTH_SCHEME_BASIC => Self::Basic,
47            "token" => Self::Token,
48            "dsn" => Self::DSN,
49            constants::AUTH_SCHEME_APIKEY => Self::ApiKey,
50            _ => Self::Custom(s.to_string()),
51        }
52    }
53}
54
55/// Configuration for request retry behavior.
56#[derive(Debug, Clone)]
57pub struct RetryContext {
58    /// Maximum number of retry attempts (0 = disabled)
59    pub max_attempts: u32,
60    /// Initial delay between retries in milliseconds
61    pub initial_delay_ms: u64,
62    /// Maximum delay cap in milliseconds
63    pub max_delay_ms: u64,
64    /// Whether to force retry on non-idempotent requests without idempotency key
65    pub force_retry: bool,
66    /// HTTP method (used to check idempotency)
67    pub method: Option<String>,
68    /// Whether an idempotency key is set
69    pub has_idempotency_key: bool,
70}
71
72impl Default for RetryContext {
73    fn default() -> Self {
74        Self {
75            max_attempts: 0, // Disabled by default
76            initial_delay_ms: 500,
77            max_delay_ms: 30_000,
78            force_retry: false,
79            method: None,
80            has_idempotency_key: false,
81        }
82    }
83}
84
85impl RetryContext {
86    /// Returns true if retries are enabled.
87    #[must_use]
88    pub const fn is_enabled(&self) -> bool {
89        self.max_attempts > 0
90    }
91
92    /// Returns true if the request method is safe to retry (idempotent or has key).
93    #[must_use]
94    pub fn is_safe_to_retry(&self) -> bool {
95        if self.force_retry || self.has_idempotency_key {
96            return true;
97        }
98
99        // GET, HEAD, PUT, OPTIONS, TRACE are idempotent per HTTP semantics
100        self.method.as_ref().is_some_and(|m| {
101            matches!(
102                m.to_uppercase().as_str(),
103                "GET" | "HEAD" | "PUT" | "OPTIONS" | "TRACE"
104            )
105        })
106    }
107}
108
109/// Maximum number of rows to display in table format to prevent memory exhaustion
110const MAX_TABLE_ROWS: usize = 1000;
111
112// Helper functions
113
114/// Extract server variable arguments from CLI matches
115fn extract_server_var_args(matches: &ArgMatches) -> Vec<String> {
116    matches
117        .try_get_many::<String>("server-var")
118        .ok()
119        .flatten()
120        .map(|values| values.cloned().collect())
121        .unwrap_or_default()
122}
123
124/// Build HTTP client with default timeout
125fn build_http_client() -> Result<reqwest::Client, Error> {
126    reqwest::Client::builder()
127        .timeout(std::time::Duration::from_secs(30))
128        .build()
129        .map_err(|e| {
130            Error::request_failed(
131                reqwest::StatusCode::INTERNAL_SERVER_ERROR,
132                format!("Failed to create HTTP client: {e}"),
133            )
134        })
135}
136
137/// Extract request body from matches
138fn extract_request_body(
139    operation: &CachedCommand,
140    matches: &ArgMatches,
141) -> Result<Option<String>, Error> {
142    if operation.request_body.is_none() {
143        return Ok(None);
144    }
145
146    // Get to the deepest subcommand matches
147    let mut current_matches = matches;
148    while let Some((_name, sub_matches)) = current_matches.subcommand() {
149        current_matches = sub_matches;
150    }
151
152    if let Some(body_value) = current_matches.get_one::<String>("body") {
153        // Validate JSON
154        let _json_body: Value = serde_json::from_str(body_value)
155            .map_err(|e| Error::invalid_json_body(e.to_string()))?;
156        Ok(Some(body_value.clone()))
157    } else {
158        Ok(None)
159    }
160}
161
162/// Handle dry-run mode
163fn handle_dry_run(
164    dry_run: bool,
165    method: &reqwest::Method,
166    url: &str,
167    headers: &reqwest::header::HeaderMap,
168    body: Option<&str>,
169    operation: &CachedCommand,
170    capture_output: bool,
171) -> Result<Option<String>, Error> {
172    if !dry_run {
173        return Ok(None);
174    }
175
176    let headers_map: HashMap<String, String> = headers
177        .iter()
178        .map(|(k, v)| {
179            let value = if is_sensitive_header(k.as_str()) {
180                "<REDACTED>".to_string()
181            } else {
182                v.to_str().unwrap_or("<binary>").to_string()
183            };
184            (k.as_str().to_string(), value)
185        })
186        .collect();
187
188    let dry_run_info = serde_json::json!({
189        "dry_run": true,
190        "method": method.to_string(),
191        "url": url,
192        "headers": headers_map,
193        "body": body,
194        "operation_id": operation.operation_id
195    });
196
197    let output = serde_json::to_string_pretty(&dry_run_info).map_err(|e| {
198        Error::serialization_error(format!("Failed to serialize dry run info: {e}"))
199    })?;
200
201    if capture_output {
202        Ok(Some(output))
203    } else {
204        // ast-grep-ignore: no-println
205        println!("{output}");
206        Ok(None)
207    }
208}
209
210/// Send HTTP request and get response
211async fn send_request(
212    request: reqwest::RequestBuilder,
213) -> Result<(reqwest::StatusCode, HashMap<String, String>, String), Error> {
214    let response = request
215        .send()
216        .await
217        .map_err(|e| Error::network_request_failed(e.to_string()))?;
218
219    let status = response.status();
220    let response_headers: HashMap<String, String> = response
221        .headers()
222        .iter()
223        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
224        .collect();
225
226    let response_text = response
227        .text()
228        .await
229        .map_err(|e| Error::response_read_error(e.to_string()))?;
230
231    Ok((status, response_headers, response_text))
232}
233
234/// Send HTTP request with retry logic
235#[allow(clippy::too_many_arguments)]
236#[allow(clippy::too_many_lines)]
237async fn send_request_with_retry(
238    client: &reqwest::Client,
239    method: Method,
240    url: &str,
241    headers: HeaderMap,
242    body: Option<String>,
243    retry_context: Option<&RetryContext>,
244    operation: &CachedCommand,
245) -> Result<(reqwest::StatusCode, HashMap<String, String>, String), Error> {
246    use crate::resilience::RetryConfig;
247
248    // If no retry context or retries disabled, just send once
249    let Some(ctx) = retry_context else {
250        let request = build_request(client, method, url, headers, body);
251        return send_request(request).await;
252    };
253
254    if !ctx.is_enabled() {
255        let request = build_request(client, method, url, headers, body);
256        return send_request(request).await;
257    }
258
259    // Check if safe to retry non-GET requests
260    if !ctx.is_safe_to_retry() {
261        // ast-grep-ignore: no-println
262        eprintln!(
263            "Warning: Retries disabled for {} {} - method is not idempotent and no --idempotency-key provided",
264            method,
265            operation.operation_id
266        );
267        // ast-grep-ignore: no-println
268        eprintln!(
269            "         Use --force-retry to enable retries anyway, or provide --idempotency-key"
270        );
271        let request = build_request(client, method.clone(), url, headers, body);
272        return send_request(request).await;
273    }
274
275    // Create a RetryConfig from the RetryContext
276    let retry_config = RetryConfig {
277        max_attempts: ctx.max_attempts as usize,
278        initial_delay_ms: ctx.initial_delay_ms,
279        max_delay_ms: ctx.max_delay_ms,
280        backoff_multiplier: 2.0,
281        jitter: true,
282    };
283
284    let max_attempts = ctx.max_attempts;
285    let mut attempt: u32 = 0;
286    let mut last_error: Option<Error> = None;
287    let mut last_status: Option<reqwest::StatusCode> = None;
288    let mut last_response_headers: Option<HashMap<String, String>> = None;
289    let mut last_response_text: Option<String> = None;
290
291    while attempt < max_attempts {
292        attempt += 1;
293
294        let request = build_request(client, method.clone(), url, headers.clone(), body.clone());
295        let result = send_request(request).await;
296
297        match result {
298            Ok((status, response_headers, response_text)) => {
299                // Success - return immediately
300                if status.is_success() {
301                    return Ok((status, response_headers, response_text));
302                }
303
304                // Check if we should retry this status code
305                if !is_retryable_status(status.as_u16()) {
306                    return Ok((status, response_headers, response_text));
307                }
308
309                // Parse Retry-After header if present
310                let retry_after = response_headers
311                    .get("retry-after")
312                    .and_then(|v| parse_retry_after_value(v));
313
314                // Calculate delay using the retry config
315                let delay = calculate_retry_delay_with_header(
316                    &retry_config,
317                    (attempt - 1) as usize, // 0-indexed for delay calculation
318                    retry_after,
319                );
320
321                // Check if we have more attempts
322                if attempt < max_attempts {
323                    // ast-grep-ignore: no-println
324                    eprintln!(
325                        "Retry {}/{}: {} {} returned {} - retrying in {}ms",
326                        attempt,
327                        max_attempts,
328                        method,
329                        operation.operation_id,
330                        status.as_u16(),
331                        delay.as_millis()
332                    );
333                    sleep(delay).await;
334                }
335
336                // Save for potential final error
337                last_status = Some(status);
338                last_response_headers = Some(response_headers);
339                last_response_text = Some(response_text);
340            }
341            Err(e) => {
342                // Network error - check if we should retry
343                let should_retry = matches!(&e, Error::Network(_));
344
345                if !should_retry {
346                    return Err(e);
347                }
348
349                // Calculate delay
350                let delay =
351                    calculate_retry_delay_with_header(&retry_config, (attempt - 1) as usize, None);
352
353                if attempt < max_attempts {
354                    // ast-grep-ignore: no-println
355                    eprintln!(
356                        "Retry {}/{}: {} {} failed - retrying in {}ms: {}",
357                        attempt,
358                        max_attempts,
359                        method,
360                        operation.operation_id,
361                        delay.as_millis(),
362                        e
363                    );
364                    sleep(delay).await;
365                }
366
367                last_error = Some(e);
368            }
369        }
370    }
371
372    // All retries exhausted - return last result
373    if let (Some(status), Some(headers), Some(text)) =
374        (last_status, last_response_headers, last_response_text)
375    {
376        // ast-grep-ignore: no-println
377        eprintln!(
378            "Retry exhausted: {} {} failed after {} attempts",
379            method, operation.operation_id, max_attempts
380        );
381        return Ok((status, headers, text));
382    }
383
384    // Return detailed retry error if we have a last error
385    if let Some(e) = last_error {
386        // ast-grep-ignore: no-println
387        eprintln!(
388            "Retry exhausted: {} {} failed after {} attempts",
389            method, operation.operation_id, max_attempts
390        );
391        // Return detailed retry error with full context
392        return Err(Error::retry_limit_exceeded_detailed(
393            max_attempts,
394            attempt,
395            e.to_string(),
396            ctx.initial_delay_ms,
397            ctx.max_delay_ms,
398            None,
399            &operation.operation_id,
400        ));
401    }
402
403    // Should not happen, but handle gracefully
404    Err(Error::retry_limit_exceeded_detailed(
405        max_attempts,
406        attempt,
407        "Request failed with no response",
408        ctx.initial_delay_ms,
409        ctx.max_delay_ms,
410        None,
411        &operation.operation_id,
412    ))
413}
414
415/// Build a request from components
416fn build_request(
417    client: &reqwest::Client,
418    method: Method,
419    url: &str,
420    headers: HeaderMap,
421    body: Option<String>,
422) -> reqwest::RequestBuilder {
423    let mut request = client.request(method, url).headers(headers);
424    if let Some(json_body) = body.and_then(|s| serde_json::from_str::<Value>(&s).ok()) {
425        request = request.json(&json_body);
426    }
427    request
428}
429
430/// Handle HTTP error responses
431fn handle_http_error(
432    status: reqwest::StatusCode,
433    response_text: String,
434    spec: &CachedSpec,
435    operation: &CachedCommand,
436) -> Error {
437    let api_name = spec.name.clone();
438    let operation_id = Some(operation.operation_id.clone());
439
440    let security_schemes: Vec<String> = operation
441        .security_requirements
442        .iter()
443        .filter_map(|scheme_name| {
444            spec.security_schemes
445                .get(scheme_name)
446                .and_then(|scheme| scheme.aperture_secret.as_ref())
447                .map(|aperture_secret| aperture_secret.name.clone())
448        })
449        .collect();
450
451    Error::http_error_with_context(
452        status.as_u16(),
453        if response_text.is_empty() {
454            constants::EMPTY_RESPONSE.to_string()
455        } else {
456            response_text
457        },
458        api_name,
459        operation_id,
460        &security_schemes,
461    )
462}
463
464/// Prepare cache context if caching is enabled
465fn prepare_cache_context(
466    cache_config: Option<&CacheConfig>,
467    spec_name: &str,
468    operation_id: &str,
469    method: &reqwest::Method,
470    url: &str,
471    headers: &reqwest::header::HeaderMap,
472    body: Option<&str>,
473) -> Result<Option<(CacheKey, ResponseCache)>, Error> {
474    let Some(cache_cfg) = cache_config else {
475        return Ok(None);
476    };
477
478    if !cache_cfg.enabled {
479        return Ok(None);
480    }
481
482    let header_map: HashMap<String, String> = headers
483        .iter()
484        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
485        .collect();
486
487    let cache_key = CacheKey::from_request(
488        spec_name,
489        operation_id,
490        method.as_ref(),
491        url,
492        &header_map,
493        body,
494    )?;
495
496    let response_cache = ResponseCache::new(cache_cfg.clone())?;
497    Ok(Some((cache_key, response_cache)))
498}
499
500/// Check cache for existing response
501async fn check_cache(
502    cache_context: Option<&(CacheKey, ResponseCache)>,
503) -> Result<Option<CachedResponse>, Error> {
504    if let Some((cache_key, response_cache)) = cache_context {
505        response_cache.get(cache_key).await
506    } else {
507        Ok(None)
508    }
509}
510
511/// Store response in cache
512#[allow(clippy::too_many_arguments)]
513async fn store_in_cache(
514    cache_context: Option<(CacheKey, ResponseCache)>,
515    response_text: &str,
516    status: reqwest::StatusCode,
517    response_headers: &HashMap<String, String>,
518    method: reqwest::Method,
519    url: String,
520    headers: &reqwest::header::HeaderMap,
521    body: Option<&str>,
522    cache_config: Option<&CacheConfig>,
523) -> Result<(), Error> {
524    let Some((cache_key, response_cache)) = cache_context else {
525        return Ok(());
526    };
527
528    let cached_request_info = CachedRequestInfo {
529        method: method.to_string(),
530        url,
531        headers: headers
532            .iter()
533            .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
534            .collect(),
535        body_hash: body.map(|b| {
536            let mut hasher = Sha256::new();
537            hasher.update(b.as_bytes());
538            format!("{:x}", hasher.finalize())
539        }),
540    };
541
542    let cache_ttl = cache_config.and_then(|cfg| {
543        if cfg.default_ttl.as_secs() > 0 {
544            Some(cfg.default_ttl)
545        } else {
546            None
547        }
548    });
549
550    response_cache
551        .store(
552            &cache_key,
553            response_text,
554            status.as_u16(),
555            response_headers,
556            cached_request_info,
557            cache_ttl,
558        )
559        .await?;
560
561    Ok(())
562}
563
564/// Executes HTTP requests based on parsed CLI arguments and cached spec data.
565///
566/// This module handles the mapping from CLI arguments back to API operations,
567/// resolves authentication secrets, builds HTTP requests, and validates responses.
568///
569/// # Arguments
570/// * `spec` - The cached specification containing operation details
571/// * `matches` - Parsed CLI arguments from clap
572/// * `base_url` - Optional base URL override. If None, uses `BaseUrlResolver`
573/// * `dry_run` - If true, show request details without executing
574/// * `idempotency_key` - Optional idempotency key for safe retries
575/// * `global_config` - Optional global configuration for URL resolution
576/// * `output_format` - Format for response output (json, yaml, table)
577/// * `jq_filter` - Optional JQ filter expression to apply to response
578/// * `cache_config` - Optional cache configuration for response caching
579/// * `capture_output` - If true, captures output and returns it instead of printing to stdout
580/// * `retry_context` - Optional retry configuration for automatic request retries
581///
582/// # Returns
583/// * `Ok(Option<String>)` - Request executed successfully. Returns Some(output) if `capture_output` is true
584/// * `Err(Error)` - Request failed or validation error
585///
586/// # Errors
587/// Returns errors for authentication failures, network issues, response validation, or JQ filter errors
588///
589/// # Panics
590/// Panics if JSON serialization of dry-run information fails (extremely unlikely)
591#[allow(clippy::too_many_lines)]
592#[allow(clippy::too_many_arguments)]
593#[allow(clippy::missing_panics_doc)]
594#[allow(clippy::missing_errors_doc)]
595pub async fn execute_request(
596    spec: &CachedSpec,
597    matches: &ArgMatches,
598    base_url: Option<&str>,
599    dry_run: bool,
600    idempotency_key: Option<&str>,
601    global_config: Option<&GlobalConfig>,
602    output_format: &OutputFormat,
603    jq_filter: Option<&str>,
604    cache_config: Option<&CacheConfig>,
605    capture_output: bool,
606    retry_context: Option<&RetryContext>,
607) -> Result<Option<String>, Error> {
608    // Find the operation from the command hierarchy (also returns the operation's ArgMatches)
609    let (operation, operation_matches) = find_operation_with_matches(spec, matches)?;
610
611    // Check if --show-examples flag is present in the operation's matches
612    // Only check if the flag exists in the matches (it won't exist in some test scenarios)
613    if operation_matches
614        .try_contains_id("show-examples")
615        .unwrap_or(false)
616        && operation_matches.get_flag("show-examples")
617    {
618        print_extended_examples(operation);
619        return Ok(None);
620    }
621
622    // Extract server variable arguments
623    let server_var_args = extract_server_var_args(matches);
624
625    // Resolve base URL using the new priority hierarchy with server variable support
626    let resolver = BaseUrlResolver::new(spec);
627    let resolver = if let Some(config) = global_config {
628        resolver.with_global_config(config)
629    } else {
630        resolver
631    };
632    let base_url = resolver.resolve_with_variables(base_url, &server_var_args)?;
633
634    // Build the full URL with path parameters
635    let url = build_url(&base_url, &operation.path, operation, operation_matches)?;
636
637    // Create HTTP client
638    let client = build_http_client()?;
639
640    // Build headers including authentication and idempotency
641    let mut headers = build_headers(
642        spec,
643        operation,
644        operation_matches,
645        &spec.name,
646        global_config,
647    )?;
648
649    // Add idempotency key if provided
650    if let Some(key) = idempotency_key {
651        headers.insert(
652            HeaderName::from_static("idempotency-key"),
653            HeaderValue::from_str(key).map_err(|_| Error::invalid_idempotency_key())?,
654        );
655    }
656
657    // Build request
658    let method = Method::from_str(&operation.method)
659        .map_err(|_| Error::invalid_http_method(&operation.method))?;
660
661    let headers_clone = headers.clone(); // For dry-run output
662
663    // Extract request body
664    let request_body = extract_request_body(operation, operation_matches)?;
665
666    // Prepare cache context
667    let cache_context = prepare_cache_context(
668        cache_config,
669        &spec.name,
670        &operation.operation_id,
671        &method,
672        &url,
673        &headers_clone,
674        request_body.as_deref(),
675    )?;
676
677    // Check cache for existing response
678    if let Some(cached_response) = check_cache(cache_context.as_ref()).await? {
679        let output = print_formatted_response(
680            &cached_response.body,
681            output_format,
682            jq_filter,
683            capture_output,
684        )?;
685        return Ok(output);
686    }
687
688    // Handle dry-run mode
689    if let Some(output) = handle_dry_run(
690        dry_run,
691        &method,
692        &url,
693        &headers_clone,
694        request_body.as_deref(),
695        operation,
696        capture_output,
697    )? {
698        return Ok(Some(output));
699    }
700    if dry_run {
701        return Ok(None);
702    }
703
704    // Build retry context with method information
705    let retry_ctx = retry_context.map(|ctx| {
706        let mut ctx = ctx.clone();
707        ctx.method = Some(method.to_string());
708        ctx
709    });
710
711    // Send request with retry support
712    let (status, response_headers, response_text) = send_request_with_retry(
713        &client,
714        method.clone(),
715        &url,
716        headers,
717        request_body.clone(),
718        retry_ctx.as_ref(),
719        operation,
720    )
721    .await?;
722
723    // Check if request was successful
724    if !status.is_success() {
725        return Err(handle_http_error(status, response_text, spec, operation));
726    }
727
728    // Store response in cache
729    store_in_cache(
730        cache_context,
731        &response_text,
732        status,
733        &response_headers,
734        method,
735        url,
736        &headers_clone,
737        request_body.as_deref(),
738        cache_config,
739    )
740    .await?;
741
742    // Print response in the requested format
743    if response_text.is_empty() {
744        Ok(None)
745    } else {
746        print_formatted_response(&response_text, output_format, jq_filter, capture_output)
747    }
748}
749
750/// Finds the operation from the command hierarchy
751/// Print extended examples for a command
752fn print_extended_examples(operation: &CachedCommand) {
753    // ast-grep-ignore: no-println
754    println!("Command: {}\n", to_kebab_case(&operation.operation_id));
755
756    if let Some(ref summary) = operation.summary {
757        // ast-grep-ignore: no-println
758        println!("Description: {summary}\n");
759    }
760
761    // ast-grep-ignore: no-println
762    println!("Method: {} {}\n", operation.method, operation.path);
763
764    if operation.examples.is_empty() {
765        // ast-grep-ignore: no-println
766        println!("No examples available for this command.");
767        return;
768    }
769
770    // ast-grep-ignore: no-println
771    println!("Examples:\n");
772    for (i, example) in operation.examples.iter().enumerate() {
773        // ast-grep-ignore: no-println
774        println!("{}. {}", i + 1, example.description);
775        // ast-grep-ignore: no-println
776        println!("   {}", example.command_line);
777        if let Some(ref explanation) = example.explanation {
778            // ast-grep-ignore: no-println
779            println!("   {explanation}");
780        }
781        // ast-grep-ignore: no-println
782        println!();
783    }
784
785    // Additional helpful information
786    if operation.parameters.is_empty() {
787        return;
788    }
789
790    // ast-grep-ignore: no-println
791    println!("Parameters:");
792    for param in &operation.parameters {
793        let required = if param.required { " (required)" } else { "" };
794        let param_type = param.schema_type.as_deref().unwrap_or("string");
795        // ast-grep-ignore: no-println
796        println!("  --{}{} [{}]", param.name, required, param_type);
797
798        let Some(ref desc) = param.description else {
799            continue;
800        };
801        // ast-grep-ignore: no-println
802        println!("      {desc}");
803    }
804    // ast-grep-ignore: no-println
805    println!();
806
807    if operation.request_body.is_some() {
808        // ast-grep-ignore: no-println
809        println!("Request Body:");
810        // ast-grep-ignore: no-println
811        println!("  --body JSON (required)");
812        // ast-grep-ignore: no-println
813        println!("      JSON data to send in the request body");
814    }
815}
816
817#[allow(dead_code)]
818fn find_operation<'a>(
819    spec: &'a CachedSpec,
820    matches: &ArgMatches,
821) -> Result<&'a CachedCommand, Error> {
822    // Get the subcommand path from matches
823    let mut current_matches = matches;
824    let mut subcommand_path = Vec::new();
825
826    while let Some((name, sub_matches)) = current_matches.subcommand() {
827        subcommand_path.push(name);
828        current_matches = sub_matches;
829    }
830
831    // For now, just find the first matching operation
832    // In a real implementation, we'd match based on the full path
833    let Some(operation_name) = subcommand_path.last() else {
834        let operation_name = "unknown".to_string();
835        let suggestions = crate::suggestions::suggest_similar_operations(spec, &operation_name);
836        return Err(Error::operation_not_found_with_suggestions(
837            operation_name,
838            &suggestions,
839        ));
840    };
841
842    for command in &spec.commands {
843        // Convert operation_id to kebab-case for comparison
844        let kebab_id = to_kebab_case(&command.operation_id);
845        if &kebab_id == operation_name || command.method.to_lowercase() == *operation_name {
846            return Ok(command);
847        }
848    }
849
850    let operation_name = subcommand_path
851        .last()
852        .map_or_else(|| "unknown".to_string(), ToString::to_string);
853
854    // Generate suggestions for similar operations
855    let suggestions = crate::suggestions::suggest_similar_operations(spec, &operation_name);
856
857    Err(Error::operation_not_found_with_suggestions(
858        operation_name,
859        &suggestions,
860    ))
861}
862
863fn find_operation_with_matches<'a>(
864    spec: &'a CachedSpec,
865    matches: &'a ArgMatches,
866) -> Result<(&'a CachedCommand, &'a ArgMatches), Error> {
867    // Get the subcommand path from matches
868    let mut current_matches = matches;
869    let mut subcommand_path = Vec::new();
870
871    while let Some((name, sub_matches)) = current_matches.subcommand() {
872        subcommand_path.push(name);
873        current_matches = sub_matches;
874    }
875
876    // For now, just find the first matching operation
877    // In a real implementation, we'd match based on the full path
878    let Some(operation_name) = subcommand_path.last() else {
879        let operation_name = "unknown".to_string();
880        let suggestions = crate::suggestions::suggest_similar_operations(spec, &operation_name);
881        return Err(Error::operation_not_found_with_suggestions(
882            operation_name,
883            &suggestions,
884        ));
885    };
886
887    for command in &spec.commands {
888        // Convert operation_id to kebab-case for comparison
889        let kebab_id = to_kebab_case(&command.operation_id);
890        if &kebab_id == operation_name || command.method.to_lowercase() == *operation_name {
891            // Return current_matches (the deepest subcommand) which contains the operation's arguments
892            return Ok((command, current_matches));
893        }
894    }
895
896    let operation_name = subcommand_path
897        .last()
898        .map_or_else(|| "unknown".to_string(), ToString::to_string);
899
900    // Generate suggestions for similar operations
901    let suggestions = crate::suggestions::suggest_similar_operations(spec, &operation_name);
902
903    Err(Error::operation_not_found_with_suggestions(
904        operation_name,
905        &suggestions,
906    ))
907}
908
909/// Get query parameter value formatted for URL
910/// Returns None if the parameter value should be skipped
911fn get_query_param_value(
912    param: &CachedParameter,
913    current_matches: &ArgMatches,
914    arg_str: &str,
915) -> Option<String> {
916    let is_boolean = param.schema_type.as_ref().is_some_and(|t| t == "boolean");
917
918    if is_boolean {
919        // Boolean parameters are flags - add only if set
920        current_matches
921            .get_flag(arg_str)
922            .then(|| format!("{arg_str}=true"))
923    } else {
924        // Non-boolean parameters have string values
925        current_matches
926            .get_one::<String>(arg_str)
927            .map(|value| format!("{arg_str}={}", urlencoding::encode(value)))
928    }
929}
930
931/// Builds the full URL with path parameters substituted
932///
933/// Note: Server variable substitution is now handled by `BaseUrlResolver.resolve_with_variables()`
934/// before calling this function, so `base_url` should already have server variables resolved.
935fn build_url(
936    base_url: &str,
937    path_template: &str,
938    operation: &CachedCommand,
939    matches: &ArgMatches,
940) -> Result<String, Error> {
941    let mut url = format!("{}{}", base_url.trim_end_matches('/'), path_template);
942
943    // Get to the deepest subcommand matches
944    let mut current_matches = matches;
945    while let Some((_name, sub_matches)) = current_matches.subcommand() {
946        current_matches = sub_matches;
947    }
948
949    // Substitute path parameters
950    // Look for {param} patterns and replace with values from matches
951    let mut start = 0;
952    while let Some(open) = url[start..].find('{') {
953        let open_pos = start + open;
954        let Some(close) = url[open_pos..].find('}') else {
955            break;
956        };
957
958        let close_pos = open_pos + close;
959        let param_name = &url[open_pos + 1..close_pos];
960
961        // Check if this is a boolean parameter
962        let param = operation.parameters.iter().find(|p| p.name == param_name);
963        let is_boolean = param
964            .and_then(|p| p.schema_type.as_ref())
965            .is_some_and(|t| t == "boolean");
966
967        let value = if is_boolean {
968            // Boolean path parameters are flags
969            if current_matches.get_flag(param_name) {
970                "true"
971            } else {
972                "false"
973            }
974            .to_string()
975        } else {
976            match current_matches
977                .try_get_one::<String>(param_name)
978                .ok()
979                .flatten()
980            {
981                Some(string_value) => string_value.clone(),
982                None => return Err(Error::missing_path_parameter(param_name)),
983            }
984        };
985
986        url.replace_range(open_pos..=close_pos, &value);
987        start = open_pos + value.len();
988    }
989
990    // Add query parameters
991    let mut query_params = Vec::new();
992    for arg in current_matches.ids() {
993        let arg_str = arg.as_str();
994        // Skip non-query args - only process query parameters from the operation
995        let param = operation
996            .parameters
997            .iter()
998            .find(|p| p.name == arg_str && p.location == "query");
999
1000        let Some(param) = param else {
1001            continue;
1002        };
1003
1004        // Get query param value using helper (handles boolean vs string params)
1005        if let Some(value) = get_query_param_value(param, current_matches, arg_str) {
1006            query_params.push(value);
1007        }
1008    }
1009
1010    if !query_params.is_empty() {
1011        url.push('?');
1012        url.push_str(&query_params.join("&"));
1013    }
1014
1015    Ok(url)
1016}
1017
1018/// Get header value for a parameter from CLI matches
1019/// Returns None if the parameter value should be skipped
1020fn get_header_param_value(
1021    param: &CachedParameter,
1022    current_matches: &ArgMatches,
1023) -> Result<Option<HeaderValue>, Error> {
1024    let is_boolean = matches!(param.schema_type.as_deref(), Some("boolean"));
1025
1026    // Handle boolean and non-boolean parameters separately to avoid
1027    // panics from mismatched types (get_flag on string or get_one on bool)
1028    if is_boolean {
1029        return Ok(current_matches
1030            .get_flag(&param.name)
1031            .then_some(HeaderValue::from_static("true")));
1032    }
1033
1034    // Non-boolean: get string value
1035    current_matches
1036        .get_one::<String>(&param.name)
1037        .map(|value| {
1038            HeaderValue::from_str(value)
1039                .map_err(|e| Error::invalid_header_value(&param.name, e.to_string()))
1040        })
1041        .transpose()
1042}
1043
1044/// Builds headers including authentication
1045fn build_headers(
1046    spec: &CachedSpec,
1047    operation: &CachedCommand,
1048    matches: &ArgMatches,
1049    api_name: &str,
1050    global_config: Option<&GlobalConfig>,
1051) -> Result<HeaderMap, Error> {
1052    let mut headers = HeaderMap::new();
1053
1054    // Add default headers
1055    headers.insert("User-Agent", HeaderValue::from_static("aperture/0.1.0"));
1056    headers.insert(
1057        constants::HEADER_ACCEPT,
1058        HeaderValue::from_static(constants::CONTENT_TYPE_JSON),
1059    );
1060
1061    // Get to the deepest subcommand matches
1062    let mut current_matches = matches;
1063    while let Some((_name, sub_matches)) = current_matches.subcommand() {
1064        current_matches = sub_matches;
1065    }
1066
1067    // Add header parameters from matches
1068    for param in &operation.parameters {
1069        // Skip non-header parameters early
1070        if param.location != "header" {
1071            continue;
1072        }
1073
1074        let header_name = HeaderName::from_str(&param.name)
1075            .map_err(|e| Error::invalid_header_name(&param.name, e.to_string()))?;
1076
1077        // Get header value using helper (handles boolean vs string params)
1078        let Some(header_value) = get_header_param_value(param, current_matches)? else {
1079            continue;
1080        };
1081
1082        headers.insert(header_name, header_value);
1083    }
1084
1085    // Add authentication headers based on security requirements
1086    for security_scheme_name in &operation.security_requirements {
1087        let Some(security_scheme) = spec.security_schemes.get(security_scheme_name) else {
1088            continue;
1089        };
1090        add_authentication_header(&mut headers, security_scheme, api_name, global_config)?;
1091    }
1092
1093    // Add custom headers from --header/-H flags
1094    // Use try_get_many to avoid panic when header arg doesn't exist
1095    let Ok(Some(custom_headers)) = current_matches.try_get_many::<String>("header") else {
1096        return Ok(headers);
1097    };
1098
1099    for header_str in custom_headers {
1100        let (name, value) = parse_custom_header(header_str)?;
1101        let header_name = HeaderName::from_str(&name)
1102            .map_err(|e| Error::invalid_header_name(&name, e.to_string()))?;
1103        let header_value = HeaderValue::from_str(&value)
1104            .map_err(|e| Error::invalid_header_value(&name, e.to_string()))?;
1105        headers.insert(header_name, header_value);
1106    }
1107
1108    Ok(headers)
1109}
1110
1111/// Validates that a header value doesn't contain control characters
1112fn validate_header_value(name: &str, value: &str) -> Result<(), Error> {
1113    if value.chars().any(|c| c == '\r' || c == '\n' || c == '\0') {
1114        return Err(Error::invalid_header_value(
1115            name,
1116            "Header value contains invalid control characters (newline, carriage return, or null)",
1117        ));
1118    }
1119    Ok(())
1120}
1121
1122/// Parses a custom header string in the format "Name: Value" or "Name:Value"
1123fn parse_custom_header(header_str: &str) -> Result<(String, String), Error> {
1124    // Find the colon separator
1125    let colon_pos = header_str
1126        .find(':')
1127        .ok_or_else(|| Error::invalid_header_format(header_str))?;
1128
1129    let name = header_str[..colon_pos].trim();
1130    let value = header_str[colon_pos + 1..].trim();
1131
1132    if name.is_empty() {
1133        return Err(Error::empty_header_name());
1134    }
1135
1136    // Support environment variable expansion in header values
1137    let expanded_value = if value.starts_with("${") && value.ends_with('}') {
1138        // Extract environment variable name
1139        let var_name = &value[2..value.len() - 1];
1140        std::env::var(var_name).unwrap_or_else(|_| value.to_string())
1141    } else {
1142        value.to_string()
1143    };
1144
1145    // Validate the header value
1146    validate_header_value(name, &expanded_value)?;
1147
1148    Ok((name.to_string(), expanded_value))
1149}
1150
1151/// Checks if a header name contains sensitive authentication information
1152fn is_sensitive_header(header_name: &str) -> bool {
1153    let name_lower = header_name.to_lowercase();
1154    matches!(
1155        name_lower.as_str(),
1156        "authorization" | "proxy-authorization" | "x-api-key" | "x-api-token" | "x-auth-token"
1157    )
1158}
1159
1160/// Adds an authentication header based on a security scheme
1161#[allow(clippy::too_many_lines)]
1162fn add_authentication_header(
1163    headers: &mut HeaderMap,
1164    security_scheme: &CachedSecurityScheme,
1165    api_name: &str,
1166    global_config: Option<&GlobalConfig>,
1167) -> Result<(), Error> {
1168    // Debug logging when RUST_LOG is set
1169    if std::env::var("RUST_LOG").is_ok() {
1170        // ast-grep-ignore: no-println
1171        eprintln!(
1172            "[DEBUG] Adding authentication header for scheme: {} (type: {})",
1173            security_scheme.name, security_scheme.scheme_type
1174        );
1175    }
1176
1177    // Priority 1: Check config-based secrets first
1178    let secret_config = global_config
1179        .and_then(|config| config.api_configs.get(api_name))
1180        .and_then(|api_config| api_config.secrets.get(&security_scheme.name));
1181
1182    let (secret_value, env_var_name) = match (secret_config, &security_scheme.aperture_secret) {
1183        (Some(config_secret), _) => {
1184            // Use config-based secret
1185            let secret_value = std::env::var(&config_secret.name)
1186                .map_err(|_| Error::secret_not_set(&security_scheme.name, &config_secret.name))?;
1187            (secret_value, config_secret.name.clone())
1188        }
1189        (None, Some(aperture_secret)) => {
1190            // Priority 2: Fall back to x-aperture-secret extension
1191            let secret_value = std::env::var(&aperture_secret.name)
1192                .map_err(|_| Error::secret_not_set(&security_scheme.name, &aperture_secret.name))?;
1193            (secret_value, aperture_secret.name.clone())
1194        }
1195        (None, None) => {
1196            // No authentication configuration found - skip this scheme
1197            return Ok(());
1198        }
1199    };
1200
1201    // Debug logging for resolved secret source
1202    if std::env::var("RUST_LOG").is_ok() {
1203        let source = if secret_config.is_some() {
1204            "config"
1205        } else {
1206            "x-aperture-secret"
1207        };
1208        // ast-grep-ignore: no-println
1209        eprintln!(
1210            "[DEBUG] Using secret from {source} for scheme '{}': env var '{env_var_name}'",
1211            security_scheme.name
1212        );
1213    }
1214
1215    // Validate the secret doesn't contain control characters
1216    validate_header_value(constants::HEADER_AUTHORIZATION, &secret_value)?;
1217
1218    // Build the appropriate header based on scheme type
1219    match security_scheme.scheme_type.as_str() {
1220        constants::AUTH_SCHEME_APIKEY => {
1221            let (Some(location), Some(param_name)) =
1222                (&security_scheme.location, &security_scheme.parameter_name)
1223            else {
1224                return Ok(());
1225            };
1226
1227            if location == "header" {
1228                let header_name = HeaderName::from_str(param_name)
1229                    .map_err(|e| Error::invalid_header_name(param_name, e.to_string()))?;
1230                let header_value = HeaderValue::from_str(&secret_value)
1231                    .map_err(|e| Error::invalid_header_value(param_name, e.to_string()))?;
1232                headers.insert(header_name, header_value);
1233            }
1234            // Note: query and cookie locations are handled differently in request building
1235        }
1236        "http" => {
1237            let Some(scheme_str) = &security_scheme.scheme else {
1238                return Ok(());
1239            };
1240
1241            let auth_scheme: AuthScheme = scheme_str.as_str().into();
1242            let auth_value = match &auth_scheme {
1243                AuthScheme::Bearer => {
1244                    format!("Bearer {secret_value}")
1245                }
1246                AuthScheme::Basic => {
1247                    // Basic auth expects "username:password" format in the secret
1248                    // The secret should contain the raw "username:password" string
1249                    // We'll base64 encode it before adding to the header
1250                    let encoded = general_purpose::STANDARD.encode(&secret_value);
1251                    format!("Basic {encoded}")
1252                }
1253                AuthScheme::Token
1254                | AuthScheme::DSN
1255                | AuthScheme::ApiKey
1256                | AuthScheme::Custom(_) => {
1257                    // Treat any other HTTP scheme as a bearer-like token
1258                    // Format: "Authorization: <scheme> <token>"
1259                    // This supports Token, ApiKey, DSN, and any custom schemes
1260                    format!("{scheme_str} {secret_value}")
1261                }
1262            };
1263
1264            let header_value = HeaderValue::from_str(&auth_value).map_err(|e| {
1265                Error::invalid_header_value(constants::HEADER_AUTHORIZATION, e.to_string())
1266            })?;
1267            headers.insert(constants::HEADER_AUTHORIZATION, header_value);
1268
1269            // Debug logging
1270            if std::env::var("RUST_LOG").is_ok() {
1271                match &auth_scheme {
1272                    AuthScheme::Bearer => {
1273                        // ast-grep-ignore: no-println
1274                        eprintln!("[DEBUG] Added Bearer authentication header");
1275                    }
1276                    AuthScheme::Basic => {
1277                        // ast-grep-ignore: no-println
1278                        eprintln!("[DEBUG] Added Basic authentication header (base64 encoded)");
1279                    }
1280                    _ => {
1281                        // ast-grep-ignore: no-println
1282                        eprintln!(
1283                            "[DEBUG] Added custom HTTP auth header with scheme: {scheme_str}"
1284                        );
1285                    }
1286                }
1287            }
1288        }
1289        _ => {
1290            return Err(Error::unsupported_security_scheme(
1291                &security_scheme.scheme_type,
1292            ));
1293        }
1294    }
1295
1296    Ok(())
1297}
1298
1299/// Prints the response text in the specified format
1300fn print_formatted_response(
1301    response_text: &str,
1302    output_format: &OutputFormat,
1303    jq_filter: Option<&str>,
1304    capture_output: bool,
1305) -> Result<Option<String>, Error> {
1306    // Apply JQ filter if provided
1307    let processed_text = if let Some(filter) = jq_filter {
1308        apply_jq_filter(response_text, filter)?
1309    } else {
1310        response_text.to_string()
1311    };
1312
1313    match output_format {
1314        OutputFormat::Json => {
1315            // Try to pretty-print JSON (default behavior)
1316            let output = serde_json::from_str::<Value>(&processed_text)
1317                .ok()
1318                .and_then(|json_value| serde_json::to_string_pretty(&json_value).ok())
1319                .unwrap_or_else(|| processed_text.clone());
1320
1321            if capture_output {
1322                return Ok(Some(output));
1323            }
1324            // ast-grep-ignore: no-println
1325            println!("{output}");
1326        }
1327        OutputFormat::Yaml => {
1328            // Convert JSON to YAML
1329            let output = serde_json::from_str::<Value>(&processed_text)
1330                .ok()
1331                .and_then(|json_value| serde_yaml::to_string(&json_value).ok())
1332                .unwrap_or_else(|| processed_text.clone());
1333
1334            if capture_output {
1335                return Ok(Some(output));
1336            }
1337            // ast-grep-ignore: no-println
1338            println!("{output}");
1339        }
1340        OutputFormat::Table => {
1341            // Convert JSON to table format
1342            let Ok(json_value) = serde_json::from_str::<Value>(&processed_text) else {
1343                // If not JSON, output as-is
1344                if capture_output {
1345                    return Ok(Some(processed_text));
1346                }
1347                // ast-grep-ignore: no-println
1348                println!("{processed_text}");
1349                return Ok(None);
1350            };
1351
1352            let table_output = print_as_table(&json_value, capture_output)?;
1353            if capture_output {
1354                return Ok(table_output);
1355            }
1356        }
1357    }
1358
1359    Ok(None)
1360}
1361
1362// Define table structures at module level to avoid clippy::items_after_statements
1363#[derive(tabled::Tabled)]
1364struct TableRow {
1365    #[tabled(rename = "Key")]
1366    key: String,
1367    #[tabled(rename = "Value")]
1368    value: String,
1369}
1370
1371#[derive(tabled::Tabled)]
1372struct KeyValue {
1373    #[tabled(rename = "Key")]
1374    key: String,
1375    #[tabled(rename = "Value")]
1376    value: String,
1377}
1378
1379/// Prints items as a numbered list
1380fn print_numbered_list(items: &[Value], capture_output: bool) -> Option<String> {
1381    if capture_output {
1382        let mut output = String::new();
1383        for (i, item) in items.iter().enumerate() {
1384            writeln!(&mut output, "{}: {}", i, format_value_for_table(item))
1385                .expect("writing to String cannot fail");
1386        }
1387        return Some(output.trim_end().to_string());
1388    }
1389
1390    for (i, item) in items.iter().enumerate() {
1391        // ast-grep-ignore: no-println
1392        println!("{}: {}", i, format_value_for_table(item));
1393    }
1394    None
1395}
1396
1397/// Helper to output or capture a message
1398fn output_or_capture(message: &str, capture_output: bool) -> Option<String> {
1399    if capture_output {
1400        return Some(message.to_string());
1401    }
1402    // ast-grep-ignore: no-println
1403    println!("{message}");
1404    None
1405}
1406
1407/// Prints JSON data as a formatted table
1408#[allow(clippy::unnecessary_wraps, clippy::too_many_lines)]
1409fn print_as_table(json_value: &Value, capture_output: bool) -> Result<Option<String>, Error> {
1410    match json_value {
1411        Value::Array(items) => {
1412            if items.is_empty() {
1413                return Ok(output_or_capture(constants::EMPTY_ARRAY, capture_output));
1414            }
1415
1416            // Check if array is too large
1417            if items.len() > MAX_TABLE_ROWS {
1418                let msg = format!(
1419                    "Array too large: {} items (max {} for table display)\nUse --format json or --jq to process the full data",
1420                    items.len(),
1421                    MAX_TABLE_ROWS
1422                );
1423                return Ok(output_or_capture(&msg, capture_output));
1424            }
1425
1426            // Try to create a table from array of objects
1427            let Some(Value::Object(_)) = items.first() else {
1428                // Continue to fallback case
1429                return Ok(print_numbered_list(items, capture_output));
1430            };
1431
1432            // Create table for array of objects
1433            let mut table_data: Vec<BTreeMap<String, String>> = Vec::new();
1434
1435            for item in items {
1436                let Value::Object(obj) = item else {
1437                    continue;
1438                };
1439                let mut row = BTreeMap::new();
1440                for (key, value) in obj {
1441                    row.insert(key.clone(), format_value_for_table(value));
1442                }
1443                table_data.push(row);
1444            }
1445
1446            if table_data.is_empty() {
1447                // Fallback to numbered list
1448                return Ok(print_numbered_list(items, capture_output));
1449            }
1450
1451            // For now, use a simple key-value representation
1452            // In the future, we could implement a more sophisticated table structure
1453            let mut rows = Vec::new();
1454            for (i, row) in table_data.iter().enumerate() {
1455                if i > 0 {
1456                    rows.push(TableRow {
1457                        key: "---".to_string(),
1458                        value: "---".to_string(),
1459                    });
1460                }
1461                for (key, value) in row {
1462                    rows.push(TableRow {
1463                        key: key.clone(),
1464                        value: value.clone(),
1465                    });
1466                }
1467            }
1468
1469            let table = Table::new(&rows);
1470            Ok(output_or_capture(&table.to_string(), capture_output))
1471        }
1472        Value::Object(obj) => {
1473            // Check if object has too many fields
1474            if obj.len() > MAX_TABLE_ROWS {
1475                let msg = format!(
1476                    "Object too large: {} fields (max {} for table display)\nUse --format json or --jq to process the full data",
1477                    obj.len(),
1478                    MAX_TABLE_ROWS
1479                );
1480                return Ok(output_or_capture(&msg, capture_output));
1481            }
1482
1483            // Create a simple key-value table for objects
1484            let rows: Vec<KeyValue> = obj
1485                .iter()
1486                .map(|(key, value)| KeyValue {
1487                    key: key.clone(),
1488                    value: format_value_for_table(value),
1489                })
1490                .collect();
1491
1492            let table = Table::new(&rows);
1493            Ok(output_or_capture(&table.to_string(), capture_output))
1494        }
1495        _ => {
1496            // For primitive values, just print them
1497            let formatted = format_value_for_table(json_value);
1498            Ok(output_or_capture(&formatted, capture_output))
1499        }
1500    }
1501}
1502
1503/// Formats a JSON value for display in a table cell
1504fn format_value_for_table(value: &Value) -> String {
1505    match value {
1506        Value::Null => constants::NULL_VALUE.to_string(),
1507        Value::Bool(b) => b.to_string(),
1508        Value::Number(n) => n.to_string(),
1509        Value::String(s) => s.clone(),
1510        Value::Array(arr) => {
1511            if arr.len() <= 3 {
1512                format!(
1513                    "[{}]",
1514                    arr.iter()
1515                        .map(format_value_for_table)
1516                        .collect::<Vec<_>>()
1517                        .join(", ")
1518                )
1519            } else {
1520                format!("[{} items]", arr.len())
1521            }
1522        }
1523        Value::Object(obj) => {
1524            if obj.len() <= 2 {
1525                format!(
1526                    "{{{}}}",
1527                    obj.iter()
1528                        .map(|(k, v)| format!("{}: {}", k, format_value_for_table(v)))
1529                        .collect::<Vec<_>>()
1530                        .join(", ")
1531                )
1532            } else {
1533                format!("{{object with {} fields}}", obj.len())
1534            }
1535        }
1536    }
1537}
1538
1539/// Applies a JQ filter to the response text
1540///
1541/// # Errors
1542///
1543/// Returns an error if:
1544/// - The response text is not valid JSON
1545/// - The JQ filter expression is invalid
1546/// - The filter execution fails
1547pub fn apply_jq_filter(response_text: &str, filter: &str) -> Result<String, Error> {
1548    // Parse the response as JSON
1549    let json_value: Value = serde_json::from_str(response_text)
1550        .map_err(|e| Error::jq_filter_error(filter, format!("Response is not valid JSON: {e}")))?;
1551
1552    #[cfg(feature = "jq")]
1553    {
1554        // Use jaq v2.x (pure Rust implementation)
1555        use jaq_core::load::{Arena, File, Loader};
1556        use jaq_core::Compiler;
1557
1558        // Create the program from the filter string
1559        let program = File {
1560            code: filter,
1561            path: (),
1562        };
1563
1564        // Collect both standard library and JSON definitions into vectors
1565        // This avoids hanging issues with lazy iterator chains
1566        let defs: Vec<_> = jaq_std::defs().chain(jaq_json::defs()).collect();
1567        let funs: Vec<_> = jaq_std::funs().chain(jaq_json::funs()).collect();
1568
1569        // Create loader with both standard library and JSON definitions
1570        let loader = Loader::new(defs);
1571        let arena = Arena::default();
1572
1573        // Parse the filter
1574        let modules = match loader.load(&arena, program) {
1575            Ok(modules) => modules,
1576            Err(errs) => {
1577                return Err(Error::jq_filter_error(
1578                    filter,
1579                    format!("Parse error: {:?}", errs),
1580                ));
1581            }
1582        };
1583
1584        // Compile the filter with both standard library and JSON functions
1585        let filter_fn = match Compiler::default().with_funs(funs).compile(modules) {
1586            Ok(filter) => filter,
1587            Err(errs) => {
1588                return Err(Error::jq_filter_error(
1589                    filter,
1590                    format!("Compilation error: {:?}", errs),
1591                ));
1592            }
1593        };
1594
1595        // Convert serde_json::Value to jaq Val
1596        let jaq_value = Val::from(json_value);
1597
1598        // Execute the filter
1599        let inputs = RcIter::new(core::iter::empty());
1600        let ctx = Ctx::new([], &inputs);
1601
1602        // Run the filter on the input value
1603        let output = filter_fn.run((ctx, jaq_value));
1604
1605        // Collect all results
1606        let results: Result<Vec<Val>, _> = output.collect();
1607
1608        match results {
1609            Ok(vals) => {
1610                if vals.is_empty() {
1611                    return Ok(constants::NULL_VALUE.to_string());
1612                }
1613
1614                if vals.len() == 1 {
1615                    // Single result - convert back to JSON
1616                    let json_val = serde_json::Value::from(vals[0].clone());
1617                    return serde_json::to_string_pretty(&json_val).map_err(|e| {
1618                        Error::serialization_error(format!("Failed to serialize result: {e}"))
1619                    });
1620                }
1621
1622                // Multiple results - return as JSON array
1623                let json_vals: Vec<Value> = vals.into_iter().map(serde_json::Value::from).collect();
1624                let array = Value::Array(json_vals);
1625                serde_json::to_string_pretty(&array).map_err(|e| {
1626                    Error::serialization_error(format!("Failed to serialize results: {e}"))
1627                })
1628            }
1629            Err(e) => Err(Error::jq_filter_error(
1630                format!("{:?}", filter),
1631                format!("Filter execution error: {e}"),
1632            )),
1633        }
1634    }
1635
1636    #[cfg(not(feature = "jq"))]
1637    {
1638        // Basic JQ-like functionality without full jq library
1639        apply_basic_jq_filter(&json_value, filter)
1640    }
1641}
1642
1643#[cfg(not(feature = "jq"))]
1644/// Basic JQ-like functionality for common cases
1645fn apply_basic_jq_filter(json_value: &Value, filter: &str) -> Result<String, Error> {
1646    // Check if the filter uses advanced features
1647    let uses_advanced_features = filter.contains('[')
1648        || filter.contains(']')
1649        || filter.contains('|')
1650        || filter.contains('(')
1651        || filter.contains(')')
1652        || filter.contains("select")
1653        || filter.contains("map")
1654        || filter.contains("length");
1655
1656    if uses_advanced_features {
1657        // ast-grep-ignore: no-println
1658        eprintln!(
1659            "{} Advanced JQ features require building with --features jq",
1660            crate::constants::MSG_WARNING_PREFIX
1661        );
1662        // ast-grep-ignore: no-println
1663        eprintln!("         Currently only basic field access is supported (e.g., '.field', '.nested.field')");
1664        // ast-grep-ignore: no-println
1665        eprintln!("         To enable full JQ support: cargo install aperture-cli --features jq");
1666    }
1667
1668    let result = match filter {
1669        "." => json_value.clone(),
1670        ".[]" => {
1671            // Handle array iteration
1672            match json_value {
1673                Value::Array(arr) => {
1674                    // Return array elements as a JSON array
1675                    Value::Array(arr.clone())
1676                }
1677                Value::Object(obj) => {
1678                    // Return object values as an array
1679                    Value::Array(obj.values().cloned().collect())
1680                }
1681                _ => Value::Null,
1682            }
1683        }
1684        ".length" => {
1685            // Handle length operation
1686            match json_value {
1687                Value::Array(arr) => Value::Number(arr.len().into()),
1688                Value::Object(obj) => Value::Number(obj.len().into()),
1689                Value::String(s) => Value::Number(s.len().into()),
1690                _ => Value::Null,
1691            }
1692        }
1693        filter if filter.starts_with(".[].") => {
1694            // Handle array map like .[].name
1695            let field_path = &filter[4..]; // Remove ".[].""
1696            match json_value {
1697                Value::Array(arr) => {
1698                    let mapped: Vec<Value> = arr
1699                        .iter()
1700                        .map(|item| get_nested_field(item, field_path))
1701                        .collect();
1702                    Value::Array(mapped)
1703                }
1704                _ => Value::Null,
1705            }
1706        }
1707        filter if filter.starts_with('.') => {
1708            // Handle simple field access like .name, .metadata.role
1709            let field_path = &filter[1..]; // Remove the leading dot
1710            get_nested_field(json_value, field_path)
1711        }
1712        _ => {
1713            return Err(Error::jq_filter_error(filter, "Unsupported JQ filter. Only basic field access like '.name' or '.metadata.role' is supported without the full jq library."));
1714        }
1715    };
1716
1717    serde_json::to_string_pretty(&result).map_err(|e| {
1718        Error::serialization_error(format!("Failed to serialize filtered result: {e}"))
1719    })
1720}
1721
1722#[cfg(not(feature = "jq"))]
1723/// Get a nested field from JSON using dot notation
1724fn get_nested_field(json_value: &Value, field_path: &str) -> Value {
1725    let parts: Vec<&str> = field_path.split('.').collect();
1726    let mut current = json_value;
1727
1728    for part in parts {
1729        if part.is_empty() {
1730            continue;
1731        }
1732
1733        // Handle array index notation like [0]
1734        if part.starts_with('[') && part.ends_with(']') {
1735            let index_str = &part[1..part.len() - 1];
1736            let Ok(index) = index_str.parse::<usize>() else {
1737                return Value::Null;
1738            };
1739
1740            match current {
1741                Value::Array(arr) => {
1742                    let Some(item) = arr.get(index) else {
1743                        return Value::Null;
1744                    };
1745                    current = item;
1746                }
1747                _ => return Value::Null,
1748            }
1749            continue;
1750        }
1751
1752        match current {
1753            Value::Object(obj) => {
1754                if let Some(field) = obj.get(part) {
1755                    current = field;
1756                } else {
1757                    return Value::Null;
1758                }
1759            }
1760            Value::Array(arr) => {
1761                // Handle numeric string as array index
1762                let Ok(index) = part.parse::<usize>() else {
1763                    return Value::Null;
1764                };
1765
1766                let Some(item) = arr.get(index) else {
1767                    return Value::Null;
1768                };
1769                current = item;
1770            }
1771            _ => return Value::Null,
1772        }
1773    }
1774
1775    current.clone()
1776}
1777
1778#[cfg(test)]
1779mod tests {
1780    use super::*;
1781
1782    #[test]
1783    fn test_apply_jq_filter_simple_field_access() {
1784        let json = r#"{"name": "Alice", "age": 30}"#;
1785        let result = apply_jq_filter(json, ".name").unwrap();
1786        let parsed: Value = serde_json::from_str(&result).unwrap();
1787        assert_eq!(parsed, serde_json::json!("Alice"));
1788    }
1789
1790    #[test]
1791    fn test_apply_jq_filter_nested_field_access() {
1792        let json = r#"{"user": {"name": "Bob", "id": 123}}"#;
1793        let result = apply_jq_filter(json, ".user.name").unwrap();
1794        let parsed: Value = serde_json::from_str(&result).unwrap();
1795        assert_eq!(parsed, serde_json::json!("Bob"));
1796    }
1797
1798    #[cfg(feature = "jq")]
1799    #[test]
1800    fn test_apply_jq_filter_array_index() {
1801        let json = r#"{"items": ["first", "second", "third"]}"#;
1802        let result = apply_jq_filter(json, ".items[1]").unwrap();
1803        let parsed: Value = serde_json::from_str(&result).unwrap();
1804        assert_eq!(parsed, serde_json::json!("second"));
1805    }
1806
1807    #[cfg(feature = "jq")]
1808    #[test]
1809    fn test_apply_jq_filter_array_iteration() {
1810        let json = r#"[{"id": 1}, {"id": 2}, {"id": 3}]"#;
1811        let result = apply_jq_filter(json, ".[].id").unwrap();
1812        let parsed: Value = serde_json::from_str(&result).unwrap();
1813        // JQ returns multiple results as an array
1814        assert_eq!(parsed, serde_json::json!([1, 2, 3]));
1815    }
1816
1817    #[cfg(feature = "jq")]
1818    #[test]
1819    fn test_apply_jq_filter_complex_expression() {
1820        let json = r#"{"users": [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]}"#;
1821        let result = apply_jq_filter(json, ".users | map(.name)").unwrap();
1822        let parsed: Value = serde_json::from_str(&result).unwrap();
1823        assert_eq!(parsed, serde_json::json!(["Alice", "Bob"]));
1824    }
1825
1826    #[cfg(feature = "jq")]
1827    #[test]
1828    fn test_apply_jq_filter_select() {
1829        let json =
1830            r#"[{"id": 1, "active": true}, {"id": 2, "active": false}, {"id": 3, "active": true}]"#;
1831        let result = apply_jq_filter(json, "[.[] | select(.active)]").unwrap();
1832        let parsed: Value = serde_json::from_str(&result).unwrap();
1833        assert_eq!(
1834            parsed,
1835            serde_json::json!([{"id": 1, "active": true}, {"id": 3, "active": true}])
1836        );
1837    }
1838
1839    #[test]
1840    fn test_apply_jq_filter_invalid_json() {
1841        let json = "not valid json";
1842        let result = apply_jq_filter(json, ".field");
1843        assert!(result.is_err());
1844        if let Err(err) = result {
1845            let error_msg = err.to_string();
1846            assert!(error_msg.contains("JQ filter error"));
1847            assert!(error_msg.contains(".field"));
1848            assert!(error_msg.contains("Response is not valid JSON"));
1849        } else {
1850            panic!("Expected error");
1851        }
1852    }
1853
1854    #[cfg(feature = "jq")]
1855    #[test]
1856    fn test_apply_jq_filter_invalid_expression() {
1857        let json = r#"{"name": "test"}"#;
1858        let result = apply_jq_filter(json, "invalid..expression");
1859        assert!(result.is_err());
1860        if let Err(err) = result {
1861            let error_msg = err.to_string();
1862            assert!(error_msg.contains("JQ filter error") || error_msg.contains("Parse error"));
1863            assert!(error_msg.contains("invalid..expression"));
1864        } else {
1865            panic!("Expected error");
1866        }
1867    }
1868
1869    #[test]
1870    fn test_apply_jq_filter_null_result() {
1871        let json = r#"{"name": "test"}"#;
1872        let result = apply_jq_filter(json, ".missing_field").unwrap();
1873        let parsed: Value = serde_json::from_str(&result).unwrap();
1874        assert_eq!(parsed, serde_json::json!(null));
1875    }
1876
1877    #[cfg(feature = "jq")]
1878    #[test]
1879    fn test_apply_jq_filter_arithmetic() {
1880        let json = r#"{"x": 10, "y": 20}"#;
1881        let result = apply_jq_filter(json, ".x + .y").unwrap();
1882        let parsed: Value = serde_json::from_str(&result).unwrap();
1883        assert_eq!(parsed, serde_json::json!(30));
1884    }
1885
1886    #[cfg(feature = "jq")]
1887    #[test]
1888    fn test_apply_jq_filter_string_concatenation() {
1889        let json = r#"{"first": "Hello", "second": "World"}"#;
1890        let result = apply_jq_filter(json, r#".first + " " + .second"#).unwrap();
1891        let parsed: Value = serde_json::from_str(&result).unwrap();
1892        assert_eq!(parsed, serde_json::json!("Hello World"));
1893    }
1894
1895    #[cfg(feature = "jq")]
1896    #[test]
1897    fn test_apply_jq_filter_length() {
1898        let json = r#"{"items": [1, 2, 3, 4, 5]}"#;
1899        let result = apply_jq_filter(json, ".items | length").unwrap();
1900        let parsed: Value = serde_json::from_str(&result).unwrap();
1901        assert_eq!(parsed, serde_json::json!(5));
1902    }
1903}