Skip to main content

aperture_cli/config/
manager.rs

1use crate::cache::fingerprint::{compute_content_hash, get_file_mtime_secs};
2use crate::cache::metadata::CacheMetadataManager;
3use crate::config::context_name::ApiContextName;
4use crate::config::models::{ApertureSecret, ApiConfig, GlobalConfig, SecretSource};
5use crate::config::url_resolver::BaseUrlResolver;
6use crate::constants;
7use crate::engine::loader;
8use crate::error::Error;
9use crate::fs::{FileSystem, OsFileSystem};
10use crate::interactive::{confirm, prompt_for_input, select_from_options};
11use crate::spec::{SpecTransformer, SpecValidator};
12use openapiv3::{OpenAPI, ReferenceOr};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17/// Struct to hold categorized validation warnings
18struct CategorizedWarnings<'a> {
19    content_type: Vec<&'a crate::spec::validator::ValidationWarning>,
20    auth: Vec<&'a crate::spec::validator::ValidationWarning>,
21    mixed_content: Vec<&'a crate::spec::validator::ValidationWarning>,
22}
23
24pub struct ConfigManager<F: FileSystem> {
25    fs: F,
26    config_dir: PathBuf,
27}
28
29impl ConfigManager<OsFileSystem> {
30    /// Creates a new `ConfigManager` with the default filesystem and config directory.
31    ///
32    /// # Errors
33    ///
34    /// Returns an error if the home directory cannot be determined.
35    pub fn new() -> Result<Self, Error> {
36        let config_dir = get_config_dir()?;
37        Ok(Self {
38            fs: OsFileSystem,
39            config_dir,
40        })
41    }
42}
43
44impl<F: FileSystem> ConfigManager<F> {
45    pub const fn with_fs(fs: F, config_dir: PathBuf) -> Self {
46        Self { fs, config_dir }
47    }
48
49    /// Get the configuration directory path
50    pub fn config_dir(&self) -> &Path {
51        &self.config_dir
52    }
53
54    /// Convert skipped endpoints to validation warnings for display
55    #[must_use]
56    pub fn skipped_endpoints_to_warnings(
57        skipped_endpoints: &[crate::cache::models::SkippedEndpoint],
58    ) -> Vec<crate::spec::validator::ValidationWarning> {
59        skipped_endpoints
60            .iter()
61            .map(|endpoint| crate::spec::validator::ValidationWarning {
62                endpoint: crate::spec::validator::UnsupportedEndpoint {
63                    path: endpoint.path.clone(),
64                    method: endpoint.method.clone(),
65                    content_type: endpoint.content_type.clone(),
66                },
67                reason: endpoint.reason.clone(),
68            })
69            .collect()
70    }
71
72    /// Save the strict mode preference for an API
73    fn save_strict_preference(&self, api_name: &str, strict: bool) -> Result<(), Error> {
74        let mut config = self.load_global_config()?;
75        let api_config = config
76            .api_configs
77            .entry(api_name.to_string())
78            .or_insert_with(|| ApiConfig {
79                base_url_override: None,
80                environment_urls: HashMap::new(),
81                strict_mode: false,
82                secrets: HashMap::new(),
83                command_mapping: None,
84            });
85        api_config.strict_mode = strict;
86        self.save_global_config(&config)?;
87        Ok(())
88    }
89
90    /// Get the strict mode preference for an API
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if the global config cannot be loaded
95    pub fn get_strict_preference(&self, api_name: &ApiContextName) -> Result<bool, Error> {
96        let api_name = api_name.as_str();
97        let config = self.load_global_config()?;
98        Ok(config
99            .api_configs
100            .get(api_name)
101            .is_some_and(|c| c.strict_mode))
102    }
103
104    /// Count total operations in an `OpenAPI` spec
105    fn count_total_operations(spec: &OpenAPI) -> usize {
106        spec.paths
107            .iter()
108            .filter_map(|(_, path_item)| match path_item {
109                ReferenceOr::Item(item) => Some(item),
110                ReferenceOr::Reference { .. } => None,
111            })
112            .map(|item| {
113                let mut count = 0;
114                if item.get.is_some() {
115                    count += 1;
116                }
117                if item.post.is_some() {
118                    count += 1;
119                }
120                if item.put.is_some() {
121                    count += 1;
122                }
123                if item.delete.is_some() {
124                    count += 1;
125                }
126                if item.patch.is_some() {
127                    count += 1;
128                }
129                if item.head.is_some() {
130                    count += 1;
131                }
132                if item.options.is_some() {
133                    count += 1;
134                }
135                if item.trace.is_some() {
136                    count += 1;
137                }
138                count
139            })
140            .sum()
141    }
142
143    /// Display validation warnings with custom prefix
144    #[must_use]
145    pub fn format_validation_warnings(
146        warnings: &[crate::spec::validator::ValidationWarning],
147        total_operations: Option<usize>,
148        indent: &str,
149    ) -> Vec<String> {
150        let mut lines = Vec::new();
151
152        if !warnings.is_empty() {
153            let categorized_warnings = Self::categorize_warnings(warnings);
154            let total_skipped =
155                categorized_warnings.content_type.len() + categorized_warnings.auth.len();
156
157            Self::format_content_type_warnings(
158                &mut lines,
159                &categorized_warnings.content_type,
160                total_operations,
161                total_skipped,
162                indent,
163            );
164            Self::format_auth_warnings(
165                &mut lines,
166                &categorized_warnings.auth,
167                total_operations,
168                total_skipped,
169                indent,
170                !categorized_warnings.content_type.is_empty(),
171            );
172            Self::format_mixed_content_warnings(
173                &mut lines,
174                &categorized_warnings.mixed_content,
175                indent,
176                !categorized_warnings.content_type.is_empty()
177                    || !categorized_warnings.auth.is_empty(),
178            );
179        }
180
181        lines
182    }
183
184    /// Display validation warnings to stderr
185    pub fn display_validation_warnings(
186        warnings: &[crate::spec::validator::ValidationWarning],
187        total_operations: Option<usize>,
188    ) {
189        if !warnings.is_empty() {
190            let lines = Self::format_validation_warnings(warnings, total_operations, "");
191            for line in lines {
192                // Use pattern matching to flatten nested if
193                match line.as_str() {
194                    "" => {
195                        // ast-grep-ignore: no-println
196                        eprintln!();
197                    }
198                    s if s.starts_with("Skipping") || s.starts_with("Endpoints") => {
199                        // ast-grep-ignore: no-println
200                        eprintln!("{} {line}", crate::constants::MSG_WARNING_PREFIX);
201                    }
202                    _ => {
203                        // ast-grep-ignore: no-println
204                        eprintln!("{line}");
205                    }
206                }
207            }
208            // ast-grep-ignore: no-println
209            eprintln!("\nUse --strict to reject specs with unsupported features.");
210        }
211    }
212
213    /// Adds a new `OpenAPI` specification to the configuration from a local file.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if:
218    /// - The spec already exists and `force` is false
219    /// - File I/O operations fail
220    /// - The `OpenAPI` spec is invalid YAML
221    /// - The spec contains unsupported features
222    ///
223    /// # Panics
224    ///
225    /// Panics if the spec path parent directory is None (should not happen in normal usage).
226    pub fn add_spec(
227        &self,
228        name: &ApiContextName,
229        file_path: &Path,
230        force: bool,
231        strict: bool,
232    ) -> Result<(), Error> {
233        self.check_spec_exists(name.as_str(), force)?;
234
235        let content = self.fs.read_to_string(file_path)?;
236        let openapi_spec = crate::spec::parse_openapi(&content)?;
237
238        // Validate against Aperture's supported feature set using SpecValidator
239        let validator = SpecValidator::new();
240        let validation_result = validator.validate_with_mode(&openapi_spec, strict);
241
242        // Check for errors first
243        if !validation_result.is_valid() {
244            return validation_result.into_result();
245        }
246
247        // Count total operations for better UX
248        let total_operations = Self::count_total_operations(&openapi_spec);
249
250        // Display warnings if any
251        Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
252
253        self.add_spec_from_validated_openapi(
254            name.as_str(),
255            &openapi_spec,
256            &content,
257            &validation_result,
258            strict,
259        )
260    }
261
262    /// Adds a new `OpenAPI` specification to the configuration from a URL.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if:
267    /// - The spec already exists and `force` is false
268    /// - Network requests fail
269    /// - The `OpenAPI` spec is invalid YAML
270    /// - The spec contains unsupported features
271    /// - Response size exceeds 10MB limit
272    /// - Request times out (30 seconds)
273    ///
274    /// # Panics
275    ///
276    /// Panics if the spec path parent directory is None (should not happen in normal usage).
277    #[allow(clippy::future_not_send)]
278    pub async fn add_spec_from_url(
279        &self,
280        name: &ApiContextName,
281        url: &str,
282        force: bool,
283        strict: bool,
284    ) -> Result<(), Error> {
285        self.check_spec_exists(name.as_str(), force)?;
286
287        // Fetch content from URL
288        let content = fetch_spec_from_url(url).await?;
289        let openapi_spec = crate::spec::parse_openapi(&content)?;
290
291        // Validate against Aperture's supported feature set using SpecValidator
292        let validator = SpecValidator::new();
293        let validation_result = validator.validate_with_mode(&openapi_spec, strict);
294
295        // Check for errors first
296        if !validation_result.is_valid() {
297            return validation_result.into_result();
298        }
299
300        // Count total operations for better UX
301        let total_operations = Self::count_total_operations(&openapi_spec);
302
303        // Display warnings if any
304        Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
305
306        self.add_spec_from_validated_openapi(
307            name.as_str(),
308            &openapi_spec,
309            &content,
310            &validation_result,
311            strict,
312        )
313    }
314
315    /// Adds a new `OpenAPI` specification from either a file path or URL.
316    ///
317    /// This is a convenience method that automatically detects whether the input
318    /// is a URL or file path and calls the appropriate method.
319    ///
320    /// # Errors
321    ///
322    /// Returns an error if:
323    /// - The spec already exists and `force` is false
324    /// - File I/O operations fail (for local files)
325    /// - Network requests fail (for URLs)
326    /// - The `OpenAPI` spec is invalid YAML
327    /// - The spec contains unsupported features
328    /// - Response size exceeds 10MB limit (for URLs)
329    /// - Request times out (for URLs)
330    #[allow(clippy::future_not_send)]
331    pub async fn add_spec_auto(
332        &self,
333        name: &ApiContextName,
334        file_or_url: &str,
335        force: bool,
336        strict: bool,
337    ) -> Result<(), Error> {
338        if is_url(file_or_url) {
339            self.add_spec_from_url(name, file_or_url, force, strict)
340                .await
341        } else {
342            // Convert file path string to Path and call sync method
343            let path = std::path::Path::new(file_or_url);
344            self.add_spec(name, path, force, strict)
345        }
346    }
347
348    /// Lists all registered API contexts.
349    ///
350    /// # Errors
351    ///
352    /// Returns an error if the specs directory cannot be read.
353    pub fn list_specs(&self) -> Result<Vec<String>, Error> {
354        let specs_dir = self.config_dir.join(crate::constants::DIR_SPECS);
355        if !self.fs.exists(&specs_dir) {
356            return Ok(Vec::new());
357        }
358
359        let mut specs = Vec::new();
360        for entry in self.fs.read_dir(&specs_dir)? {
361            // Early return guard clause for non-files
362            if !self.fs.is_file(&entry) {
363                continue;
364            }
365
366            // Use let-else for file name extraction
367            let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) else {
368                continue;
369            };
370
371            // Check if file has yaml extension
372            if std::path::Path::new(file_name)
373                .extension()
374                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
375            {
376                specs.push(
377                    file_name
378                        .trim_end_matches(crate::constants::FILE_EXT_YAML)
379                        .to_string(),
380                );
381            }
382        }
383        Ok(specs)
384    }
385
386    /// Removes an API specification from the configuration.
387    ///
388    /// # Errors
389    ///
390    /// Returns an error if the spec does not exist or cannot be removed.
391    pub fn remove_spec(&self, name: &ApiContextName) -> Result<(), Error> {
392        let name = name.as_str();
393        let spec_path = self
394            .config_dir
395            .join(crate::constants::DIR_SPECS)
396            .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
397        let cache_path = self
398            .config_dir
399            .join(crate::constants::DIR_CACHE)
400            .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
401
402        if !self.fs.exists(&spec_path) {
403            return Err(Error::spec_not_found(name));
404        }
405
406        self.fs.remove_file(&spec_path)?;
407        if self.fs.exists(&cache_path) {
408            self.fs.remove_file(&cache_path)?;
409        }
410
411        // Remove from cache metadata
412        let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
413        let metadata_manager = CacheMetadataManager::new(&self.fs);
414        // Ignore errors if metadata removal fails - the important files are already removed
415        let _ = metadata_manager.remove_spec_metadata(&cache_dir, name);
416
417        Ok(())
418    }
419
420    /// Opens an API specification in the default editor.
421    ///
422    /// # Errors
423    ///
424    /// Returns an error if:
425    /// - The spec does not exist.
426    /// - The `$EDITOR` environment variable is not set.
427    /// - The editor command fails to execute.
428    pub fn edit_spec(&self, name: &ApiContextName) -> Result<(), Error> {
429        let name = name.as_str();
430        let spec_path = self
431            .config_dir
432            .join(crate::constants::DIR_SPECS)
433            .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
434
435        if !self.fs.exists(&spec_path) {
436            return Err(Error::spec_not_found(name));
437        }
438
439        let editor = std::env::var("EDITOR").map_err(|_| Error::editor_not_set())?;
440
441        // Parse the editor command to handle commands with arguments (e.g., "code --wait")
442        let mut parts = editor.split_whitespace();
443        let program = parts.next().ok_or_else(Error::editor_not_set)?;
444        let args: Vec<&str> = parts.collect();
445
446        Command::new(program)
447            .args(&args)
448            .arg(&spec_path)
449            .status()
450            .map_err(|e| Error::io_error(format!("Failed to get editor process status: {e}")))?
451            .success()
452            .then_some(()) // Convert bool to Option<()>
453            .ok_or_else(|| Error::editor_failed(name))
454    }
455
456    /// Loads the global configuration from `config.toml`.
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if the configuration file exists but cannot be read or parsed.
461    pub fn load_global_config(&self) -> Result<GlobalConfig, Error> {
462        let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
463        if self.fs.exists(&config_path) {
464            let content = self.fs.read_to_string(&config_path)?;
465            toml::from_str(&content).map_err(|e| Error::invalid_config(e.to_string()))
466        } else {
467            Ok(GlobalConfig::default())
468        }
469    }
470
471    /// Saves the global configuration to `config.toml`.
472    ///
473    /// # Errors
474    ///
475    /// Returns an error if the configuration cannot be serialized or written.
476    pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
477        let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
478
479        // Ensure config directory exists
480        self.fs.create_dir_all(&self.config_dir)?;
481
482        let content = toml::to_string_pretty(config)
483            .map_err(|e| Error::serialization_error(format!("Failed to serialize config: {e}")))?;
484
485        self.fs.atomic_write(&config_path, content.as_bytes())?;
486        Ok(())
487    }
488
489    // ---- Settings Management ----
490
491    /// Sets a global configuration setting value.
492    ///
493    /// Uses `toml_edit` to preserve comments and formatting in the config file.
494    ///
495    /// # Arguments
496    /// * `key` - The setting key to modify
497    /// * `value` - The value to set
498    ///
499    /// # Errors
500    ///
501    /// Returns an error if the config file cannot be read, parsed, or written.
502    pub fn set_setting(
503        &self,
504        key: &crate::config::settings::SettingKey,
505        value: &crate::config::settings::SettingValue,
506    ) -> Result<(), Error> {
507        use crate::config::settings::{SettingKey, SettingValue};
508        use toml_edit::DocumentMut;
509
510        let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
511
512        // Load existing document or create new one
513        let content = if self.fs.exists(&config_path) {
514            self.fs.read_to_string(&config_path)?
515        } else {
516            String::new()
517        };
518
519        let mut doc: DocumentMut = content
520            .parse()
521            .map_err(|e| Error::invalid_config(format!("Failed to parse config: {e}")))?;
522
523        // Apply the setting based on key
524        // Note: Type mismatches indicate a programming error since parse_for_key
525        // should always produce the correct type for each key.
526        match (key, value) {
527            (SettingKey::DefaultTimeoutSecs, SettingValue::U64(v)) => {
528                doc["default_timeout_secs"] =
529                    toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
530            }
531            (SettingKey::AgentDefaultsJsonErrors, SettingValue::Bool(v)) => {
532                // Ensure agent_defaults table exists
533                if doc.get("agent_defaults").is_none() {
534                    doc["agent_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
535                }
536                doc["agent_defaults"]["json_errors"] = toml_edit::value(*v);
537            }
538            (SettingKey::RetryDefaultsMaxAttempts, SettingValue::U64(v)) => {
539                // Ensure retry_defaults table exists
540                if doc.get("retry_defaults").is_none() {
541                    doc["retry_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
542                }
543                doc["retry_defaults"]["max_attempts"] =
544                    toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
545            }
546            (SettingKey::RetryDefaultsInitialDelayMs, SettingValue::U64(v)) => {
547                // Ensure retry_defaults table exists
548                if doc.get("retry_defaults").is_none() {
549                    doc["retry_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
550                }
551                doc["retry_defaults"]["initial_delay_ms"] =
552                    toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
553            }
554            (SettingKey::RetryDefaultsMaxDelayMs, SettingValue::U64(v)) => {
555                // Ensure retry_defaults table exists
556                if doc.get("retry_defaults").is_none() {
557                    doc["retry_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
558                }
559                doc["retry_defaults"]["max_delay_ms"] =
560                    toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
561            }
562            // Type mismatches are programming errors - parse_for_key guarantees correct types
563            (
564                SettingKey::DefaultTimeoutSecs
565                | SettingKey::RetryDefaultsMaxAttempts
566                | SettingKey::RetryDefaultsInitialDelayMs
567                | SettingKey::RetryDefaultsMaxDelayMs,
568                _,
569            ) => {
570                debug_assert!(false, "Integer settings require U64 value");
571            }
572            (SettingKey::AgentDefaultsJsonErrors, _) => {
573                debug_assert!(false, "AgentDefaultsJsonErrors requires Bool value");
574            }
575        }
576
577        // Ensure config directory exists
578        self.fs.create_dir_all(&self.config_dir)?;
579
580        // Write back preserving formatting (atomic to prevent corruption)
581        self.fs
582            .atomic_write(&config_path, doc.to_string().as_bytes())?;
583        Ok(())
584    }
585
586    /// Gets a global configuration setting value.
587    ///
588    /// # Arguments
589    /// * `key` - The setting key to retrieve
590    ///
591    /// # Errors
592    ///
593    /// Returns an error if the config file cannot be read or parsed.
594    pub fn get_setting(
595        &self,
596        key: &crate::config::settings::SettingKey,
597    ) -> Result<crate::config::settings::SettingValue, Error> {
598        let config = self.load_global_config()?;
599        Ok(key.value_from_config(&config))
600    }
601
602    /// Lists all available configuration settings with their current values.
603    ///
604    /// # Errors
605    ///
606    /// Returns an error if the config file cannot be read or parsed.
607    pub fn list_settings(&self) -> Result<Vec<crate::config::settings::SettingInfo>, Error> {
608        use crate::config::settings::{SettingInfo, SettingKey};
609
610        let config = self.load_global_config()?;
611        let settings = SettingKey::ALL
612            .iter()
613            .map(|key| SettingInfo::new(*key, &key.value_from_config(&config)))
614            .collect();
615
616        Ok(settings)
617    }
618
619    /// Sets the base URL for an API specification.
620    ///
621    /// # Arguments
622    /// * `api_name` - The name of the API specification
623    /// * `url` - The base URL to set
624    /// * `environment` - Optional environment name for environment-specific URLs
625    ///
626    /// # Errors
627    ///
628    /// Returns an error if the spec doesn't exist or config cannot be saved.
629    pub fn set_url(
630        &self,
631        api_name: &ApiContextName,
632        url: &str,
633        environment: Option<&str>,
634    ) -> Result<(), Error> {
635        let api_name = api_name.as_str();
636        // Verify the spec exists
637        let spec_path = self
638            .config_dir
639            .join(crate::constants::DIR_SPECS)
640            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
641        if !self.fs.exists(&spec_path) {
642            return Err(Error::spec_not_found(api_name));
643        }
644
645        // Load current config
646        let mut config = self.load_global_config()?;
647
648        // Get or create API config
649        let api_config = config
650            .api_configs
651            .entry(api_name.to_string())
652            .or_insert_with(|| ApiConfig {
653                base_url_override: None,
654                environment_urls: HashMap::new(),
655                strict_mode: false,
656                secrets: HashMap::new(),
657                command_mapping: None,
658            });
659
660        // Set the URL
661        if let Some(env) = environment {
662            api_config
663                .environment_urls
664                .insert(env.to_string(), url.to_string());
665        } else {
666            api_config.base_url_override = Some(url.to_string());
667        }
668
669        // Save updated config
670        self.save_global_config(&config)?;
671        Ok(())
672    }
673
674    /// Gets the base URL configuration for an API specification.
675    ///
676    /// # Arguments
677    /// * `api_name` - The name of the API specification
678    ///
679    /// # Returns
680    /// A tuple of (`base_url_override`, `environment_urls`, `resolved_url`)
681    ///
682    /// # Errors
683    ///
684    /// Returns an error if the spec doesn't exist.
685    #[allow(clippy::type_complexity)]
686    pub fn get_url(
687        &self,
688        api_name: &ApiContextName,
689    ) -> Result<(Option<String>, HashMap<String, String>, String), Error> {
690        let api_name = api_name.as_str();
691        // Verify the spec exists
692        let spec_path = self
693            .config_dir
694            .join(crate::constants::DIR_SPECS)
695            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
696        if !self.fs.exists(&spec_path) {
697            return Err(Error::spec_not_found(api_name));
698        }
699
700        // Load the cached spec to get its base URL
701        let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
702        let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
703
704        // Load global config
705        let config = self.load_global_config()?;
706
707        // Get API config
708        let api_config = config.api_configs.get(api_name);
709
710        let base_url_override = api_config.and_then(|c| c.base_url_override.clone());
711        let environment_urls = api_config
712            .map(|c| c.environment_urls.clone())
713            .unwrap_or_default();
714
715        // Resolve the URL that would actually be used
716        let resolved_url = cached_spec.map_or_else(
717            || "https://api.example.com".to_string(),
718            |spec| {
719                let resolver = BaseUrlResolver::new(&spec);
720                let resolver = if api_config.is_some() {
721                    resolver.with_global_config(&config)
722                } else {
723                    resolver
724                };
725                resolver.resolve(None)
726            },
727        );
728
729        Ok((base_url_override, environment_urls, resolved_url))
730    }
731
732    /// Lists all configured base URLs across all API specifications.
733    ///
734    /// # Returns
735    /// A map of API names to their URL configurations
736    ///
737    /// # Errors
738    ///
739    /// Returns an error if the config cannot be loaded.
740    #[allow(clippy::type_complexity)]
741    pub fn list_urls(
742        &self,
743    ) -> Result<HashMap<String, (Option<String>, HashMap<String, String>)>, Error> {
744        let config = self.load_global_config()?;
745
746        let mut result = HashMap::new();
747        for (api_name, api_config) in config.api_configs {
748            result.insert(
749                api_name,
750                (api_config.base_url_override, api_config.environment_urls),
751            );
752        }
753
754        Ok(result)
755    }
756
757    /// Test-only method to add spec from URL with custom timeout
758    #[doc(hidden)]
759    #[allow(clippy::future_not_send)]
760    pub async fn add_spec_from_url_with_timeout(
761        &self,
762        name: &ApiContextName,
763        url: &str,
764        force: bool,
765        timeout: std::time::Duration,
766    ) -> Result<(), Error> {
767        // Default to non-strict mode to match CLI behavior
768        self.add_spec_from_url_with_timeout_and_mode(name.as_str(), url, force, timeout, false)
769            .await
770    }
771
772    /// Test-only method to add spec from URL with custom timeout and validation mode
773    #[doc(hidden)]
774    #[allow(clippy::future_not_send)]
775    async fn add_spec_from_url_with_timeout_and_mode(
776        &self,
777        name: &str,
778        url: &str,
779        force: bool,
780        timeout: std::time::Duration,
781        strict: bool,
782    ) -> Result<(), Error> {
783        self.check_spec_exists(name, force)?;
784
785        // Fetch content from URL with custom timeout
786        let content = fetch_spec_from_url_with_timeout(url, timeout).await?;
787        let openapi_spec = crate::spec::parse_openapi(&content)?;
788
789        // Validate against Aperture's supported feature set using SpecValidator
790        let validator = SpecValidator::new();
791        let validation_result = validator.validate_with_mode(&openapi_spec, strict);
792
793        // Check for errors first
794        if !validation_result.is_valid() {
795            return validation_result.into_result();
796        }
797
798        // Note: Not displaying warnings in test method to avoid polluting test output
799
800        self.add_spec_from_validated_openapi(
801            name,
802            &openapi_spec,
803            &content,
804            &validation_result,
805            strict,
806        )
807    }
808
809    /// Sets a secret configuration for a specific security scheme
810    ///
811    /// # Arguments
812    /// * `api_name` - The name of the API specification
813    /// * `scheme_name` - The name of the security scheme
814    /// * `env_var_name` - The environment variable name containing the secret
815    ///
816    /// # Errors
817    ///
818    /// Returns an error if the spec doesn't exist or config cannot be saved.
819    pub fn set_secret(
820        &self,
821        api_name: &ApiContextName,
822        scheme_name: &str,
823        env_var_name: &str,
824    ) -> Result<(), Error> {
825        let api_name = api_name.as_str();
826        // Verify the spec exists
827        let spec_path = self
828            .config_dir
829            .join(crate::constants::DIR_SPECS)
830            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
831        if !self.fs.exists(&spec_path) {
832            return Err(Error::spec_not_found(api_name));
833        }
834
835        // Load current config
836        let mut config = self.load_global_config()?;
837
838        // Get or create API config
839        let api_config = config
840            .api_configs
841            .entry(api_name.to_string())
842            .or_insert_with(|| ApiConfig {
843                base_url_override: None,
844                environment_urls: HashMap::new(),
845                strict_mode: false,
846                secrets: HashMap::new(),
847                command_mapping: None,
848            });
849
850        // Set the secret
851        api_config.secrets.insert(
852            scheme_name.to_string(),
853            ApertureSecret {
854                source: SecretSource::Env,
855                name: env_var_name.to_string(),
856            },
857        );
858
859        // Save updated config
860        self.save_global_config(&config)?;
861        Ok(())
862    }
863
864    /// Lists configured secrets for an API specification
865    ///
866    /// # Arguments
867    /// * `api_name` - The name of the API specification
868    ///
869    /// # Returns
870    /// A map of scheme names to their secret configurations
871    ///
872    /// # Errors
873    ///
874    /// Returns an error if the spec doesn't exist.
875    pub fn list_secrets(
876        &self,
877        api_name: &ApiContextName,
878    ) -> Result<HashMap<String, ApertureSecret>, Error> {
879        let api_name = api_name.as_str();
880        // Verify the spec exists
881        let spec_path = self
882            .config_dir
883            .join(crate::constants::DIR_SPECS)
884            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
885        if !self.fs.exists(&spec_path) {
886            return Err(Error::spec_not_found(api_name));
887        }
888
889        // Load global config
890        let config = self.load_global_config()?;
891
892        // Get API config secrets
893        let secrets = config
894            .api_configs
895            .get(api_name)
896            .map(|c| c.secrets.clone())
897            .unwrap_or_default();
898
899        Ok(secrets)
900    }
901
902    /// Gets a secret configuration for a specific security scheme
903    ///
904    /// # Arguments
905    /// * `api_name` - The name of the API specification
906    /// * `scheme_name` - The name of the security scheme
907    ///
908    /// # Returns
909    /// The secret configuration if found
910    ///
911    /// # Errors
912    ///
913    /// Returns an error if the spec doesn't exist.
914    pub fn get_secret(
915        &self,
916        api_name: &ApiContextName,
917        scheme_name: &str,
918    ) -> Result<Option<ApertureSecret>, Error> {
919        let secrets = self.list_secrets(api_name)?;
920        Ok(secrets.get(scheme_name).cloned())
921    }
922
923    /// Removes a specific secret configuration for a security scheme
924    ///
925    /// # Arguments
926    /// * `api_name` - The name of the API specification
927    /// * `scheme_name` - The name of the security scheme to remove
928    ///
929    /// # Errors
930    /// Returns an error if the spec doesn't exist or if the scheme is not configured
931    pub fn remove_secret(&self, api_name: &ApiContextName, scheme_name: &str) -> Result<(), Error> {
932        let api_name = api_name.as_str();
933        // Verify the spec exists
934        let spec_path = self
935            .config_dir
936            .join(crate::constants::DIR_SPECS)
937            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
938        if !self.fs.exists(&spec_path) {
939            return Err(Error::spec_not_found(api_name));
940        }
941
942        // Load global config
943        let mut config = self.load_global_config()?;
944
945        // Check if the API has any configured secrets
946        let Some(api_config) = config.api_configs.get_mut(api_name) else {
947            return Err(Error::invalid_config(format!(
948                "No secrets configured for API '{api_name}'"
949            )));
950        };
951
952        // Check if the API config exists but has no secrets
953        if api_config.secrets.is_empty() {
954            return Err(Error::invalid_config(format!(
955                "No secrets configured for API '{api_name}'"
956            )));
957        }
958
959        // Check if the specific scheme exists
960        if !api_config.secrets.contains_key(scheme_name) {
961            return Err(Error::invalid_config(format!(
962                "Secret for scheme '{scheme_name}' is not configured for API '{api_name}'"
963            )));
964        }
965
966        // Remove the secret
967        api_config.secrets.remove(scheme_name);
968
969        // If no meaningful config remains, remove the entire API config entry
970        if api_config.is_empty() {
971            config.api_configs.remove(api_name);
972        }
973
974        // Save the updated config
975        self.save_global_config(&config)?;
976
977        Ok(())
978    }
979
980    /// Removes all secret configurations for an API specification
981    ///
982    /// # Arguments
983    /// * `api_name` - The name of the API specification
984    ///
985    /// # Errors
986    /// Returns an error if the spec doesn't exist
987    pub fn clear_secrets(&self, api_name: &ApiContextName) -> Result<(), Error> {
988        let api_name = api_name.as_str();
989        // Verify the spec exists
990        let spec_path = self
991            .config_dir
992            .join(crate::constants::DIR_SPECS)
993            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
994        if !self.fs.exists(&spec_path) {
995            return Err(Error::spec_not_found(api_name));
996        }
997
998        // Load global config
999        let mut config = self.load_global_config()?;
1000
1001        // Check if the API exists in config
1002        let Some(api_config) = config.api_configs.get_mut(api_name) else {
1003            // If API config doesn't exist, that's fine - no secrets to clear
1004            return Ok(());
1005        };
1006
1007        // Clear all secrets
1008        api_config.secrets.clear();
1009
1010        // If no meaningful config remains, remove the entire API config entry
1011        if api_config.is_empty() {
1012            config.api_configs.remove(api_name);
1013        }
1014
1015        // Save the updated config
1016        self.save_global_config(&config)?;
1017
1018        Ok(())
1019    }
1020
1021    // ---- Command Mapping Management ----
1022
1023    /// Ensures that the API spec file exists for the given context name.
1024    ///
1025    /// # Errors
1026    ///
1027    /// Returns `spec_not_found` if the API specification file does not exist.
1028    fn ensure_spec_exists(&self, api_name: &str) -> Result<(), Error> {
1029        let spec_path = self
1030            .config_dir
1031            .join(crate::constants::DIR_SPECS)
1032            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
1033
1034        if !self.fs.exists(&spec_path) {
1035            return Err(Error::spec_not_found(api_name));
1036        }
1037
1038        Ok(())
1039    }
1040
1041    /// Gets or creates the command mapping for an API, returning a mutable reference
1042    /// to the `GlobalConfig` with the mapping initialized.
1043    fn ensure_command_mapping(
1044        &self,
1045        api_name: &str,
1046    ) -> Result<(crate::config::models::GlobalConfig, String), Error> {
1047        self.ensure_spec_exists(api_name)?;
1048
1049        let mut config = self.load_global_config()?;
1050        let api_config = config
1051            .api_configs
1052            .entry(api_name.to_string())
1053            .or_insert_with(|| ApiConfig {
1054                base_url_override: None,
1055                environment_urls: HashMap::new(),
1056                strict_mode: false,
1057                secrets: HashMap::new(),
1058                command_mapping: None,
1059            });
1060        if api_config.command_mapping.is_none() {
1061            api_config.command_mapping = Some(crate::config::models::CommandMapping::default());
1062        }
1063        Ok((config, api_name.to_string()))
1064    }
1065
1066    /// Sets a group mapping (tag rename) for an API.
1067    ///
1068    /// # Errors
1069    ///
1070    /// Returns an error if the spec doesn't exist or config cannot be saved.
1071    ///
1072    /// # Panics
1073    ///
1074    /// Panics if `ensure_command_mapping` invariants are violated (should never happen).
1075    pub fn set_group_mapping(
1076        &self,
1077        api_name: &ApiContextName,
1078        original_tag: &str,
1079        new_name: &str,
1080    ) -> Result<(), Error> {
1081        if new_name.trim().is_empty() {
1082            return Err(Error::invalid_config("Group name cannot be empty"));
1083        }
1084        let (mut config, key) = self.ensure_command_mapping(api_name.as_str())?;
1085        // Safety: ensure_command_mapping guarantees these exist
1086        let mapping = config
1087            .api_configs
1088            .get_mut(&key)
1089            .expect("ensure_command_mapping guarantees api_config exists")
1090            .command_mapping
1091            .as_mut()
1092            .expect("ensure_command_mapping guarantees command_mapping exists");
1093        mapping
1094            .groups
1095            .insert(original_tag.to_string(), new_name.to_string());
1096        self.save_global_config(&config)
1097    }
1098
1099    /// Removes a group mapping for an API.
1100    ///
1101    /// # Errors
1102    ///
1103    /// Returns an error if the spec doesn't exist or config cannot be saved.
1104    pub fn remove_group_mapping(
1105        &self,
1106        api_name: &ApiContextName,
1107        original_tag: &str,
1108    ) -> Result<(), Error> {
1109        self.ensure_spec_exists(api_name.as_str())?;
1110
1111        let mut config = self.load_global_config()?;
1112        let Some(api_config) = config.api_configs.get_mut(api_name.as_str()) else {
1113            return Ok(());
1114        };
1115        let Some(ref mut mapping) = api_config.command_mapping else {
1116            return Ok(());
1117        };
1118        mapping.groups.remove(original_tag);
1119        self.save_global_config(&config)
1120    }
1121
1122    /// Sets an operation mapping field for an API.
1123    ///
1124    /// # Errors
1125    ///
1126    /// Returns an error if the spec doesn't exist or config cannot be saved.
1127    ///
1128    /// # Panics
1129    ///
1130    /// Panics if `ensure_command_mapping` invariants are violated (should never happen).
1131    pub fn set_operation_mapping(
1132        &self,
1133        api_name: &ApiContextName,
1134        operation_id: &str,
1135        name: Option<&str>,
1136        group: Option<&str>,
1137        alias: Option<&str>,
1138        hidden: Option<bool>,
1139    ) -> Result<(), Error> {
1140        if name.is_some_and(|n| n.trim().is_empty()) {
1141            return Err(Error::invalid_config("Operation name cannot be empty"));
1142        }
1143        if group.is_some_and(|g| g.trim().is_empty()) {
1144            return Err(Error::invalid_config("Operation group cannot be empty"));
1145        }
1146        if alias.is_some_and(|a| a.trim().is_empty()) {
1147            return Err(Error::invalid_config("Alias cannot be empty"));
1148        }
1149
1150        // No-op updates should not create empty mapping entries.
1151        if name.is_none() && group.is_none() && alias.is_none() && hidden.is_none() {
1152            self.ensure_spec_exists(api_name.as_str())?;
1153            return Ok(());
1154        }
1155
1156        let (mut config, key) = self.ensure_command_mapping(api_name.as_str())?;
1157        // Safety: ensure_command_mapping guarantees these exist
1158        let mapping = config
1159            .api_configs
1160            .get_mut(&key)
1161            .expect("ensure_command_mapping guarantees api_config exists")
1162            .command_mapping
1163            .as_mut()
1164            .expect("ensure_command_mapping guarantees command_mapping exists");
1165        let op = mapping
1166            .operations
1167            .entry(operation_id.to_string())
1168            .or_default();
1169
1170        if let Some(n) = name {
1171            op.name = Some(n.to_string());
1172        }
1173        if let Some(g) = group {
1174            op.group = Some(g.to_string());
1175        }
1176        // Add alias if specified and not already present
1177        let alias_str = alias.map(str::to_string);
1178        if alias_str.as_ref().is_some_and(|a| !op.aliases.contains(a)) {
1179            op.aliases
1180                .push(alias_str.expect("checked is_some_and above"));
1181        }
1182        if let Some(h) = hidden {
1183            op.hidden = h;
1184        }
1185
1186        self.save_global_config(&config)
1187    }
1188
1189    /// Removes an operation mapping for an API.
1190    ///
1191    /// # Errors
1192    ///
1193    /// Returns an error if the spec doesn't exist or config cannot be saved.
1194    pub fn remove_operation_mapping(
1195        &self,
1196        api_name: &ApiContextName,
1197        operation_id: &str,
1198    ) -> Result<(), Error> {
1199        self.ensure_spec_exists(api_name.as_str())?;
1200
1201        let mut config = self.load_global_config()?;
1202        let Some(api_config) = config.api_configs.get_mut(api_name.as_str()) else {
1203            return Ok(());
1204        };
1205        let Some(ref mut mapping) = api_config.command_mapping else {
1206            return Ok(());
1207        };
1208        mapping.operations.remove(operation_id);
1209        self.save_global_config(&config)
1210    }
1211
1212    /// Removes a single alias from an operation mapping.
1213    ///
1214    /// If the operation mapping or alias does not exist, this is a no-op.
1215    ///
1216    /// # Errors
1217    ///
1218    /// Returns an error if the config cannot be loaded or saved.
1219    pub fn remove_alias(
1220        &self,
1221        api_name: &ApiContextName,
1222        operation_id: &str,
1223        alias: &str,
1224    ) -> Result<(), Error> {
1225        self.ensure_spec_exists(api_name.as_str())?;
1226
1227        let mut config = self.load_global_config()?;
1228        let Some(api_config) = config.api_configs.get_mut(api_name.as_str()) else {
1229            return Ok(());
1230        };
1231        let Some(ref mut mapping) = api_config.command_mapping else {
1232            return Ok(());
1233        };
1234        let Some(op) = mapping.operations.get_mut(operation_id) else {
1235            return Ok(());
1236        };
1237        op.aliases.retain(|a| a != alias);
1238        self.save_global_config(&config)
1239    }
1240
1241    /// Gets the command mapping for an API, if any.
1242    ///
1243    /// # Errors
1244    ///
1245    /// Returns an error if the config cannot be loaded.
1246    pub fn get_command_mapping(
1247        &self,
1248        api_name: &ApiContextName,
1249    ) -> Result<Option<crate::config::models::CommandMapping>, Error> {
1250        let config = self.load_global_config()?;
1251        Ok(config
1252            .api_configs
1253            .get(api_name.as_str())
1254            .and_then(|c| c.command_mapping.clone()))
1255    }
1256
1257    /// Configure secrets interactively for an API specification
1258    ///
1259    /// Loads the cached spec to discover available security schemes and
1260    /// presents an interactive menu for configuration.
1261    ///
1262    /// # Arguments
1263    /// * `api_name` - The name of the API specification
1264    ///
1265    /// # Errors
1266    ///
1267    /// Returns an error if:
1268    /// - The spec doesn't exist
1269    /// - Cannot load cached spec
1270    /// - User interaction fails
1271    /// - Cannot save configuration
1272    ///
1273    /// # Panics
1274    ///
1275    /// Panics if the selected scheme is not found in the cached spec
1276    /// (this should never happen due to menu validation)
1277    pub fn set_secret_interactive(&self, api_name: &ApiContextName) -> Result<(), Error> {
1278        // Verify the spec exists and load cached spec
1279        let (cached_spec, current_secrets) = self.load_spec_for_interactive_config(api_name)?;
1280
1281        if cached_spec.security_schemes.is_empty() {
1282            // ast-grep-ignore: no-println
1283            println!("No security schemes found in API '{api_name}'.");
1284            return Ok(());
1285        }
1286
1287        Self::display_interactive_header(api_name.as_str(), &cached_spec);
1288
1289        // Create options for selection with rich descriptions
1290        let options = Self::build_security_scheme_options(&cached_spec, &current_secrets);
1291
1292        // Interactive loop for configuration
1293        self.run_interactive_configuration_loop(
1294            api_name.as_str(),
1295            &cached_spec,
1296            &current_secrets,
1297            &options,
1298            api_name,
1299        )?;
1300
1301        // ast-grep-ignore: no-println
1302        println!("\nInteractive configuration complete!");
1303        Ok(())
1304    }
1305
1306    /// Checks if a spec already exists and handles force flag
1307    ///
1308    /// # Errors
1309    ///
1310    /// Returns an error if the spec already exists and force is false
1311    fn check_spec_exists(&self, name: &str, force: bool) -> Result<(), Error> {
1312        let spec_path = self
1313            .config_dir
1314            .join(crate::constants::DIR_SPECS)
1315            .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
1316
1317        if self.fs.exists(&spec_path) && !force {
1318            return Err(Error::spec_already_exists(name));
1319        }
1320
1321        Ok(())
1322    }
1323
1324    /// Transforms an `OpenAPI` spec into cached representation
1325    ///
1326    /// # Errors
1327    ///
1328    /// Returns an error if transformation fails
1329    fn transform_spec_to_cached(
1330        name: &str,
1331        openapi_spec: &OpenAPI,
1332        validation_result: &crate::spec::validator::ValidationResult,
1333    ) -> Result<crate::cache::models::CachedSpec, Error> {
1334        let transformer = SpecTransformer::new();
1335
1336        // Convert warnings to skip_endpoints format - skip endpoints with unsupported content types or auth
1337        let skip_endpoints: Vec<(String, String)> = validation_result
1338            .warnings
1339            .iter()
1340            .filter_map(super::super::spec::validator::ValidationWarning::to_skip_endpoint)
1341            .collect();
1342
1343        transformer.transform_with_warnings(
1344            name,
1345            openapi_spec,
1346            &skip_endpoints,
1347            &validation_result.warnings,
1348        )
1349    }
1350
1351    /// Creates necessary directories for spec and cache files
1352    ///
1353    /// # Errors
1354    ///
1355    /// Returns an error if directory creation fails
1356    fn create_spec_directories(&self, name: &str) -> Result<(PathBuf, PathBuf), Error> {
1357        let spec_path = self
1358            .config_dir
1359            .join(crate::constants::DIR_SPECS)
1360            .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
1361        let cache_path = self
1362            .config_dir
1363            .join(crate::constants::DIR_CACHE)
1364            .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
1365
1366        let spec_parent = spec_path.parent().ok_or_else(|| {
1367            Error::invalid_path(
1368                spec_path.display().to_string(),
1369                "Path has no parent directory",
1370            )
1371        })?;
1372        let cache_parent = cache_path.parent().ok_or_else(|| {
1373            Error::invalid_path(
1374                cache_path.display().to_string(),
1375                "Path has no parent directory",
1376            )
1377        })?;
1378
1379        self.fs.create_dir_all(spec_parent)?;
1380        self.fs.create_dir_all(cache_parent)?;
1381
1382        Ok((spec_path, cache_path))
1383    }
1384
1385    /// Writes spec and cache files to disk
1386    ///
1387    /// # Errors
1388    ///
1389    /// Returns an error if file operations fail
1390    fn write_spec_files(
1391        &self,
1392        name: &str,
1393        content: &str,
1394        cached_spec: &crate::cache::models::CachedSpec,
1395        spec_path: &Path,
1396        cache_path: &Path,
1397    ) -> Result<(), Error> {
1398        // Write original spec file atomically
1399        self.fs.atomic_write(spec_path, content.as_bytes())?;
1400
1401        // Serialize and write cached representation atomically
1402        let cached_data = postcard::to_allocvec(cached_spec)
1403            .map_err(|e| Error::serialization_error(e.to_string()))?;
1404        self.fs.atomic_write(cache_path, &cached_data)?;
1405
1406        // Compute fingerprint for cache invalidation
1407        let content_hash = compute_content_hash(content.as_bytes());
1408        let spec_file_size = content.len() as u64;
1409        let mtime_secs = get_file_mtime_secs(spec_path);
1410
1411        // Update cache metadata with fingerprint for optimized version checking
1412        let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
1413        let metadata_manager = CacheMetadataManager::new(&self.fs);
1414        metadata_manager.update_spec_metadata_with_fingerprint(
1415            &cache_dir,
1416            name,
1417            cached_data.len() as u64,
1418            Some(content_hash),
1419            mtime_secs,
1420            Some(spec_file_size),
1421        )?;
1422
1423        Ok(())
1424    }
1425
1426    /// Common logic for adding a spec from a validated `OpenAPI` object
1427    ///
1428    /// # Errors
1429    ///
1430    /// Returns an error if transformation or file operations fail
1431    fn add_spec_from_validated_openapi(
1432        &self,
1433        name: &str,
1434        openapi_spec: &OpenAPI,
1435        content: &str,
1436        validation_result: &crate::spec::validator::ValidationResult,
1437        strict: bool,
1438    ) -> Result<(), Error> {
1439        // Transform to cached representation
1440        let mut cached_spec =
1441            Self::transform_spec_to_cached(name, openapi_spec, validation_result)?;
1442
1443        // Apply command mappings from config (if any)
1444        self.apply_command_mapping_if_configured(name, &mut cached_spec)?;
1445
1446        // Create directories
1447        let (spec_path, cache_path) = self.create_spec_directories(name)?;
1448
1449        // Write files
1450        self.write_spec_files(name, content, &cached_spec, &spec_path, &cache_path)?;
1451
1452        // Save strict mode preference
1453        self.save_strict_preference(name, strict)?;
1454
1455        Ok(())
1456    }
1457
1458    /// Loads and applies command mappings from config for the given API.
1459    ///
1460    /// If no mapping is configured, this is a no-op.
1461    /// Stale mapping warnings are printed to stderr.
1462    fn apply_command_mapping_if_configured(
1463        &self,
1464        name: &str,
1465        cached_spec: &mut crate::cache::models::CachedSpec,
1466    ) -> Result<(), Error> {
1467        let config = self.load_global_config()?;
1468        let Some(api_config) = config.api_configs.get(name) else {
1469            return Ok(());
1470        };
1471        let Some(ref mapping) = api_config.command_mapping else {
1472            return Ok(());
1473        };
1474
1475        let result =
1476            crate::config::mapping::apply_command_mapping(&mut cached_spec.commands, mapping)?;
1477
1478        for warning in &result.warnings {
1479            // ast-grep-ignore: no-println
1480            eprintln!("{} {warning}", crate::constants::MSG_WARNING_PREFIX);
1481        }
1482
1483        Ok(())
1484    }
1485
1486    /// Loads spec and current secrets for interactive configuration
1487    ///
1488    /// # Errors
1489    ///
1490    /// Returns an error if the spec doesn't exist or cannot be loaded
1491    fn load_spec_for_interactive_config(
1492        &self,
1493        api_name: &ApiContextName,
1494    ) -> Result<
1495        (
1496            crate::cache::models::CachedSpec,
1497            std::collections::HashMap<String, ApertureSecret>,
1498        ),
1499        Error,
1500    > {
1501        // Verify the spec exists
1502        let spec_path = self
1503            .config_dir
1504            .join(crate::constants::DIR_SPECS)
1505            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
1506        if !self.fs.exists(&spec_path) {
1507            return Err(Error::spec_not_found(api_name.as_str()));
1508        }
1509
1510        // Load cached spec to get security schemes
1511        let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
1512        let cached_spec = loader::load_cached_spec(&cache_dir, api_name.as_str())?;
1513
1514        // Get current configuration
1515        let current_secrets = self.list_secrets(api_name)?;
1516
1517        Ok((cached_spec, current_secrets))
1518    }
1519
1520    /// Displays the interactive configuration header
1521    fn display_interactive_header(api_name: &str, cached_spec: &crate::cache::models::CachedSpec) {
1522        // ast-grep-ignore: no-println
1523        println!("Interactive Secret Configuration for API: {api_name}");
1524        // ast-grep-ignore: no-println
1525        println!(
1526            "Found {} security scheme(s):\n",
1527            cached_spec.security_schemes.len()
1528        );
1529    }
1530
1531    /// Builds options for security scheme selection with rich descriptions
1532    fn build_security_scheme_options(
1533        cached_spec: &crate::cache::models::CachedSpec,
1534        current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1535    ) -> Vec<(String, String)> {
1536        cached_spec
1537            .security_schemes
1538            .values()
1539            .map(|scheme| {
1540                let mut description = format!("{} ({})", scheme.scheme_type, scheme.name);
1541
1542                // Add type-specific details
1543                match scheme.scheme_type.as_str() {
1544                    constants::AUTH_SCHEME_APIKEY => {
1545                        if let (Some(location), Some(param)) =
1546                            (&scheme.location, &scheme.parameter_name)
1547                        {
1548                            description = format!("{description} - {location} parameter: {param}");
1549                        }
1550                    }
1551                    "http" => {
1552                        if let Some(http_scheme) = &scheme.scheme {
1553                            description = format!("{description} - {http_scheme} authentication");
1554                        }
1555                    }
1556                    _ => {}
1557                }
1558
1559                // Show current configuration status - use match to avoid nested if
1560                description = match (
1561                    current_secrets.contains_key(&scheme.name),
1562                    &scheme.aperture_secret,
1563                ) {
1564                    (true, _) => format!("{description} [CONFIGURED]"),
1565                    (false, Some(_)) => format!("{description} [x-aperture-secret]"),
1566                    (false, None) => format!("{description} [NOT CONFIGURED]"),
1567                };
1568
1569                // Add OpenAPI description if available
1570                if let Some(openapi_desc) = &scheme.description {
1571                    description = format!("{description} - {openapi_desc}");
1572                }
1573
1574                (scheme.name.clone(), description)
1575            })
1576            .collect()
1577    }
1578
1579    /// Runs the interactive configuration loop
1580    ///
1581    /// # Errors
1582    ///
1583    /// Returns an error if user interaction fails or configuration cannot be saved
1584    fn run_interactive_configuration_loop(
1585        &self,
1586        api_name: &str,
1587        cached_spec: &crate::cache::models::CachedSpec,
1588        current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1589        options: &[(String, String)],
1590        validated_name: &ApiContextName,
1591    ) -> Result<(), Error> {
1592        loop {
1593            let selected_scheme =
1594                select_from_options("\nSelect a security scheme to configure:", options)?;
1595
1596            let scheme = cached_spec.security_schemes.get(&selected_scheme).expect(
1597                "Selected scheme should exist in cached spec - menu validation ensures this",
1598            );
1599
1600            Self::display_scheme_configuration_details(&selected_scheme, scheme, current_secrets);
1601
1602            // Prompt for environment variable
1603            let env_var = prompt_for_input(&format!(
1604                "\nEnter environment variable name for '{selected_scheme}' (or press Enter to skip): "
1605            ))?;
1606
1607            if env_var.is_empty() {
1608                // ast-grep-ignore: no-println
1609                println!("Skipping configuration for '{selected_scheme}'");
1610            } else {
1611                self.handle_secret_configuration(
1612                    api_name,
1613                    &selected_scheme,
1614                    &env_var,
1615                    validated_name,
1616                )?;
1617            }
1618
1619            // Ask if user wants to configure another scheme
1620            if !confirm("\nConfigure another security scheme?")? {
1621                break;
1622            }
1623        }
1624
1625        Ok(())
1626    }
1627
1628    /// Displays configuration details for a selected security scheme
1629    fn display_scheme_configuration_details(
1630        selected_scheme: &str,
1631        scheme: &crate::cache::models::CachedSecurityScheme,
1632        current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1633    ) {
1634        // ast-grep-ignore: no-println
1635        println!("\nConfiguration for '{selected_scheme}':");
1636        // ast-grep-ignore: no-println
1637        println!("   Type: {}", scheme.scheme_type);
1638        if let Some(desc) = &scheme.description {
1639            // ast-grep-ignore: no-println
1640            println!("   Description: {desc}");
1641        }
1642
1643        // Show current configuration - use pattern matching to avoid nested if
1644        match (
1645            current_secrets.get(selected_scheme),
1646            &scheme.aperture_secret,
1647        ) {
1648            (Some(current_secret), _) => {
1649                // ast-grep-ignore: no-println
1650                println!("   Current: environment variable '{}'", current_secret.name);
1651            }
1652            (None, Some(aperture_secret)) => {
1653                // ast-grep-ignore: no-println
1654                println!(
1655                    "   Current: x-aperture-secret -> '{}'",
1656                    aperture_secret.name
1657                );
1658            }
1659            (None, None) => {
1660                // ast-grep-ignore: no-println
1661                println!("   Current: not configured");
1662            }
1663        }
1664    }
1665
1666    /// Handles secret configuration validation and saving
1667    ///
1668    /// # Errors
1669    ///
1670    /// Returns an error if validation fails or configuration cannot be saved
1671    fn handle_secret_configuration(
1672        &self,
1673        api_name: &str,
1674        selected_scheme: &str,
1675        env_var: &str,
1676        validated_name: &ApiContextName,
1677    ) -> Result<(), Error> {
1678        // Validate environment variable name using the comprehensive validator
1679        if let Err(e) = crate::interactive::validate_env_var_name(env_var) {
1680            // ast-grep-ignore: no-println
1681            println!("Invalid environment variable name: {e}");
1682            return Ok(()); // Continue the loop, don't fail completely
1683        }
1684
1685        // Show preview and confirm
1686        // ast-grep-ignore: no-println
1687        println!("\nConfiguration Preview:");
1688        // ast-grep-ignore: no-println
1689        println!("   API: {api_name}");
1690        // ast-grep-ignore: no-println
1691        println!("   Scheme: {selected_scheme}");
1692        // ast-grep-ignore: no-println
1693        println!("   Environment Variable: {env_var}");
1694
1695        if confirm("Apply this configuration?")? {
1696            self.set_secret(validated_name, selected_scheme, env_var)?;
1697            // ast-grep-ignore: no-println
1698            println!("Configuration saved successfully!");
1699        } else {
1700            // ast-grep-ignore: no-println
1701            println!("Configuration cancelled.");
1702        }
1703
1704        Ok(())
1705    }
1706
1707    /// Categorizes warnings by type for better formatting
1708    fn categorize_warnings(
1709        warnings: &[crate::spec::validator::ValidationWarning],
1710    ) -> CategorizedWarnings<'_> {
1711        let mut categorized = CategorizedWarnings {
1712            content_type: Vec::new(),
1713            auth: Vec::new(),
1714            mixed_content: Vec::new(),
1715        };
1716
1717        for warning in warnings {
1718            // Pattern match on reason content to avoid nested if-else chain
1719            if warning.reason.contains("no supported content types") {
1720                categorized.content_type.push(warning);
1721                continue;
1722            }
1723
1724            if warning.reason.contains("unsupported authentication") {
1725                categorized.auth.push(warning);
1726                continue;
1727            }
1728
1729            if warning
1730                .reason
1731                .contains("unsupported content types alongside JSON")
1732            {
1733                categorized.mixed_content.push(warning);
1734            }
1735        }
1736
1737        categorized
1738    }
1739
1740    /// Formats content type warnings
1741    fn format_content_type_warnings(
1742        lines: &mut Vec<String>,
1743        content_type_warnings: &[&crate::spec::validator::ValidationWarning],
1744        total_operations: Option<usize>,
1745        total_skipped: usize,
1746        indent: &str,
1747    ) {
1748        if content_type_warnings.is_empty() {
1749            return;
1750        }
1751
1752        let warning_msg = total_operations.map_or_else(
1753            || {
1754                format!(
1755                    "{}Skipping {} endpoints with unsupported content types:",
1756                    indent,
1757                    content_type_warnings.len()
1758                )
1759            },
1760            |total| {
1761                let available = total.saturating_sub(total_skipped);
1762                format!(
1763                    "{}Skipping {} endpoints with unsupported content types ({} of {} endpoints will be available):",
1764                    indent,
1765                    content_type_warnings.len(),
1766                    available,
1767                    total
1768                )
1769            },
1770        );
1771        lines.push(warning_msg);
1772
1773        for warning in content_type_warnings {
1774            lines.push(format!(
1775                "{}  - {} {} ({}) - {}",
1776                indent,
1777                warning.endpoint.method,
1778                warning.endpoint.path,
1779                warning.endpoint.content_type,
1780                warning.reason
1781            ));
1782        }
1783    }
1784
1785    /// Formats authentication warnings
1786    fn format_auth_warnings(
1787        lines: &mut Vec<String>,
1788        auth_warnings: &[&crate::spec::validator::ValidationWarning],
1789        total_operations: Option<usize>,
1790        total_skipped: usize,
1791        indent: &str,
1792        add_blank_line: bool,
1793    ) {
1794        if auth_warnings.is_empty() {
1795            return;
1796        }
1797
1798        if add_blank_line {
1799            lines.push(String::new()); // Add blank line between sections
1800        }
1801
1802        let warning_msg = total_operations.map_or_else(
1803            || {
1804                format!(
1805                    "{}Skipping {} endpoints with unsupported authentication:",
1806                    indent,
1807                    auth_warnings.len()
1808                )
1809            },
1810            |total| {
1811                let available = total.saturating_sub(total_skipped);
1812                format!(
1813                    "{}Skipping {} endpoints with unsupported authentication ({} of {} endpoints will be available):",
1814                    indent,
1815                    auth_warnings.len(),
1816                    available,
1817                    total
1818                )
1819            },
1820        );
1821        lines.push(warning_msg);
1822
1823        for warning in auth_warnings {
1824            lines.push(format!(
1825                "{}  - {} {} - {}",
1826                indent, warning.endpoint.method, warning.endpoint.path, warning.reason
1827            ));
1828        }
1829    }
1830
1831    /// Formats mixed content warnings
1832    fn format_mixed_content_warnings(
1833        lines: &mut Vec<String>,
1834        mixed_content_warnings: &[&crate::spec::validator::ValidationWarning],
1835        indent: &str,
1836        add_blank_line: bool,
1837    ) {
1838        if mixed_content_warnings.is_empty() {
1839            return;
1840        }
1841
1842        if add_blank_line {
1843            lines.push(String::new()); // Add blank line between sections
1844        }
1845
1846        lines.push(format!(
1847            "{indent}Endpoints with partial content type support:"
1848        ));
1849        for warning in mixed_content_warnings {
1850            lines.push(format!(
1851                "{}  - {} {} supports JSON but not: {}",
1852                indent,
1853                warning.endpoint.method,
1854                warning.endpoint.path,
1855                warning.endpoint.content_type
1856            ));
1857        }
1858    }
1859}
1860
1861/// Gets the default configuration directory path.
1862///
1863/// # Errors
1864///
1865/// Returns an error if the home directory cannot be determined.
1866pub fn get_config_dir() -> Result<PathBuf, Error> {
1867    let home_dir = dirs::home_dir().ok_or_else(Error::home_directory_not_found)?;
1868    let config_dir = home_dir.join(".config").join("aperture");
1869    Ok(config_dir)
1870}
1871
1872/// Determines if the input string is a URL (starts with http:// or https://)
1873#[must_use]
1874pub fn is_url(input: &str) -> bool {
1875    input.starts_with("http://") || input.starts_with("https://")
1876}
1877
1878/// Fetches `OpenAPI` specification content from a URL with security limits
1879///
1880/// # Errors
1881///
1882/// Returns an error if:
1883/// - Network request fails
1884/// - Response status is not successful
1885/// - Response size exceeds 10MB limit
1886/// - Request times out (30 seconds)
1887const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
1888
1889#[allow(clippy::future_not_send)]
1890async fn fetch_spec_from_url(url: &str) -> Result<String, Error> {
1891    fetch_spec_from_url_with_timeout(url, std::time::Duration::from_secs(30)).await
1892}
1893
1894#[allow(clippy::future_not_send)]
1895async fn fetch_spec_from_url_with_timeout(
1896    url: &str,
1897    timeout: std::time::Duration,
1898) -> Result<String, Error> {
1899    // Create HTTP client with timeout and security limits
1900    let client = reqwest::Client::builder()
1901        .timeout(timeout)
1902        .build()
1903        .map_err(|e| Error::network_request_failed(format!("Failed to create HTTP client: {e}")))?;
1904
1905    // Make the request
1906    let response = client.get(url).send().await.map_err(|e| {
1907        // Use early returns to avoid nested if-else chain
1908        if e.is_timeout() {
1909            return Error::network_request_failed(format!(
1910                "Request timed out after {} seconds",
1911                timeout.as_secs()
1912            ));
1913        }
1914
1915        if e.is_connect() {
1916            return Error::network_request_failed(format!("Failed to connect to {url}: {e}"));
1917        }
1918
1919        Error::network_request_failed(format!("Network error: {e}"))
1920    })?;
1921
1922    // Check response status
1923    if !response.status().is_success() {
1924        return Err(Error::request_failed(
1925            response.status(),
1926            format!("HTTP {} from {url}", response.status()),
1927        ));
1928    }
1929
1930    // Check content length before downloading - use let-else to flatten
1931    let Some(content_length) = response.content_length() else {
1932        // No content length header, proceed to download with size check later
1933        return download_and_validate_response(response).await;
1934    };
1935
1936    if content_length > MAX_RESPONSE_SIZE {
1937        return Err(Error::network_request_failed(format!(
1938            "Response too large: {content_length} bytes (max {MAX_RESPONSE_SIZE} bytes)"
1939        )));
1940    }
1941
1942    download_and_validate_response(response).await
1943}
1944
1945/// Helper function to download and validate response body
1946#[allow(clippy::future_not_send)]
1947async fn download_and_validate_response(response: reqwest::Response) -> Result<String, Error> {
1948    // Read response body with size limit
1949    let bytes = response
1950        .bytes()
1951        .await
1952        .map_err(|e| Error::network_request_failed(format!("Failed to read response body: {e}")))?;
1953
1954    // Double-check size after download
1955    if bytes.len() > usize::try_from(MAX_RESPONSE_SIZE).unwrap_or(usize::MAX) {
1956        return Err(Error::network_request_failed(format!(
1957            "Response too large: {} bytes (max {MAX_RESPONSE_SIZE} bytes)",
1958            bytes.len()
1959        )));
1960    }
1961
1962    // Convert to string
1963    String::from_utf8(bytes.to_vec())
1964        .map_err(|e| Error::network_request_failed(format!("Invalid UTF-8 in response: {e}")))
1965}