Skip to main content

aperture_cli/
agent.rs

1use crate::cache::models::{
2    CachedApertureSecret, CachedCommand, CachedParameter, CachedRequestBody, CachedSpec,
3};
4use crate::config::models::GlobalConfig;
5use crate::config::url_resolver::BaseUrlResolver;
6use crate::constants;
7use crate::error::Error;
8use crate::spec::{resolve_parameter_reference, resolve_schema_reference};
9use crate::utils::to_kebab_case;
10use openapiv3::{OpenAPI, Operation, Parameter as OpenApiParameter, ReferenceOr, SecurityScheme};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14/// Type alias for schema information extracted from a parameter
15/// Returns: (`schema_type`, `format`, `default_value`, `enum_values`, `example`)
16type ParameterSchemaInfo = (
17    Option<String>,
18    Option<String>,
19    Option<String>,
20    Vec<String>,
21    Option<String>,
22);
23
24/// JSON manifest describing all available commands and parameters for an API context.
25/// This is output when the `--describe-json` flag is used.
26#[derive(Debug, Serialize, Deserialize)]
27pub struct ApiCapabilityManifest {
28    /// Basic API metadata
29    pub api: ApiInfo,
30    /// Endpoint availability statistics
31    pub endpoints: EndpointStatistics,
32    /// Available command groups organized by tags
33    pub commands: HashMap<String, Vec<CommandInfo>>,
34    /// Security schemes available for this API
35    pub security_schemes: HashMap<String, SecuritySchemeInfo>,
36    /// Batch processing capabilities
37    pub batch: BatchCapabilityInfo,
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41pub struct ApiInfo {
42    /// API name
43    pub name: String,
44    /// API version
45    pub version: String,
46    /// API description
47    pub description: Option<String>,
48    /// Base URL for the API
49    pub base_url: String,
50}
51
52/// Statistics about endpoint availability
53#[derive(Debug, Serialize, Deserialize)]
54pub struct EndpointStatistics {
55    /// Total number of endpoints in the `OpenAPI` spec
56    pub total: usize,
57    /// Number of endpoints available for use
58    pub available: usize,
59    /// Number of endpoints skipped due to unsupported features
60    pub skipped: usize,
61}
62
63/// Describes the batch processing capabilities available via `--batch-file`.
64///
65/// This section enables agents to auto-discover the batch file schema and
66/// dependent workflow features without consulting external documentation.
67#[derive(Debug, Serialize, Deserialize)]
68pub struct BatchCapabilityInfo {
69    /// Accepted batch file formats
70    pub file_formats: Vec<String>,
71    /// Schema for a single batch operation entry
72    pub operation_schema: BatchOperationSchema,
73    /// Dependent workflow capabilities (variable capture, interpolation, ordering)
74    pub dependent_workflows: DependentWorkflowInfo,
75}
76
77/// Schema description for a single operation within a batch file.
78#[derive(Debug, Serialize, Deserialize)]
79pub struct BatchOperationSchema {
80    /// Field descriptions for batch operations
81    pub fields: Vec<BatchFieldInfo>,
82}
83
84/// A single field in the batch operation schema.
85#[derive(Debug, Serialize, Deserialize)]
86pub struct BatchFieldInfo {
87    /// Field name as it appears in the batch file
88    pub name: String,
89    /// Type description (e.g., "string", "string[]", "map<string, string>")
90    #[serde(rename = "type")]
91    pub field_type: String,
92    /// Whether the field is required
93    pub required: bool,
94    /// Human/agent-readable description
95    pub description: String,
96}
97
98/// Describes the dependent workflow system: capture, interpolation, and ordering.
99#[derive(Debug, Serialize, Deserialize)]
100pub struct DependentWorkflowInfo {
101    /// Interpolation syntax for referencing captured variables
102    pub interpolation_syntax: String,
103    /// How the execution path is selected
104    pub execution_modes: ExecutionModeInfo,
105    /// Details about the dependent (sequential) execution path
106    pub dependent_execution: DependentExecutionInfo,
107}
108
109/// Describes the two execution modes and when each is selected.
110#[derive(Debug, Serialize, Deserialize)]
111pub struct ExecutionModeInfo {
112    /// When the concurrent (parallel) path is used
113    pub concurrent: String,
114    /// When the dependent (sequential) path is used
115    pub dependent: String,
116}
117
118/// Details about dependent execution behavior.
119#[derive(Debug, Serialize, Deserialize)]
120pub struct DependentExecutionInfo {
121    /// Ordering algorithm used
122    pub ordering: String,
123    /// What happens when an operation fails
124    pub failure_mode: String,
125    /// Whether `{{variable}}` references infer dependencies automatically
126    pub implicit_dependencies: bool,
127    /// Variable types supported by the capture/interpolation system
128    pub variable_types: VariableTypeInfo,
129}
130
131/// Describes the two variable types in the capture system.
132#[derive(Debug, Serialize, Deserialize)]
133pub struct VariableTypeInfo {
134    /// How scalar variables (from `capture`) behave
135    pub scalar: String,
136    /// How list variables (from `capture_append`) behave
137    pub list: String,
138}
139
140/// Returns the field descriptors for the batch operation schema.
141fn batch_operation_fields() -> Vec<BatchFieldInfo> {
142    vec![
143        BatchFieldInfo {
144            name: "id".into(),
145            field_type: "string".into(),
146            required: false,
147            description: "Unique identifier. Required when using capture, capture_append, or depends_on.".into(),
148        },
149        BatchFieldInfo {
150            name: "args".into(),
151            field_type: "string[]".into(),
152            required: true,
153            description: "Command arguments (e.g. [\"users\", \"create-user\", \"--body\", \"{...}\"] or [\"users\", \"create-user\", \"--body-file\", \"/path/to/body.json\"]).".into(),
154        },
155        BatchFieldInfo {
156            name: "description".into(),
157            field_type: "string".into(),
158            required: false,
159            description: "Human-readable description of this operation.".into(),
160        },
161        BatchFieldInfo {
162            name: "headers".into(),
163            field_type: "map<string, string>".into(),
164            required: false,
165            description: "Custom HTTP headers for this operation.".into(),
166        },
167        BatchFieldInfo {
168            name: "capture".into(),
169            field_type: "map<string, string>".into(),
170            required: false,
171            description: "Extract scalar values from the response via JQ queries. Maps variable_name → jq_query (e.g. {\"user_id\": \".id\"}). Captured values are available as {{variable_name}} in subsequent operations.".into(),
172        },
173        BatchFieldInfo {
174            name: "capture_append".into(),
175            field_type: "map<string, string>".into(),
176            required: false,
177            description: "Append extracted values to a named list via JQ queries. Multiple operations can append to the same list. The list interpolates as a JSON array literal (e.g. [\"a\",\"b\"]).".into(),
178        },
179        BatchFieldInfo {
180            name: "depends_on".into(),
181            field_type: "string[]".into(),
182            required: false,
183            description: "Explicit dependency on other operations by id. This operation waits until all listed operations have completed. Dependencies can also be inferred from {{variable}} usage.".into(),
184        },
185        BatchFieldInfo {
186            name: "use_cache".into(),
187            field_type: "boolean".into(),
188            required: false,
189            description: "Enable response caching for this operation.".into(),
190        },
191        BatchFieldInfo {
192            name: "retry".into(),
193            field_type: "integer".into(),
194            required: false,
195            description: "Maximum retry attempts for this operation.".into(),
196        },
197        BatchFieldInfo {
198            name: "retry_delay".into(),
199            field_type: "string".into(),
200            required: false,
201            description: "Initial retry delay (e.g. \"500ms\", \"1s\").".into(),
202        },
203        BatchFieldInfo {
204            name: "retry_max_delay".into(),
205            field_type: "string".into(),
206            required: false,
207            description: "Maximum retry delay cap (e.g. \"30s\", \"1m\").".into(),
208        },
209        BatchFieldInfo {
210            name: "force_retry".into(),
211            field_type: "boolean".into(),
212            required: false,
213            description: "Allow retrying non-idempotent requests without an idempotency key.".into(),
214        },
215        BatchFieldInfo {
216            name: "body_file".into(),
217            field_type: "string".into(),
218            required: false,
219            description: "Read the request body from this file path instead of embedding JSON in args. Equivalent to --body-file in args; avoids quoting issues with large or complex JSON payloads. Mutually exclusive with --body or --body-file entries in args.".into(),
220        },
221    ]
222}
223
224/// Builds the static `BatchCapabilityInfo` included in every capability manifest.
225fn build_batch_capability_info() -> BatchCapabilityInfo {
226    BatchCapabilityInfo {
227        file_formats: vec!["json".into(), "yaml".into()],
228        operation_schema: BatchOperationSchema {
229            fields: batch_operation_fields(),
230        },
231        dependent_workflows: DependentWorkflowInfo {
232            interpolation_syntax: "{{variable_name}}".into(),
233            execution_modes: ExecutionModeInfo {
234                concurrent: "Used when no operation has capture, capture_append, or depends_on. Operations run in parallel with concurrency and rate-limit controls.".into(),
235                dependent: "Used when any operation has capture, capture_append, or depends_on. Operations run sequentially in topological order with variable interpolation.".into(),
236            },
237            dependent_execution: DependentExecutionInfo {
238                ordering: "Topological sort via Kahn's algorithm. Operations without dependencies preserve original file order.".into(),
239                failure_mode: "Atomic: halts on first failure. Subsequent operations are marked as skipped.".into(),
240                implicit_dependencies: true,
241                variable_types: VariableTypeInfo {
242                    scalar: "From capture — {{name}} interpolates as the extracted string value.".into(),
243                    list: "From capture_append — {{name}} interpolates as a JSON array literal (e.g. [\"a\",\"b\"]).".into(),
244                },
245            },
246        },
247    }
248}
249
250#[derive(Debug, Serialize, Deserialize)]
251pub struct CommandInfo {
252    /// Command name (kebab-case operation ID)
253    pub name: String,
254    /// HTTP method
255    pub method: String,
256    /// API path with parameters
257    pub path: String,
258    /// Operation description
259    pub description: Option<String>,
260    /// Operation summary
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub summary: Option<String>,
263    /// Operation ID from the `OpenAPI` spec
264    pub operation_id: String,
265    /// Parameters for this operation
266    pub parameters: Vec<ParameterInfo>,
267    /// Request body information if applicable
268    pub request_body: Option<RequestBodyInfo>,
269    /// Security requirements for this operation
270    #[serde(skip_serializing_if = "Vec::is_empty", default)]
271    pub security_requirements: Vec<String>,
272    /// Tags associated with this operation (kebab-case)
273    #[serde(skip_serializing_if = "Vec::is_empty", default)]
274    pub tags: Vec<String>,
275    /// Original tag names from the `OpenAPI` spec (before kebab-case conversion)
276    #[serde(skip_serializing_if = "Vec::is_empty", default)]
277    pub original_tags: Vec<String>,
278    /// Whether this operation is deprecated
279    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
280    pub deprecated: bool,
281    /// External documentation URL
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub external_docs_url: Option<String>,
284    /// Response schema for successful responses (200/201/204)
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub response_schema: Option<ResponseSchemaInfo>,
287    /// Display name override for the command group (from command mapping)
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub display_group: Option<String>,
290    /// Display name override for the subcommand (from command mapping)
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub display_name: Option<String>,
293    /// Additional subcommand aliases (from command mapping)
294    #[serde(skip_serializing_if = "Vec::is_empty", default)]
295    pub aliases: Vec<String>,
296    /// Whether this command is hidden from help output (from command mapping)
297    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
298    pub hidden: bool,
299    /// Pagination capability for this operation
300    pub pagination: PaginationManifestInfo,
301}
302
303#[derive(Debug, Serialize, Deserialize)]
304pub struct ParameterInfo {
305    /// Parameter name
306    pub name: String,
307    /// Parameter location (path, query, header)
308    pub location: String,
309    /// Whether the parameter is required
310    pub required: bool,
311    /// Parameter type
312    pub param_type: String,
313    /// Parameter description
314    pub description: Option<String>,
315    /// Parameter format (e.g., int32, int64, date-time)
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub format: Option<String>,
318    /// Default value if specified
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub default_value: Option<String>,
321    /// Enumeration of valid values
322    #[serde(skip_serializing_if = "Vec::is_empty", default)]
323    pub enum_values: Vec<String>,
324    /// Example value
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub example: Option<String>,
327}
328
329#[derive(Debug, Serialize, Deserialize)]
330pub struct RequestBodyInfo {
331    /// Whether the request body is required
332    pub required: bool,
333    /// Content type (e.g., "application/json")
334    pub content_type: String,
335    /// Description of the request body
336    pub description: Option<String>,
337    /// Example of the request body
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub example: Option<String>,
340}
341
342/// Pagination capability description for a single command in the manifest.
343///
344/// Agents use this to know whether `--auto-paginate` will work on this
345/// operation and how it will iterate.
346#[derive(Debug, Serialize, Deserialize)]
347pub struct PaginationManifestInfo {
348    /// Whether pagination is supported for this operation.
349    pub supported: bool,
350    /// The detected strategy: `"cursor"`, `"offset"`, `"link-header"`, or `"none"`.
351    pub strategy: String,
352    /// Response body field carrying the next cursor (cursor strategy only).
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub cursor_field: Option<String>,
355    /// Query parameter injected with the cursor value (cursor strategy only).
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub cursor_param: Option<String>,
358    /// Query parameter incremented per page (offset strategy only).
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub page_param: Option<String>,
361    /// Query parameter holding the page size (offset strategy only).
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub limit_param: Option<String>,
364}
365
366impl Default for PaginationManifestInfo {
367    fn default() -> Self {
368        Self {
369            supported: false,
370            strategy: crate::constants::PAGINATION_STRATEGY_NONE.to_string(),
371            cursor_field: None,
372            cursor_param: None,
373            page_param: None,
374            limit_param: None,
375        }
376    }
377}
378
379impl PaginationManifestInfo {
380    /// Converts a cached `PaginationInfo` into manifest form.
381    fn from_cached(info: &crate::cache::models::PaginationInfo) -> Self {
382        use crate::cache::models::PaginationStrategy;
383        use crate::constants;
384
385        let (supported, strategy) = match info.strategy {
386            PaginationStrategy::None => (false, constants::PAGINATION_STRATEGY_NONE),
387            PaginationStrategy::Cursor => (true, constants::PAGINATION_STRATEGY_CURSOR),
388            PaginationStrategy::Offset => (true, constants::PAGINATION_STRATEGY_OFFSET),
389            PaginationStrategy::LinkHeader => (true, constants::PAGINATION_STRATEGY_LINK_HEADER),
390        };
391
392        Self {
393            supported,
394            strategy: strategy.to_string(),
395            cursor_field: info.cursor_field.clone(),
396            cursor_param: info.cursor_param.clone(),
397            page_param: info.page_param.clone(),
398            limit_param: info.limit_param.clone(),
399        }
400    }
401}
402
403/// Response schema information for successful responses (200/201/204)
404///
405/// This struct provides schema information extracted from `OpenAPI` response definitions,
406/// enabling AI agents to understand the expected response structure before execution.
407///
408/// # Current Limitations
409///
410/// 1. **Response references not resolved**: If a response is defined as a reference
411///    (e.g., `$ref: '#/components/responses/UserResponse'`), the schema will not be
412///    extracted. Only inline response definitions are processed.
413///
414/// 2. **Nested schema references not resolved**: While top-level schema references
415///    (e.g., `$ref: '#/components/schemas/User'`) are resolved, nested references
416///    within object properties remain as `$ref` objects in the output. For example:
417///    ```json
418///    {
419///      "type": "object",
420///      "properties": {
421///        "user": { "$ref": "#/components/schemas/User" }  // Not resolved
422///      }
423///    }
424///    ```
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct ResponseSchemaInfo {
427    /// Content type (e.g., "application/json")
428    pub content_type: String,
429    /// JSON Schema representation of the response body
430    ///
431    /// Note: This schema may contain unresolved `$ref` objects for nested references.
432    /// Only the top-level schema reference is resolved.
433    pub schema: serde_json::Value,
434    /// Example response if available from the spec
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub example: Option<serde_json::Value>,
437}
438
439/// Detailed, parsable security scheme description
440#[derive(Debug, Serialize, Deserialize)]
441pub struct SecuritySchemeInfo {
442    /// Type of security scheme (http, apiKey)
443    #[serde(rename = "type")]
444    pub scheme_type: String,
445    /// Optional description of the security scheme
446    pub description: Option<String>,
447    /// Detailed scheme configuration
448    #[serde(flatten)]
449    pub details: SecuritySchemeDetails,
450    /// Aperture-specific secret mapping
451    #[serde(rename = "x-aperture-secret", skip_serializing_if = "Option::is_none")]
452    pub aperture_secret: Option<CachedApertureSecret>,
453}
454
455/// Detailed security scheme configuration
456#[derive(Debug, Serialize, Deserialize)]
457#[serde(tag = "scheme", rename_all = "camelCase")]
458pub enum SecuritySchemeDetails {
459    /// HTTP authentication scheme (e.g., bearer, basic)
460    #[serde(rename = "bearer")]
461    HttpBearer {
462        /// Optional bearer token format
463        #[serde(skip_serializing_if = "Option::is_none")]
464        bearer_format: Option<String>,
465    },
466    /// HTTP basic authentication
467    #[serde(rename = "basic")]
468    HttpBasic,
469    /// API Key authentication
470    #[serde(rename = "apiKey")]
471    ApiKey {
472        /// Location of the API key (header, query, cookie)
473        #[serde(rename = "in")]
474        location: String,
475        /// Name of the parameter/header
476        name: String,
477    },
478}
479
480/// Generates a capability manifest from an `OpenAPI` specification.
481///
482/// This function creates a comprehensive JSON description of all available commands,
483/// parameters, and security requirements directly from the original `OpenAPI` spec,
484/// preserving all metadata that might be lost in the cached representation.
485///
486/// # Arguments
487/// * `api_name` - The name of the API context
488/// * `spec` - The original `OpenAPI` specification
489/// * `global_config` - Optional global configuration for URL resolution
490///
491/// # Returns
492/// * `Ok(String)` - JSON-formatted capability manifest
493/// * `Err(Error)` - If JSON serialization fails
494///
495/// # Errors
496/// Returns an error if JSON serialization fails
497pub fn generate_capability_manifest_from_openapi(
498    api_name: &str,
499    spec: &OpenAPI,
500    cached_spec: &CachedSpec,
501    global_config: Option<&GlobalConfig>,
502) -> Result<String, Error> {
503    // First, convert the OpenAPI spec to a temporary CachedSpec for URL resolution
504    let base_url = spec.servers.first().map(|s| s.url.clone());
505    let servers: Vec<String> = spec.servers.iter().map(|s| s.url.clone()).collect();
506
507    let temp_cached_spec = CachedSpec {
508        cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
509        name: api_name.to_string(),
510        version: spec.info.version.clone(),
511        commands: vec![], // We'll generate commands directly from OpenAPI
512        base_url,
513        servers,
514        security_schemes: HashMap::new(), // We'll extract these directly too
515        skipped_endpoints: vec![],        // No endpoints are skipped for agent manifest
516        server_variables: HashMap::new(), // We'll extract these later if needed
517    };
518
519    // Resolve base URL using the same priority hierarchy as executor
520    let resolver = BaseUrlResolver::new(&temp_cached_spec);
521    let resolver = if let Some(config) = global_config {
522        resolver.with_global_config(config)
523    } else {
524        resolver
525    };
526    let resolved_base_url = resolver.resolve(None);
527
528    // Extract commands directly from OpenAPI spec, excluding skipped endpoints
529    let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
530
531    // Build a set of skipped (path, method) pairs for efficient lookup
532    let skipped_set: std::collections::HashSet<(&str, &str)> = cached_spec
533        .skipped_endpoints
534        .iter()
535        .map(|ep| (ep.path.as_str(), ep.method.as_str()))
536        .collect();
537
538    for (path, path_item) in &spec.paths.paths {
539        let ReferenceOr::Item(item) = path_item else {
540            continue;
541        };
542
543        // Process each HTTP method
544        for (method, operation) in crate::spec::http_methods_iter(item) {
545            let Some(op) = operation else {
546                continue;
547            };
548
549            // Skip endpoints that were filtered out during caching
550            if skipped_set.contains(&(path.as_str(), method.to_uppercase().as_str())) {
551                continue;
552            }
553
554            let command_info =
555                convert_openapi_operation_to_info(method, path, op, spec, spec.security.as_ref());
556
557            // Group by first tag or "default", converted to kebab-case
558            let group_name = op.tags.first().map_or_else(
559                || constants::DEFAULT_GROUP.to_string(),
560                |tag| to_kebab_case(tag),
561            );
562
563            command_groups
564                .entry(group_name)
565                .or_default()
566                .push(command_info);
567        }
568    }
569
570    // Overlay command mapping fields from the cached spec.
571    //
572    // The manifest is generated from the raw OpenAPI spec for richer metadata,
573    // but command mappings (display_group, display_name, aliases, hidden) are
574    // applied at the cache layer during `config add`/`config reinit`. We merge
575    // these fields back into the manifest so agents see the effective CLI names.
576    let mapping_index: HashMap<&str, &CachedCommand> = cached_spec
577        .commands
578        .iter()
579        .map(|c| (c.operation_id.as_str(), c))
580        .collect();
581
582    // We also need to re-group commands when display_group overrides are present,
583    // since the original grouping uses the raw tag name.
584    let mut regrouped: HashMap<String, Vec<CommandInfo>> = HashMap::new();
585    for (_group, commands) in command_groups {
586        for mut cmd_info in commands {
587            if let Some(cached_cmd) = mapping_index.get(cmd_info.operation_id.as_str()) {
588                cmd_info.display_group.clone_from(&cached_cmd.display_group);
589                cmd_info.display_name.clone_from(&cached_cmd.display_name);
590                cmd_info.aliases.clone_from(&cached_cmd.aliases);
591                cmd_info.hidden = cached_cmd.hidden;
592                cmd_info.pagination = PaginationManifestInfo::from_cached(&cached_cmd.pagination);
593            }
594
595            // Determine the effective group key for manifest output
596            let effective_group = cmd_info.display_group.as_ref().map_or_else(
597                || {
598                    cmd_info.original_tags.first().map_or_else(
599                        || constants::DEFAULT_GROUP.to_string(),
600                        |tag| to_kebab_case(tag),
601                    )
602                },
603                |g| to_kebab_case(g),
604            );
605
606            regrouped.entry(effective_group).or_default().push(cmd_info);
607        }
608    }
609
610    // Extract security schemes directly from OpenAPI
611    let security_schemes = extract_security_schemes_from_openapi(spec);
612
613    // Compute endpoint statistics from the cached spec
614    let skipped = cached_spec.skipped_endpoints.len();
615    let available = cached_spec.commands.len();
616    let total = available + skipped;
617
618    // Create the manifest
619    let manifest = ApiCapabilityManifest {
620        api: ApiInfo {
621            name: spec.info.title.clone(),
622            version: spec.info.version.clone(),
623            description: spec.info.description.clone(),
624            base_url: resolved_base_url,
625        },
626        endpoints: EndpointStatistics {
627            total,
628            available,
629            skipped,
630        },
631        commands: regrouped,
632        security_schemes,
633        batch: build_batch_capability_info(),
634    };
635
636    // Serialize to JSON
637    serde_json::to_string_pretty(&manifest)
638        .map_err(|e| Error::serialization_error(format!("Failed to serialize agent manifest: {e}")))
639}
640
641/// Generates a capability manifest from a cached API specification.
642///
643/// This function creates a comprehensive JSON description of all available commands,
644/// parameters, and security requirements for the given API context.
645///
646/// # Arguments
647/// * `spec` - The cached API specification
648/// * `global_config` - Optional global configuration for URL resolution
649///
650/// # Returns
651/// * `Ok(String)` - JSON-formatted capability manifest
652/// * `Err(Error)` - If JSON serialization fails
653///
654/// # Errors
655/// Returns an error if JSON serialization fails
656pub fn generate_capability_manifest(
657    spec: &CachedSpec,
658    global_config: Option<&GlobalConfig>,
659) -> Result<String, Error> {
660    let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
661
662    // Group commands by their tag (namespace) and convert to CommandInfo
663    for cached_command in &spec.commands {
664        let group_name = if cached_command.name.is_empty() {
665            constants::DEFAULT_GROUP.to_string()
666        } else {
667            to_kebab_case(&cached_command.name)
668        };
669
670        let command_info = convert_cached_command_to_info(cached_command);
671        command_groups
672            .entry(group_name)
673            .or_default()
674            .push(command_info);
675    }
676
677    // Resolve base URL using the same priority hierarchy as executor
678    let resolver = BaseUrlResolver::new(spec);
679    let resolver = if let Some(config) = global_config {
680        resolver.with_global_config(config)
681    } else {
682        resolver
683    };
684    let base_url = resolver.resolve(None);
685
686    // Compute endpoint statistics
687    let skipped = spec.skipped_endpoints.len();
688    let available = spec.commands.len();
689    let total = available + skipped;
690
691    // Create the manifest
692    let manifest = ApiCapabilityManifest {
693        api: ApiInfo {
694            name: spec.name.clone(),
695            version: spec.version.clone(),
696            description: None, // Not available in cached spec
697            base_url,
698        },
699        endpoints: EndpointStatistics {
700            total,
701            available,
702            skipped,
703        },
704        commands: command_groups,
705        security_schemes: extract_security_schemes(spec),
706        batch: build_batch_capability_info(),
707    };
708
709    // Serialize to JSON
710    serde_json::to_string_pretty(&manifest)
711        .map_err(|e| Error::serialization_error(format!("Failed to serialize agent manifest: {e}")))
712}
713
714/// Converts a `CachedCommand` to `CommandInfo` for the manifest
715fn convert_cached_command_to_info(cached_command: &CachedCommand) -> CommandInfo {
716    let command_name = if cached_command.operation_id.is_empty() {
717        cached_command.method.to_lowercase()
718    } else {
719        to_kebab_case(&cached_command.operation_id)
720    };
721
722    let parameters: Vec<ParameterInfo> = cached_command
723        .parameters
724        .iter()
725        .map(convert_cached_parameter_to_info)
726        .collect();
727
728    let request_body = cached_command
729        .request_body
730        .as_ref()
731        .map(convert_cached_request_body_to_info);
732
733    // Extract response schema from cached responses
734    let response_schema = extract_response_schema_from_cached(&cached_command.responses);
735
736    CommandInfo {
737        name: command_name,
738        method: cached_command.method.clone(),
739        path: cached_command.path.clone(),
740        description: cached_command.description.clone(),
741        summary: cached_command.summary.clone(),
742        operation_id: cached_command.operation_id.clone(),
743        parameters,
744        request_body,
745        security_requirements: cached_command.security_requirements.clone(),
746        tags: cached_command
747            .tags
748            .iter()
749            .map(|t| to_kebab_case(t))
750            .collect(),
751        original_tags: cached_command.tags.clone(),
752        deprecated: cached_command.deprecated,
753        external_docs_url: cached_command.external_docs_url.clone(),
754        response_schema,
755        display_group: cached_command.display_group.clone(),
756        display_name: cached_command.display_name.clone(),
757        aliases: cached_command.aliases.clone(),
758        hidden: cached_command.hidden,
759        pagination: PaginationManifestInfo::from_cached(&cached_command.pagination),
760    }
761}
762
763/// Converts a `CachedParameter` to `ParameterInfo` for the manifest
764fn convert_cached_parameter_to_info(cached_param: &CachedParameter) -> ParameterInfo {
765    ParameterInfo {
766        name: cached_param.name.clone(),
767        location: cached_param.location.clone(),
768        required: cached_param.required,
769        param_type: cached_param
770            .schema_type
771            .clone()
772            .unwrap_or_else(|| constants::SCHEMA_TYPE_STRING.to_string()),
773        description: cached_param.description.clone(),
774        format: cached_param.format.clone(),
775        default_value: cached_param.default_value.clone(),
776        enum_values: cached_param.enum_values.clone(),
777        example: cached_param.example.clone(),
778    }
779}
780
781/// Converts a `CachedRequestBody` to `RequestBodyInfo` for the manifest
782fn convert_cached_request_body_to_info(cached_body: &CachedRequestBody) -> RequestBodyInfo {
783    RequestBodyInfo {
784        required: cached_body.required,
785        content_type: cached_body.content_type.clone(),
786        description: cached_body.description.clone(),
787        example: cached_body.example.clone(),
788    }
789}
790
791/// Extracts response schema from cached responses
792///
793/// Looks for successful response codes (200, 201, 204) in priority order.
794/// If a response exists but lacks `content_type` or schema, falls through to
795/// check the next status code.
796fn extract_response_schema_from_cached(
797    responses: &[crate::cache::models::CachedResponse],
798) -> Option<ResponseSchemaInfo> {
799    constants::SUCCESS_STATUS_CODES.iter().find_map(|code| {
800        responses
801            .iter()
802            .find(|r| r.status_code == *code)
803            .and_then(|response| {
804                let content_type = response.content_type.as_ref()?;
805                let schema_str = response.schema.as_ref()?;
806                let schema = serde_json::from_str(schema_str).ok()?;
807                let example = response
808                    .example
809                    .as_ref()
810                    .and_then(|ex| serde_json::from_str(ex).ok());
811
812                Some(ResponseSchemaInfo {
813                    content_type: content_type.clone(),
814                    schema,
815                    example,
816                })
817            })
818    })
819}
820
821/// Extracts security schemes from the cached spec for the capability manifest
822fn extract_security_schemes(spec: &CachedSpec) -> HashMap<String, SecuritySchemeInfo> {
823    let mut security_schemes = HashMap::new();
824
825    for (name, scheme) in &spec.security_schemes {
826        let details = match scheme.scheme_type.as_str() {
827            constants::SECURITY_TYPE_HTTP => {
828                scheme.scheme.as_ref().map_or(
829                    SecuritySchemeDetails::HttpBearer {
830                        bearer_format: None,
831                    },
832                    |http_scheme| match http_scheme.as_str() {
833                        constants::AUTH_SCHEME_BEARER => SecuritySchemeDetails::HttpBearer {
834                            bearer_format: scheme.bearer_format.clone(),
835                        },
836                        constants::AUTH_SCHEME_BASIC => SecuritySchemeDetails::HttpBasic,
837                        _ => {
838                            // For other HTTP schemes, default to bearer
839                            SecuritySchemeDetails::HttpBearer {
840                                bearer_format: None,
841                            }
842                        }
843                    },
844                )
845            }
846            constants::AUTH_SCHEME_APIKEY => SecuritySchemeDetails::ApiKey {
847                location: scheme
848                    .location
849                    .clone()
850                    .unwrap_or_else(|| constants::LOCATION_HEADER.to_string()),
851                name: scheme
852                    .parameter_name
853                    .clone()
854                    .unwrap_or_else(|| constants::HEADER_AUTHORIZATION.to_string()),
855            },
856            _ => {
857                // Default to bearer for unknown types
858                SecuritySchemeDetails::HttpBearer {
859                    bearer_format: None,
860                }
861            }
862        };
863
864        let scheme_info = SecuritySchemeInfo {
865            scheme_type: scheme.scheme_type.clone(),
866            description: scheme.description.clone(),
867            details,
868            aperture_secret: scheme.aperture_secret.clone(),
869        };
870
871        security_schemes.insert(name.clone(), scheme_info);
872    }
873
874    security_schemes
875}
876
877/// Converts an `OpenAPI` operation to `CommandInfo` with full metadata
878fn convert_openapi_operation_to_info(
879    method: &str,
880    path: &str,
881    operation: &Operation,
882    spec: &OpenAPI,
883    global_security: Option<&Vec<openapiv3::SecurityRequirement>>,
884) -> CommandInfo {
885    let command_name = operation
886        .operation_id
887        .as_ref()
888        .map_or_else(|| method.to_lowercase(), |op_id| to_kebab_case(op_id));
889
890    // Extract parameters with full metadata, resolving references
891    let parameters: Vec<ParameterInfo> = operation
892        .parameters
893        .iter()
894        .filter_map(|param_ref| match param_ref {
895            ReferenceOr::Item(param) => Some(convert_openapi_parameter_to_info(param)),
896            ReferenceOr::Reference { reference } => resolve_parameter_reference(spec, reference)
897                .ok()
898                .map(|param| convert_openapi_parameter_to_info(&param)),
899        })
900        .collect();
901
902    // Extract request body info
903    let request_body = operation.request_body.as_ref().and_then(|rb_ref| {
904        let ReferenceOr::Item(body) = rb_ref else {
905            return None;
906        };
907
908        // Prefer JSON content if available
909        let content_type = if body.content.contains_key(constants::CONTENT_TYPE_JSON) {
910            constants::CONTENT_TYPE_JSON
911        } else {
912            body.content.keys().next().map(String::as_str)?
913        };
914
915        let media_type = body.content.get(content_type)?;
916        let example = media_type
917            .example
918            .as_ref()
919            .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
920
921        Some(RequestBodyInfo {
922            required: body.required,
923            content_type: content_type.to_string(),
924            description: body.description.clone(),
925            example,
926        })
927    });
928
929    // Extract security requirements
930    let security_requirements = operation.security.as_ref().map_or_else(
931        || {
932            global_security.map_or(vec![], |reqs| {
933                reqs.iter().flat_map(|req| req.keys().cloned()).collect()
934            })
935        },
936        |op_security| {
937            op_security
938                .iter()
939                .flat_map(|req| req.keys().cloned())
940                .collect()
941        },
942    );
943
944    // Extract response schema from successful responses (200, 201, 204)
945    let response_schema = extract_response_schema_from_operation(operation, spec);
946
947    CommandInfo {
948        name: command_name,
949        method: method.to_uppercase(),
950        path: path.to_string(),
951        description: operation.description.clone(),
952        summary: operation.summary.clone(),
953        operation_id: operation.operation_id.clone().unwrap_or_default(),
954        parameters,
955        request_body,
956        security_requirements,
957        tags: operation.tags.iter().map(|t| to_kebab_case(t)).collect(),
958        original_tags: operation.tags.clone(),
959        deprecated: operation.deprecated,
960        external_docs_url: operation
961            .external_docs
962            .as_ref()
963            .map(|docs| docs.url.clone()),
964        response_schema,
965        // Command mapping fields start as None/empty/false here; they are
966        // overlaid from the cached spec in generate_capability_manifest_from_openapi().
967        display_group: None,
968        display_name: None,
969        aliases: vec![],
970        hidden: false,
971        pagination: PaginationManifestInfo::default(),
972    }
973}
974
975/// Extracts response schema from an operation's responses
976///
977/// Looks for successful response codes (200, 201, 204) in priority order
978/// and extracts the schema for the first one found with application/json content.
979fn extract_response_schema_from_operation(
980    operation: &Operation,
981    spec: &OpenAPI,
982) -> Option<ResponseSchemaInfo> {
983    constants::SUCCESS_STATUS_CODES.iter().find_map(|code| {
984        operation
985            .responses
986            .responses
987            .get(&openapiv3::StatusCode::Code(
988                code.parse().expect("valid status code"),
989            ))
990            .and_then(|response_ref| extract_response_schema_from_response(response_ref, spec))
991    })
992}
993
994/// Extracts response schema from a single response reference
995///
996/// # Limitations
997///
998/// - **Response references are not resolved**: If `response_ref` is a `$ref` to
999///   `#/components/responses/...`, this function returns `None`. Only inline
1000///   response definitions are processed. This is a known limitation that may
1001///   be addressed in a future version.
1002///
1003/// - **Nested schema references**: While top-level schema references within the
1004///   response content are resolved, any nested `$ref` within the schema's
1005///   properties remain unresolved. See [`ResponseSchemaInfo`] for details.
1006fn extract_response_schema_from_response(
1007    response_ref: &ReferenceOr<openapiv3::Response>,
1008    spec: &OpenAPI,
1009) -> Option<ResponseSchemaInfo> {
1010    // Note: Response references ($ref to #/components/responses/...) are not
1011    // currently resolved. This would require implementing resolve_response_reference()
1012    // similar to resolve_schema_reference().
1013    let ReferenceOr::Item(response) = response_ref else {
1014        return None;
1015    };
1016
1017    // Prefer application/json content type
1018    let content_type = if response.content.contains_key(constants::CONTENT_TYPE_JSON) {
1019        constants::CONTENT_TYPE_JSON
1020    } else {
1021        // Fall back to first available content type
1022        response.content.keys().next().map(String::as_str)?
1023    };
1024
1025    let media_type = response.content.get(content_type)?;
1026    let schema_ref = media_type.schema.as_ref()?;
1027
1028    // Resolve schema reference if necessary
1029    let schema_value = match schema_ref {
1030        ReferenceOr::Item(schema) => serde_json::to_value(schema).ok()?,
1031        ReferenceOr::Reference { reference } => {
1032            let resolved = resolve_schema_reference(spec, reference).ok()?;
1033            serde_json::to_value(&resolved).ok()?
1034        }
1035    };
1036
1037    // Extract example if available
1038    let example = media_type
1039        .example
1040        .as_ref()
1041        .and_then(|ex| serde_json::to_value(ex).ok());
1042
1043    Some(ResponseSchemaInfo {
1044        content_type: content_type.to_string(),
1045        schema: schema_value,
1046        example,
1047    })
1048}
1049
1050/// Extracts schema information from a parameter format
1051fn extract_schema_info_from_parameter(
1052    format: &openapiv3::ParameterSchemaOrContent,
1053) -> ParameterSchemaInfo {
1054    let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = format else {
1055        return (
1056            Some(constants::SCHEMA_TYPE_STRING.to_string()),
1057            None,
1058            None,
1059            vec![],
1060            None,
1061        );
1062    };
1063
1064    match schema_ref {
1065        ReferenceOr::Item(schema) => {
1066            let (schema_type, format, enums) =
1067                extract_schema_type_from_schema_kind(&schema.schema_kind);
1068
1069            let default_value = schema
1070                .schema_data
1071                .default
1072                .as_ref()
1073                .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()));
1074
1075            (Some(schema_type), format, default_value, enums, None)
1076        }
1077        ReferenceOr::Reference { .. } => (
1078            Some(constants::SCHEMA_TYPE_STRING.to_string()),
1079            None,
1080            None,
1081            vec![],
1082            None,
1083        ),
1084    }
1085}
1086
1087/// Extracts type information from a schema kind
1088fn extract_schema_type_from_schema_kind(
1089    schema_kind: &openapiv3::SchemaKind,
1090) -> (String, Option<String>, Vec<String>) {
1091    match schema_kind {
1092        openapiv3::SchemaKind::Type(type_val) => match type_val {
1093            openapiv3::Type::String(string_type) => {
1094                let enum_values: Vec<String> = string_type
1095                    .enumeration
1096                    .iter()
1097                    .filter_map(|v| v.as_ref())
1098                    .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.clone()))
1099                    .collect();
1100                (constants::SCHEMA_TYPE_STRING.to_string(), None, enum_values)
1101            }
1102            openapiv3::Type::Number(_) => (constants::SCHEMA_TYPE_NUMBER.to_string(), None, vec![]),
1103            openapiv3::Type::Integer(_) => {
1104                (constants::SCHEMA_TYPE_INTEGER.to_string(), None, vec![])
1105            }
1106            openapiv3::Type::Boolean(_) => {
1107                (constants::SCHEMA_TYPE_BOOLEAN.to_string(), None, vec![])
1108            }
1109            openapiv3::Type::Array(_) => (constants::SCHEMA_TYPE_ARRAY.to_string(), None, vec![]),
1110            openapiv3::Type::Object(_) => (constants::SCHEMA_TYPE_OBJECT.to_string(), None, vec![]),
1111        },
1112        _ => (constants::SCHEMA_TYPE_STRING.to_string(), None, vec![]),
1113    }
1114}
1115
1116/// Converts an `OpenAPI` parameter to `ParameterInfo` with full metadata
1117fn convert_openapi_parameter_to_info(param: &OpenApiParameter) -> ParameterInfo {
1118    let (param_data, location_str) = match param {
1119        OpenApiParameter::Query { parameter_data, .. } => {
1120            (parameter_data, constants::PARAM_LOCATION_QUERY)
1121        }
1122        OpenApiParameter::Header { parameter_data, .. } => {
1123            (parameter_data, constants::PARAM_LOCATION_HEADER)
1124        }
1125        OpenApiParameter::Path { parameter_data, .. } => {
1126            (parameter_data, constants::PARAM_LOCATION_PATH)
1127        }
1128        OpenApiParameter::Cookie { parameter_data, .. } => {
1129            (parameter_data, constants::PARAM_LOCATION_COOKIE)
1130        }
1131    };
1132
1133    // Extract schema information
1134    let (schema_type, format, default_value, enum_values, example) =
1135        extract_schema_info_from_parameter(&param_data.format);
1136
1137    // Extract example from parameter data
1138    let example = param_data
1139        .example
1140        .as_ref()
1141        .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()))
1142        .or(example);
1143
1144    ParameterInfo {
1145        name: param_data.name.clone(),
1146        location: location_str.to_string(),
1147        required: param_data.required,
1148        param_type: schema_type.unwrap_or_else(|| constants::SCHEMA_TYPE_STRING.to_string()),
1149        description: param_data.description.clone(),
1150        format,
1151        default_value,
1152        enum_values,
1153        example,
1154    }
1155}
1156
1157/// Extracts security schemes directly from `OpenAPI` spec
1158fn extract_security_schemes_from_openapi(spec: &OpenAPI) -> HashMap<String, SecuritySchemeInfo> {
1159    let mut security_schemes = HashMap::new();
1160
1161    let Some(components) = &spec.components else {
1162        return security_schemes;
1163    };
1164
1165    for (name, scheme_ref) in &components.security_schemes {
1166        let ReferenceOr::Item(scheme) = scheme_ref else {
1167            continue;
1168        };
1169
1170        let Some(scheme_info) = convert_openapi_security_scheme(name, scheme) else {
1171            continue;
1172        };
1173
1174        security_schemes.insert(name.clone(), scheme_info);
1175    }
1176
1177    security_schemes
1178}
1179
1180/// Converts an `OpenAPI` security scheme to `SecuritySchemeInfo`
1181fn convert_openapi_security_scheme(
1182    _name: &str,
1183    scheme: &SecurityScheme,
1184) -> Option<SecuritySchemeInfo> {
1185    match scheme {
1186        SecurityScheme::APIKey {
1187            location,
1188            name: param_name,
1189            description,
1190            ..
1191        } => {
1192            let location_str = match location {
1193                openapiv3::APIKeyLocation::Query => constants::PARAM_LOCATION_QUERY,
1194                openapiv3::APIKeyLocation::Header => constants::PARAM_LOCATION_HEADER,
1195                openapiv3::APIKeyLocation::Cookie => constants::PARAM_LOCATION_COOKIE,
1196            };
1197
1198            let aperture_secret = extract_aperture_secret_from_extensions(scheme);
1199
1200            Some(SecuritySchemeInfo {
1201                scheme_type: constants::AUTH_SCHEME_APIKEY.to_string(),
1202                description: description.clone(),
1203                details: SecuritySchemeDetails::ApiKey {
1204                    location: location_str.to_string(),
1205                    name: param_name.clone(),
1206                },
1207                aperture_secret,
1208            })
1209        }
1210        SecurityScheme::HTTP {
1211            scheme: http_scheme,
1212            bearer_format,
1213            description,
1214            ..
1215        } => {
1216            let details = match http_scheme.as_str() {
1217                constants::AUTH_SCHEME_BEARER => SecuritySchemeDetails::HttpBearer {
1218                    bearer_format: bearer_format.clone(),
1219                },
1220                constants::AUTH_SCHEME_BASIC => SecuritySchemeDetails::HttpBasic,
1221                _ => SecuritySchemeDetails::HttpBearer {
1222                    bearer_format: None,
1223                },
1224            };
1225
1226            let aperture_secret = extract_aperture_secret_from_extensions(scheme);
1227
1228            Some(SecuritySchemeInfo {
1229                scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
1230                description: description.clone(),
1231                details,
1232                aperture_secret,
1233            })
1234        }
1235        SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => None,
1236    }
1237}
1238
1239/// Extracts x-aperture-secret extension from a security scheme's extensions
1240fn extract_aperture_secret_from_extensions(
1241    scheme: &SecurityScheme,
1242) -> Option<CachedApertureSecret> {
1243    let extensions = match scheme {
1244        SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. } => {
1245            extensions
1246        }
1247        SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => return None,
1248    };
1249
1250    extensions
1251        .get(constants::EXT_APERTURE_SECRET)
1252        .and_then(|value| {
1253            let obj = value.as_object()?;
1254            let source = obj.get(constants::EXT_KEY_SOURCE)?.as_str()?;
1255            let name = obj.get(constants::EXT_KEY_NAME)?.as_str()?;
1256
1257            if source != constants::SOURCE_ENV {
1258                return None;
1259            }
1260
1261            Some(CachedApertureSecret {
1262                source: source.to_string(),
1263                name: name.to_string(),
1264            })
1265        })
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270    use super::*;
1271    use crate::cache::models::{
1272        CachedApertureSecret, CachedCommand, CachedParameter, CachedSecurityScheme, CachedSpec,
1273        PaginationInfo,
1274    };
1275
1276    #[test]
1277    fn test_command_name_conversion() {
1278        // Test that command names are properly converted
1279        assert_eq!(to_kebab_case("getUserById"), "get-user-by-id");
1280        assert_eq!(to_kebab_case("createUser"), "create-user");
1281        assert_eq!(to_kebab_case("list"), "list");
1282        assert_eq!(to_kebab_case("GET"), "get");
1283        assert_eq!(
1284            to_kebab_case("List an Organization's Issues"),
1285            "list-an-organizations-issues"
1286        );
1287    }
1288
1289    #[test]
1290    #[allow(clippy::too_many_lines)]
1291    fn test_generate_capability_manifest() {
1292        let mut security_schemes = HashMap::new();
1293        security_schemes.insert(
1294            "bearerAuth".to_string(),
1295            CachedSecurityScheme {
1296                name: "bearerAuth".to_string(),
1297                scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
1298                scheme: Some(constants::AUTH_SCHEME_BEARER.to_string()),
1299                location: Some(constants::LOCATION_HEADER.to_string()),
1300                parameter_name: Some(constants::HEADER_AUTHORIZATION.to_string()),
1301                description: None,
1302                bearer_format: None,
1303                aperture_secret: Some(CachedApertureSecret {
1304                    source: constants::SOURCE_ENV.to_string(),
1305                    name: "API_TOKEN".to_string(),
1306                }),
1307            },
1308        );
1309
1310        let spec = CachedSpec {
1311            cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
1312            name: "Test API".to_string(),
1313            version: "1.0.0".to_string(),
1314            commands: vec![CachedCommand {
1315                name: "users".to_string(),
1316                description: Some("Get user by ID".to_string()),
1317                summary: None,
1318                operation_id: "getUserById".to_string(),
1319                method: constants::HTTP_METHOD_GET.to_string(),
1320                path: "/users/{id}".to_string(),
1321                parameters: vec![CachedParameter {
1322                    name: "id".to_string(),
1323                    location: constants::PARAM_LOCATION_PATH.to_string(),
1324                    required: true,
1325                    description: None,
1326                    schema: Some(constants::SCHEMA_TYPE_STRING.to_string()),
1327                    schema_type: Some(constants::SCHEMA_TYPE_STRING.to_string()),
1328                    format: None,
1329                    default_value: None,
1330                    enum_values: vec![],
1331                    example: None,
1332                }],
1333                request_body: None,
1334                responses: vec![],
1335                security_requirements: vec!["bearerAuth".to_string()],
1336                tags: vec!["users".to_string()],
1337                deprecated: false,
1338                external_docs_url: None,
1339                examples: vec![],
1340                display_group: None,
1341                display_name: None,
1342                aliases: vec![],
1343                hidden: false,
1344                pagination: PaginationInfo::default(),
1345            }],
1346            base_url: Some("https://test-api.example.com".to_string()),
1347            servers: vec!["https://test-api.example.com".to_string()],
1348            security_schemes,
1349            skipped_endpoints: vec![],
1350            server_variables: HashMap::new(),
1351        };
1352
1353        let manifest_json = generate_capability_manifest(&spec, None).unwrap();
1354        let manifest: ApiCapabilityManifest = serde_json::from_str(&manifest_json).unwrap();
1355
1356        assert_eq!(manifest.api.name, "Test API");
1357        assert_eq!(manifest.api.version, "1.0.0");
1358        assert!(manifest.commands.contains_key("users"));
1359
1360        let users_commands = &manifest.commands["users"];
1361        assert_eq!(users_commands.len(), 1);
1362        assert_eq!(users_commands[0].name, "get-user-by-id");
1363        assert_eq!(users_commands[0].method, constants::HTTP_METHOD_GET);
1364        assert_eq!(users_commands[0].parameters.len(), 1);
1365        assert_eq!(users_commands[0].parameters[0].name, "id");
1366
1367        // Test security information extraction
1368        assert!(!manifest.security_schemes.is_empty());
1369        assert!(manifest.security_schemes.contains_key("bearerAuth"));
1370        let bearer_auth = &manifest.security_schemes["bearerAuth"];
1371        assert_eq!(bearer_auth.scheme_type, constants::SECURITY_TYPE_HTTP);
1372        assert!(matches!(
1373            &bearer_auth.details,
1374            SecuritySchemeDetails::HttpBearer { .. }
1375        ));
1376        assert!(bearer_auth.aperture_secret.is_some());
1377        let aperture_secret = bearer_auth.aperture_secret.as_ref().unwrap();
1378        assert_eq!(aperture_secret.name, "API_TOKEN");
1379        assert_eq!(aperture_secret.source, constants::SOURCE_ENV);
1380
1381        // Test batch capability info
1382        assert_eq!(manifest.batch.file_formats, vec!["json", "yaml"]);
1383        let field_names: Vec<&str> = manifest
1384            .batch
1385            .operation_schema
1386            .fields
1387            .iter()
1388            .map(|f| f.name.as_str())
1389            .collect();
1390        assert!(field_names.contains(&"capture"));
1391        assert!(field_names.contains(&"capture_append"));
1392        assert!(field_names.contains(&"depends_on"));
1393        assert!(field_names.contains(&"args"));
1394        assert_eq!(
1395            manifest.batch.dependent_workflows.interpolation_syntax,
1396            "{{variable_name}}"
1397        );
1398        assert!(
1399            manifest
1400                .batch
1401                .dependent_workflows
1402                .dependent_execution
1403                .implicit_dependencies
1404        );
1405    }
1406}