aperture_cli/config/
manager.rs

1use crate::cache::metadata::CacheMetadataManager;
2use crate::config::models::{ApertureSecret, ApiConfig, GlobalConfig, SecretSource};
3use crate::config::url_resolver::BaseUrlResolver;
4use crate::constants;
5use crate::engine::loader;
6use crate::error::Error;
7use crate::fs::{FileSystem, OsFileSystem};
8use crate::interactive::{confirm, prompt_for_input, select_from_options};
9use crate::spec::{SpecTransformer, SpecValidator};
10use openapiv3::{OpenAPI, ReferenceOr};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15/// Struct to hold categorized validation warnings
16struct CategorizedWarnings<'a> {
17    content_type: Vec<&'a crate::spec::validator::ValidationWarning>,
18    auth: Vec<&'a crate::spec::validator::ValidationWarning>,
19    mixed_content: Vec<&'a crate::spec::validator::ValidationWarning>,
20}
21
22pub struct ConfigManager<F: FileSystem> {
23    fs: F,
24    config_dir: PathBuf,
25}
26
27impl ConfigManager<OsFileSystem> {
28    /// Creates a new `ConfigManager` with the default filesystem and config directory.
29    ///
30    /// # Errors
31    ///
32    /// Returns an error if the home directory cannot be determined.
33    pub fn new() -> Result<Self, Error> {
34        let config_dir = get_config_dir()?;
35        Ok(Self {
36            fs: OsFileSystem,
37            config_dir,
38        })
39    }
40}
41
42impl<F: FileSystem> ConfigManager<F> {
43    pub const fn with_fs(fs: F, config_dir: PathBuf) -> Self {
44        Self { fs, config_dir }
45    }
46
47    /// Get the configuration directory path
48    pub fn config_dir(&self) -> &Path {
49        &self.config_dir
50    }
51
52    /// Convert skipped endpoints to validation warnings for display
53    #[must_use]
54    pub fn skipped_endpoints_to_warnings(
55        skipped_endpoints: &[crate::cache::models::SkippedEndpoint],
56    ) -> Vec<crate::spec::validator::ValidationWarning> {
57        skipped_endpoints
58            .iter()
59            .map(|endpoint| crate::spec::validator::ValidationWarning {
60                endpoint: crate::spec::validator::UnsupportedEndpoint {
61                    path: endpoint.path.clone(),
62                    method: endpoint.method.clone(),
63                    content_type: endpoint.content_type.clone(),
64                },
65                reason: endpoint.reason.clone(),
66            })
67            .collect()
68    }
69
70    /// Save the strict mode preference for an API
71    fn save_strict_preference(&self, api_name: &str, strict: bool) -> Result<(), Error> {
72        let mut config = self.load_global_config()?;
73        let api_config = config
74            .api_configs
75            .entry(api_name.to_string())
76            .or_insert_with(|| ApiConfig {
77                base_url_override: None,
78                environment_urls: HashMap::new(),
79                strict_mode: false,
80                secrets: HashMap::new(),
81            });
82        api_config.strict_mode = strict;
83        self.save_global_config(&config)?;
84        Ok(())
85    }
86
87    /// Get the strict mode preference for an API
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the global config cannot be loaded
92    pub fn get_strict_preference(&self, api_name: &str) -> Result<bool, Error> {
93        let config = self.load_global_config()?;
94        Ok(config
95            .api_configs
96            .get(api_name)
97            .is_some_and(|c| c.strict_mode))
98    }
99
100    /// Count total operations in an `OpenAPI` spec
101    fn count_total_operations(spec: &OpenAPI) -> usize {
102        spec.paths
103            .iter()
104            .filter_map(|(_, path_item)| match path_item {
105                ReferenceOr::Item(item) => Some(item),
106                ReferenceOr::Reference { .. } => None,
107            })
108            .map(|item| {
109                let mut count = 0;
110                if item.get.is_some() {
111                    count += 1;
112                }
113                if item.post.is_some() {
114                    count += 1;
115                }
116                if item.put.is_some() {
117                    count += 1;
118                }
119                if item.delete.is_some() {
120                    count += 1;
121                }
122                if item.patch.is_some() {
123                    count += 1;
124                }
125                if item.head.is_some() {
126                    count += 1;
127                }
128                if item.options.is_some() {
129                    count += 1;
130                }
131                if item.trace.is_some() {
132                    count += 1;
133                }
134                count
135            })
136            .sum()
137    }
138
139    /// Display validation warnings with custom prefix
140    #[must_use]
141    pub fn format_validation_warnings(
142        warnings: &[crate::spec::validator::ValidationWarning],
143        total_operations: Option<usize>,
144        indent: &str,
145    ) -> Vec<String> {
146        let mut lines = Vec::new();
147
148        if !warnings.is_empty() {
149            let categorized_warnings = Self::categorize_warnings(warnings);
150            let total_skipped =
151                categorized_warnings.content_type.len() + categorized_warnings.auth.len();
152
153            Self::format_content_type_warnings(
154                &mut lines,
155                &categorized_warnings.content_type,
156                total_operations,
157                total_skipped,
158                indent,
159            );
160            Self::format_auth_warnings(
161                &mut lines,
162                &categorized_warnings.auth,
163                total_operations,
164                total_skipped,
165                indent,
166                !categorized_warnings.content_type.is_empty(),
167            );
168            Self::format_mixed_content_warnings(
169                &mut lines,
170                &categorized_warnings.mixed_content,
171                indent,
172                !categorized_warnings.content_type.is_empty()
173                    || !categorized_warnings.auth.is_empty(),
174            );
175        }
176
177        lines
178    }
179
180    /// Display validation warnings to stderr
181    pub fn display_validation_warnings(
182        warnings: &[crate::spec::validator::ValidationWarning],
183        total_operations: Option<usize>,
184    ) {
185        if !warnings.is_empty() {
186            let lines = Self::format_validation_warnings(warnings, total_operations, "");
187            for line in lines {
188                if line.is_empty() {
189                    eprintln!();
190                } else if line.starts_with("Skipping") || line.starts_with("Endpoints") {
191                    eprintln!("{} {line}", crate::constants::MSG_WARNING_PREFIX);
192                } else {
193                    eprintln!("{line}");
194                }
195            }
196            eprintln!("\nUse --strict to reject specs with unsupported features.");
197        }
198    }
199
200    /// Adds a new `OpenAPI` specification to the configuration from a local file.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if:
205    /// - The spec already exists and `force` is false
206    /// - File I/O operations fail
207    /// - The `OpenAPI` spec is invalid YAML
208    /// - The spec contains unsupported features
209    ///
210    /// # Panics
211    ///
212    /// Panics if the spec path parent directory is None (should not happen in normal usage).
213    pub fn add_spec(
214        &self,
215        name: &str,
216        file_path: &Path,
217        force: bool,
218        strict: bool,
219    ) -> Result<(), Error> {
220        self.check_spec_exists(name, force)?;
221
222        let content = self.fs.read_to_string(file_path)?;
223        let openapi_spec = crate::spec::parse_openapi(&content)?;
224
225        // Validate against Aperture's supported feature set using SpecValidator
226        let validator = SpecValidator::new();
227        let validation_result = validator.validate_with_mode(&openapi_spec, strict);
228
229        // Check for errors first
230        if !validation_result.is_valid() {
231            return validation_result.into_result();
232        }
233
234        // Count total operations for better UX
235        let total_operations = Self::count_total_operations(&openapi_spec);
236
237        // Display warnings if any
238        Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
239
240        self.add_spec_from_validated_openapi(
241            name,
242            &openapi_spec,
243            &content,
244            &validation_result,
245            strict,
246        )
247    }
248
249    /// Adds a new `OpenAPI` specification to the configuration from a URL.
250    ///
251    /// # Errors
252    ///
253    /// Returns an error if:
254    /// - The spec already exists and `force` is false
255    /// - Network requests fail
256    /// - The `OpenAPI` spec is invalid YAML
257    /// - The spec contains unsupported features
258    /// - Response size exceeds 10MB limit
259    /// - Request times out (30 seconds)
260    ///
261    /// # Panics
262    ///
263    /// Panics if the spec path parent directory is None (should not happen in normal usage).
264    #[allow(clippy::future_not_send)]
265    pub async fn add_spec_from_url(
266        &self,
267        name: &str,
268        url: &str,
269        force: bool,
270        strict: bool,
271    ) -> Result<(), Error> {
272        self.check_spec_exists(name, force)?;
273
274        // Fetch content from URL
275        let content = fetch_spec_from_url(url).await?;
276        let openapi_spec = crate::spec::parse_openapi(&content)?;
277
278        // Validate against Aperture's supported feature set using SpecValidator
279        let validator = SpecValidator::new();
280        let validation_result = validator.validate_with_mode(&openapi_spec, strict);
281
282        // Check for errors first
283        if !validation_result.is_valid() {
284            return validation_result.into_result();
285        }
286
287        // Count total operations for better UX
288        let total_operations = Self::count_total_operations(&openapi_spec);
289
290        // Display warnings if any
291        Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
292
293        self.add_spec_from_validated_openapi(
294            name,
295            &openapi_spec,
296            &content,
297            &validation_result,
298            strict,
299        )
300    }
301
302    /// Adds a new `OpenAPI` specification from either a file path or URL.
303    ///
304    /// This is a convenience method that automatically detects whether the input
305    /// is a URL or file path and calls the appropriate method.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if:
310    /// - The spec already exists and `force` is false
311    /// - File I/O operations fail (for local files)
312    /// - Network requests fail (for URLs)
313    /// - The `OpenAPI` spec is invalid YAML
314    /// - The spec contains unsupported features
315    /// - Response size exceeds 10MB limit (for URLs)
316    /// - Request times out (for URLs)
317    #[allow(clippy::future_not_send)]
318    pub async fn add_spec_auto(
319        &self,
320        name: &str,
321        file_or_url: &str,
322        force: bool,
323        strict: bool,
324    ) -> Result<(), Error> {
325        if is_url(file_or_url) {
326            self.add_spec_from_url(name, file_or_url, force, strict)
327                .await
328        } else {
329            // Convert file path string to Path and call sync method
330            let path = std::path::Path::new(file_or_url);
331            self.add_spec(name, path, force, strict)
332        }
333    }
334
335    /// Lists all registered API contexts.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if the specs directory cannot be read.
340    pub fn list_specs(&self) -> Result<Vec<String>, Error> {
341        let specs_dir = self.config_dir.join(crate::constants::DIR_SPECS);
342        if !self.fs.exists(&specs_dir) {
343            return Ok(Vec::new());
344        }
345
346        let mut specs = Vec::new();
347        for entry in self.fs.read_dir(&specs_dir)? {
348            if self.fs.is_file(&entry) {
349                if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
350                    if std::path::Path::new(file_name)
351                        .extension()
352                        .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
353                    {
354                        specs.push(
355                            file_name
356                                .trim_end_matches(crate::constants::FILE_EXT_YAML)
357                                .to_string(),
358                        );
359                    }
360                }
361            }
362        }
363        Ok(specs)
364    }
365
366    /// Removes an API specification from the configuration.
367    ///
368    /// # Errors
369    ///
370    /// Returns an error if the spec does not exist or cannot be removed.
371    pub fn remove_spec(&self, name: &str) -> Result<(), Error> {
372        let spec_path = self
373            .config_dir
374            .join(crate::constants::DIR_SPECS)
375            .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
376        let cache_path = self
377            .config_dir
378            .join(crate::constants::DIR_CACHE)
379            .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
380
381        if !self.fs.exists(&spec_path) {
382            return Err(Error::spec_not_found(name));
383        }
384
385        self.fs.remove_file(&spec_path)?;
386        if self.fs.exists(&cache_path) {
387            self.fs.remove_file(&cache_path)?;
388        }
389
390        // Remove from cache metadata
391        let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
392        let metadata_manager = CacheMetadataManager::new(&self.fs);
393        // Ignore errors if metadata removal fails - the important files are already removed
394        let _ = metadata_manager.remove_spec_metadata(&cache_dir, name);
395
396        Ok(())
397    }
398
399    /// Opens an API specification in the default editor.
400    ///
401    /// # Errors
402    ///
403    /// Returns an error if:
404    /// - The spec does not exist.
405    /// - The `$EDITOR` environment variable is not set.
406    /// - The editor command fails to execute.
407    pub fn edit_spec(&self, name: &str) -> Result<(), Error> {
408        let spec_path = self
409            .config_dir
410            .join(crate::constants::DIR_SPECS)
411            .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
412
413        if !self.fs.exists(&spec_path) {
414            return Err(Error::spec_not_found(name));
415        }
416
417        let editor = std::env::var("EDITOR").map_err(|_| Error::editor_not_set())?;
418
419        Command::new(editor)
420            .arg(&spec_path)
421            .status()
422            .map_err(|e| Error::io_error(format!("Failed to get editor process status: {e}")))?
423            .success()
424            .then_some(()) // Convert bool to Option<()>
425            .ok_or_else(|| Error::editor_failed(name))
426    }
427
428    /// Loads the global configuration from `config.toml`.
429    ///
430    /// # Errors
431    ///
432    /// Returns an error if the configuration file exists but cannot be read or parsed.
433    pub fn load_global_config(&self) -> Result<GlobalConfig, Error> {
434        let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
435        if self.fs.exists(&config_path) {
436            let content = self.fs.read_to_string(&config_path)?;
437            toml::from_str(&content).map_err(|e| Error::invalid_config(e.to_string()))
438        } else {
439            Ok(GlobalConfig::default())
440        }
441    }
442
443    /// Saves the global configuration to `config.toml`.
444    ///
445    /// # Errors
446    ///
447    /// Returns an error if the configuration cannot be serialized or written.
448    pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
449        let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
450
451        // Ensure config directory exists
452        self.fs.create_dir_all(&self.config_dir)?;
453
454        let content = toml::to_string_pretty(config)
455            .map_err(|e| Error::serialization_error(format!("Failed to serialize config: {e}")))?;
456
457        self.fs.write_all(&config_path, content.as_bytes())?;
458        Ok(())
459    }
460
461    /// Sets the base URL for an API specification.
462    ///
463    /// # Arguments
464    /// * `api_name` - The name of the API specification
465    /// * `url` - The base URL to set
466    /// * `environment` - Optional environment name for environment-specific URLs
467    ///
468    /// # Errors
469    ///
470    /// Returns an error if the spec doesn't exist or config cannot be saved.
471    pub fn set_url(
472        &self,
473        api_name: &str,
474        url: &str,
475        environment: Option<&str>,
476    ) -> Result<(), Error> {
477        // Verify the spec exists
478        let spec_path = self
479            .config_dir
480            .join(crate::constants::DIR_SPECS)
481            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
482        if !self.fs.exists(&spec_path) {
483            return Err(Error::spec_not_found(api_name));
484        }
485
486        // Load current config
487        let mut config = self.load_global_config()?;
488
489        // Get or create API config
490        let api_config = config
491            .api_configs
492            .entry(api_name.to_string())
493            .or_insert_with(|| ApiConfig {
494                base_url_override: None,
495                environment_urls: HashMap::new(),
496                strict_mode: false,
497                secrets: HashMap::new(),
498            });
499
500        // Set the URL
501        if let Some(env) = environment {
502            api_config
503                .environment_urls
504                .insert(env.to_string(), url.to_string());
505        } else {
506            api_config.base_url_override = Some(url.to_string());
507        }
508
509        // Save updated config
510        self.save_global_config(&config)?;
511        Ok(())
512    }
513
514    /// Gets the base URL configuration for an API specification.
515    ///
516    /// # Arguments
517    /// * `api_name` - The name of the API specification
518    ///
519    /// # Returns
520    /// A tuple of (`base_url_override`, `environment_urls`, `resolved_url`)
521    ///
522    /// # Errors
523    ///
524    /// Returns an error if the spec doesn't exist.
525    #[allow(clippy::type_complexity)]
526    pub fn get_url(
527        &self,
528        api_name: &str,
529    ) -> Result<(Option<String>, HashMap<String, String>, String), Error> {
530        // Verify the spec exists
531        let spec_path = self
532            .config_dir
533            .join(crate::constants::DIR_SPECS)
534            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
535        if !self.fs.exists(&spec_path) {
536            return Err(Error::spec_not_found(api_name));
537        }
538
539        // Load the cached spec to get its base URL
540        let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
541        let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
542
543        // Load global config
544        let config = self.load_global_config()?;
545
546        // Get API config
547        let api_config = config.api_configs.get(api_name);
548
549        let base_url_override = api_config.and_then(|c| c.base_url_override.clone());
550        let environment_urls = api_config
551            .map(|c| c.environment_urls.clone())
552            .unwrap_or_default();
553
554        // Resolve the URL that would actually be used
555        let resolved_url = cached_spec.map_or_else(
556            || "https://api.example.com".to_string(),
557            |spec| {
558                let resolver = BaseUrlResolver::new(&spec);
559                let resolver = if api_config.is_some() {
560                    resolver.with_global_config(&config)
561                } else {
562                    resolver
563                };
564                resolver.resolve(None)
565            },
566        );
567
568        Ok((base_url_override, environment_urls, resolved_url))
569    }
570
571    /// Lists all configured base URLs across all API specifications.
572    ///
573    /// # Returns
574    /// A map of API names to their URL configurations
575    ///
576    /// # Errors
577    ///
578    /// Returns an error if the config cannot be loaded.
579    #[allow(clippy::type_complexity)]
580    pub fn list_urls(
581        &self,
582    ) -> Result<HashMap<String, (Option<String>, HashMap<String, String>)>, Error> {
583        let config = self.load_global_config()?;
584
585        let mut result = HashMap::new();
586        for (api_name, api_config) in config.api_configs {
587            result.insert(
588                api_name,
589                (api_config.base_url_override, api_config.environment_urls),
590            );
591        }
592
593        Ok(result)
594    }
595
596    /// Test-only method to add spec from URL with custom timeout
597    #[doc(hidden)]
598    #[allow(clippy::future_not_send)]
599    pub async fn add_spec_from_url_with_timeout(
600        &self,
601        name: &str,
602        url: &str,
603        force: bool,
604        timeout: std::time::Duration,
605    ) -> Result<(), Error> {
606        // Default to non-strict mode to match CLI behavior
607        self.add_spec_from_url_with_timeout_and_mode(name, url, force, timeout, false)
608            .await
609    }
610
611    /// Test-only method to add spec from URL with custom timeout and validation mode
612    #[doc(hidden)]
613    #[allow(clippy::future_not_send)]
614    async fn add_spec_from_url_with_timeout_and_mode(
615        &self,
616        name: &str,
617        url: &str,
618        force: bool,
619        timeout: std::time::Duration,
620        strict: bool,
621    ) -> Result<(), Error> {
622        self.check_spec_exists(name, force)?;
623
624        // Fetch content from URL with custom timeout
625        let content = fetch_spec_from_url_with_timeout(url, timeout).await?;
626        let openapi_spec = crate::spec::parse_openapi(&content)?;
627
628        // Validate against Aperture's supported feature set using SpecValidator
629        let validator = SpecValidator::new();
630        let validation_result = validator.validate_with_mode(&openapi_spec, strict);
631
632        // Check for errors first
633        if !validation_result.is_valid() {
634            return validation_result.into_result();
635        }
636
637        // Note: Not displaying warnings in test method to avoid polluting test output
638
639        self.add_spec_from_validated_openapi(
640            name,
641            &openapi_spec,
642            &content,
643            &validation_result,
644            strict,
645        )
646    }
647
648    /// Sets a secret configuration for a specific security scheme
649    ///
650    /// # Arguments
651    /// * `api_name` - The name of the API specification
652    /// * `scheme_name` - The name of the security scheme
653    /// * `env_var_name` - The environment variable name containing the secret
654    ///
655    /// # Errors
656    ///
657    /// Returns an error if the spec doesn't exist or config cannot be saved.
658    pub fn set_secret(
659        &self,
660        api_name: &str,
661        scheme_name: &str,
662        env_var_name: &str,
663    ) -> Result<(), Error> {
664        // Verify the spec exists
665        let spec_path = self
666            .config_dir
667            .join(crate::constants::DIR_SPECS)
668            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
669        if !self.fs.exists(&spec_path) {
670            return Err(Error::spec_not_found(api_name));
671        }
672
673        // Load current config
674        let mut config = self.load_global_config()?;
675
676        // Get or create API config
677        let api_config = config
678            .api_configs
679            .entry(api_name.to_string())
680            .or_insert_with(|| ApiConfig {
681                base_url_override: None,
682                environment_urls: HashMap::new(),
683                strict_mode: false,
684                secrets: HashMap::new(),
685            });
686
687        // Set the secret
688        api_config.secrets.insert(
689            scheme_name.to_string(),
690            ApertureSecret {
691                source: SecretSource::Env,
692                name: env_var_name.to_string(),
693            },
694        );
695
696        // Save updated config
697        self.save_global_config(&config)?;
698        Ok(())
699    }
700
701    /// Lists configured secrets for an API specification
702    ///
703    /// # Arguments
704    /// * `api_name` - The name of the API specification
705    ///
706    /// # Returns
707    /// A map of scheme names to their secret configurations
708    ///
709    /// # Errors
710    ///
711    /// Returns an error if the spec doesn't exist.
712    pub fn list_secrets(&self, api_name: &str) -> Result<HashMap<String, ApertureSecret>, Error> {
713        // Verify the spec exists
714        let spec_path = self
715            .config_dir
716            .join(crate::constants::DIR_SPECS)
717            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
718        if !self.fs.exists(&spec_path) {
719            return Err(Error::spec_not_found(api_name));
720        }
721
722        // Load global config
723        let config = self.load_global_config()?;
724
725        // Get API config secrets
726        let secrets = config
727            .api_configs
728            .get(api_name)
729            .map(|c| c.secrets.clone())
730            .unwrap_or_default();
731
732        Ok(secrets)
733    }
734
735    /// Gets a secret configuration for a specific security scheme
736    ///
737    /// # Arguments
738    /// * `api_name` - The name of the API specification
739    /// * `scheme_name` - The name of the security scheme
740    ///
741    /// # Returns
742    /// The secret configuration if found
743    ///
744    /// # Errors
745    ///
746    /// Returns an error if the spec doesn't exist.
747    pub fn get_secret(
748        &self,
749        api_name: &str,
750        scheme_name: &str,
751    ) -> Result<Option<ApertureSecret>, Error> {
752        let secrets = self.list_secrets(api_name)?;
753        Ok(secrets.get(scheme_name).cloned())
754    }
755
756    /// Removes a specific secret configuration for a security scheme
757    ///
758    /// # Arguments
759    /// * `api_name` - The name of the API specification
760    /// * `scheme_name` - The name of the security scheme to remove
761    ///
762    /// # Errors
763    /// Returns an error if the spec doesn't exist or if the scheme is not configured
764    pub fn remove_secret(&self, api_name: &str, scheme_name: &str) -> Result<(), Error> {
765        // Verify the spec exists
766        let spec_path = self
767            .config_dir
768            .join(crate::constants::DIR_SPECS)
769            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
770        if !self.fs.exists(&spec_path) {
771            return Err(Error::spec_not_found(api_name));
772        }
773
774        // Load global config
775        let mut config = self.load_global_config()?;
776
777        // Check if the API has any configured secrets
778        let Some(api_config) = config.api_configs.get_mut(api_name) else {
779            return Err(Error::invalid_config(format!(
780                "No secrets configured for API '{api_name}'"
781            )));
782        };
783
784        // Check if the API config exists but has no secrets
785        if api_config.secrets.is_empty() {
786            return Err(Error::invalid_config(format!(
787                "No secrets configured for API '{api_name}'"
788            )));
789        }
790
791        // Check if the specific scheme exists
792        if !api_config.secrets.contains_key(scheme_name) {
793            return Err(Error::invalid_config(format!(
794                "Secret for scheme '{scheme_name}' is not configured for API '{api_name}'"
795            )));
796        }
797
798        // Remove the secret
799        api_config.secrets.remove(scheme_name);
800
801        // If no secrets remain, remove the entire API config
802        if api_config.secrets.is_empty() && api_config.base_url_override.is_none() {
803            config.api_configs.remove(api_name);
804        }
805
806        // Save the updated config
807        self.save_global_config(&config)?;
808
809        Ok(())
810    }
811
812    /// Removes all secret configurations for an API specification
813    ///
814    /// # Arguments
815    /// * `api_name` - The name of the API specification
816    ///
817    /// # Errors
818    /// Returns an error if the spec doesn't exist
819    pub fn clear_secrets(&self, api_name: &str) -> Result<(), Error> {
820        // Verify the spec exists
821        let spec_path = self
822            .config_dir
823            .join(crate::constants::DIR_SPECS)
824            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
825        if !self.fs.exists(&spec_path) {
826            return Err(Error::spec_not_found(api_name));
827        }
828
829        // Load global config
830        let mut config = self.load_global_config()?;
831
832        // Check if the API exists in config
833        if let Some(api_config) = config.api_configs.get_mut(api_name) {
834            // Clear all secrets
835            api_config.secrets.clear();
836
837            // If no other configuration remains, remove the entire API config
838            if api_config.base_url_override.is_none() {
839                config.api_configs.remove(api_name);
840            }
841
842            // Save the updated config
843            self.save_global_config(&config)?;
844        }
845        // If API config doesn't exist, that's fine - no secrets to clear
846
847        Ok(())
848    }
849
850    /// Configure secrets interactively for an API specification
851    ///
852    /// Loads the cached spec to discover available security schemes and
853    /// presents an interactive menu for configuration.
854    ///
855    /// # Arguments
856    /// * `api_name` - The name of the API specification
857    ///
858    /// # Errors
859    ///
860    /// Returns an error if:
861    /// - The spec doesn't exist
862    /// - Cannot load cached spec
863    /// - User interaction fails
864    /// - Cannot save configuration
865    ///
866    /// # Panics
867    ///
868    /// Panics if the selected scheme is not found in the cached spec
869    /// (this should never happen due to menu validation)
870    pub fn set_secret_interactive(&self, api_name: &str) -> Result<(), Error> {
871        // Verify the spec exists and load cached spec
872        let (cached_spec, current_secrets) = self.load_spec_for_interactive_config(api_name)?;
873
874        if cached_spec.security_schemes.is_empty() {
875            println!("No security schemes found in API '{api_name}'.");
876            return Ok(());
877        }
878
879        Self::display_interactive_header(api_name, &cached_spec);
880
881        // Create options for selection with rich descriptions
882        let options = Self::build_security_scheme_options(&cached_spec, &current_secrets);
883
884        // Interactive loop for configuration
885        self.run_interactive_configuration_loop(
886            api_name,
887            &cached_spec,
888            &current_secrets,
889            &options,
890        )?;
891
892        println!("\nInteractive configuration complete!");
893        Ok(())
894    }
895
896    /// Checks if a spec already exists and handles force flag
897    ///
898    /// # Errors
899    ///
900    /// Returns an error if the spec already exists and force is false
901    fn check_spec_exists(&self, name: &str, force: bool) -> Result<(), Error> {
902        let spec_path = self
903            .config_dir
904            .join(crate::constants::DIR_SPECS)
905            .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
906
907        if self.fs.exists(&spec_path) && !force {
908            return Err(Error::spec_already_exists(name));
909        }
910
911        Ok(())
912    }
913
914    /// Transforms an `OpenAPI` spec into cached representation
915    ///
916    /// # Errors
917    ///
918    /// Returns an error if transformation fails
919    fn transform_spec_to_cached(
920        name: &str,
921        openapi_spec: &OpenAPI,
922        validation_result: &crate::spec::validator::ValidationResult,
923    ) -> Result<crate::cache::models::CachedSpec, Error> {
924        let transformer = SpecTransformer::new();
925
926        // Convert warnings to skip_endpoints format - skip endpoints with unsupported content types or auth
927        let skip_endpoints: Vec<(String, String)> = validation_result
928            .warnings
929            .iter()
930            .filter_map(super::super::spec::validator::ValidationWarning::to_skip_endpoint)
931            .collect();
932
933        transformer.transform_with_warnings(
934            name,
935            openapi_spec,
936            &skip_endpoints,
937            &validation_result.warnings,
938        )
939    }
940
941    /// Creates necessary directories for spec and cache files
942    ///
943    /// # Errors
944    ///
945    /// Returns an error if directory creation fails
946    fn create_spec_directories(&self, name: &str) -> Result<(PathBuf, PathBuf), Error> {
947        let spec_path = self
948            .config_dir
949            .join(crate::constants::DIR_SPECS)
950            .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
951        let cache_path = self
952            .config_dir
953            .join(crate::constants::DIR_CACHE)
954            .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
955
956        let spec_parent = spec_path.parent().ok_or_else(|| {
957            Error::invalid_path(
958                spec_path.display().to_string(),
959                "Path has no parent directory",
960            )
961        })?;
962        let cache_parent = cache_path.parent().ok_or_else(|| {
963            Error::invalid_path(
964                cache_path.display().to_string(),
965                "Path has no parent directory",
966            )
967        })?;
968
969        self.fs.create_dir_all(spec_parent)?;
970        self.fs.create_dir_all(cache_parent)?;
971
972        Ok((spec_path, cache_path))
973    }
974
975    /// Writes spec and cache files to disk
976    ///
977    /// # Errors
978    ///
979    /// Returns an error if file operations fail
980    fn write_spec_files(
981        &self,
982        name: &str,
983        content: &str,
984        cached_spec: &crate::cache::models::CachedSpec,
985        spec_path: &Path,
986        cache_path: &Path,
987    ) -> Result<(), Error> {
988        // Write original spec file
989        self.fs.write_all(spec_path, content.as_bytes())?;
990
991        // Serialize and write cached representation
992        let cached_data = bincode::serialize(cached_spec)
993            .map_err(|e| Error::serialization_error(e.to_string()))?;
994        self.fs.write_all(cache_path, &cached_data)?;
995
996        // Update cache metadata for optimized version checking
997        let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
998        let metadata_manager = CacheMetadataManager::new(&self.fs);
999        metadata_manager.update_spec_metadata(&cache_dir, name, cached_data.len() as u64)?;
1000
1001        Ok(())
1002    }
1003
1004    /// Common logic for adding a spec from a validated `OpenAPI` object
1005    ///
1006    /// # Errors
1007    ///
1008    /// Returns an error if transformation or file operations fail
1009    fn add_spec_from_validated_openapi(
1010        &self,
1011        name: &str,
1012        openapi_spec: &OpenAPI,
1013        content: &str,
1014        validation_result: &crate::spec::validator::ValidationResult,
1015        strict: bool,
1016    ) -> Result<(), Error> {
1017        // Transform to cached representation
1018        let cached_spec = Self::transform_spec_to_cached(name, openapi_spec, validation_result)?;
1019
1020        // Create directories
1021        let (spec_path, cache_path) = self.create_spec_directories(name)?;
1022
1023        // Write files
1024        self.write_spec_files(name, content, &cached_spec, &spec_path, &cache_path)?;
1025
1026        // Save strict mode preference
1027        self.save_strict_preference(name, strict)?;
1028
1029        Ok(())
1030    }
1031
1032    /// Loads spec and current secrets for interactive configuration
1033    ///
1034    /// # Errors
1035    ///
1036    /// Returns an error if the spec doesn't exist or cannot be loaded
1037    fn load_spec_for_interactive_config(
1038        &self,
1039        api_name: &str,
1040    ) -> Result<
1041        (
1042            crate::cache::models::CachedSpec,
1043            std::collections::HashMap<String, ApertureSecret>,
1044        ),
1045        Error,
1046    > {
1047        // Verify the spec exists
1048        let spec_path = self
1049            .config_dir
1050            .join(crate::constants::DIR_SPECS)
1051            .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
1052        if !self.fs.exists(&spec_path) {
1053            return Err(Error::spec_not_found(api_name));
1054        }
1055
1056        // Load cached spec to get security schemes
1057        let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
1058        let cached_spec = loader::load_cached_spec(&cache_dir, api_name)?;
1059
1060        // Get current configuration
1061        let current_secrets = self.list_secrets(api_name)?;
1062
1063        Ok((cached_spec, current_secrets))
1064    }
1065
1066    /// Displays the interactive configuration header
1067    fn display_interactive_header(api_name: &str, cached_spec: &crate::cache::models::CachedSpec) {
1068        println!("Interactive Secret Configuration for API: {api_name}");
1069        println!(
1070            "Found {} security scheme(s):\n",
1071            cached_spec.security_schemes.len()
1072        );
1073    }
1074
1075    /// Builds options for security scheme selection with rich descriptions
1076    fn build_security_scheme_options(
1077        cached_spec: &crate::cache::models::CachedSpec,
1078        current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1079    ) -> Vec<(String, String)> {
1080        cached_spec
1081            .security_schemes
1082            .values()
1083            .map(|scheme| {
1084                let mut description = format!("{} ({})", scheme.scheme_type, scheme.name);
1085
1086                // Add type-specific details
1087                match scheme.scheme_type.as_str() {
1088                    constants::AUTH_SCHEME_APIKEY => {
1089                        if let (Some(location), Some(param)) =
1090                            (&scheme.location, &scheme.parameter_name)
1091                        {
1092                            description = format!("{description} - {location} parameter: {param}");
1093                        }
1094                    }
1095                    "http" => {
1096                        if let Some(http_scheme) = &scheme.scheme {
1097                            description = format!("{description} - {http_scheme} authentication");
1098                        }
1099                    }
1100                    _ => {}
1101                }
1102
1103                // Show current configuration status
1104                if current_secrets.contains_key(&scheme.name) {
1105                    description = format!("{description} [CONFIGURED]");
1106                } else if scheme.aperture_secret.is_some() {
1107                    description = format!("{description} [x-aperture-secret]");
1108                } else {
1109                    description = format!("{description} [NOT CONFIGURED]");
1110                }
1111
1112                // Add OpenAPI description if available
1113                if let Some(openapi_desc) = &scheme.description {
1114                    description = format!("{description} - {openapi_desc}");
1115                }
1116
1117                (scheme.name.clone(), description)
1118            })
1119            .collect()
1120    }
1121
1122    /// Runs the interactive configuration loop
1123    ///
1124    /// # Errors
1125    ///
1126    /// Returns an error if user interaction fails or configuration cannot be saved
1127    fn run_interactive_configuration_loop(
1128        &self,
1129        api_name: &str,
1130        cached_spec: &crate::cache::models::CachedSpec,
1131        current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1132        options: &[(String, String)],
1133    ) -> Result<(), Error> {
1134        loop {
1135            let selected_scheme =
1136                select_from_options("\nSelect a security scheme to configure:", options)?;
1137
1138            let scheme = cached_spec.security_schemes.get(&selected_scheme).expect(
1139                "Selected scheme should exist in cached spec - menu validation ensures this",
1140            );
1141
1142            Self::display_scheme_configuration_details(&selected_scheme, scheme, current_secrets);
1143
1144            // Prompt for environment variable
1145            let env_var = prompt_for_input(&format!(
1146                "\nEnter environment variable name for '{selected_scheme}' (or press Enter to skip): "
1147            ))?;
1148
1149            if env_var.is_empty() {
1150                println!("Skipping configuration for '{selected_scheme}'");
1151            } else {
1152                self.handle_secret_configuration(api_name, &selected_scheme, &env_var)?;
1153            }
1154
1155            // Ask if user wants to configure another scheme
1156            if !confirm("\nConfigure another security scheme?")? {
1157                break;
1158            }
1159        }
1160
1161        Ok(())
1162    }
1163
1164    /// Displays configuration details for a selected security scheme
1165    fn display_scheme_configuration_details(
1166        selected_scheme: &str,
1167        scheme: &crate::cache::models::CachedSecurityScheme,
1168        current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1169    ) {
1170        println!("\nConfiguration for '{selected_scheme}':");
1171        println!("   Type: {}", scheme.scheme_type);
1172        if let Some(desc) = &scheme.description {
1173            println!("   Description: {desc}");
1174        }
1175
1176        // Show current configuration
1177        if let Some(current_secret) = current_secrets.get(selected_scheme) {
1178            println!("   Current: environment variable '{}'", current_secret.name);
1179        } else if let Some(aperture_secret) = &scheme.aperture_secret {
1180            println!(
1181                "   Current: x-aperture-secret -> '{}'",
1182                aperture_secret.name
1183            );
1184        } else {
1185            println!("   Current: not configured");
1186        }
1187    }
1188
1189    /// Handles secret configuration validation and saving
1190    ///
1191    /// # Errors
1192    ///
1193    /// Returns an error if validation fails or configuration cannot be saved
1194    fn handle_secret_configuration(
1195        &self,
1196        api_name: &str,
1197        selected_scheme: &str,
1198        env_var: &str,
1199    ) -> Result<(), Error> {
1200        // Validate environment variable name using the comprehensive validator
1201        if let Err(e) = crate::interactive::validate_env_var_name(env_var) {
1202            println!("Invalid environment variable name: {e}");
1203            return Ok(()); // Continue the loop, don't fail completely
1204        }
1205
1206        // Show preview and confirm
1207        println!("\nConfiguration Preview:");
1208        println!("   API: {api_name}");
1209        println!("   Scheme: {selected_scheme}");
1210        println!("   Environment Variable: {env_var}");
1211
1212        if confirm("Apply this configuration?")? {
1213            self.set_secret(api_name, selected_scheme, env_var)?;
1214            println!("Configuration saved successfully!");
1215        } else {
1216            println!("Configuration cancelled.");
1217        }
1218
1219        Ok(())
1220    }
1221
1222    /// Categorizes warnings by type for better formatting
1223    fn categorize_warnings(
1224        warnings: &[crate::spec::validator::ValidationWarning],
1225    ) -> CategorizedWarnings<'_> {
1226        let mut categorized = CategorizedWarnings {
1227            content_type: Vec::new(),
1228            auth: Vec::new(),
1229            mixed_content: Vec::new(),
1230        };
1231
1232        for warning in warnings {
1233            if warning.reason.contains("no supported content types") {
1234                categorized.content_type.push(warning);
1235            } else if warning.reason.contains("unsupported authentication") {
1236                categorized.auth.push(warning);
1237            } else if warning
1238                .reason
1239                .contains("unsupported content types alongside JSON")
1240            {
1241                categorized.mixed_content.push(warning);
1242            }
1243        }
1244
1245        categorized
1246    }
1247
1248    /// Formats content type warnings
1249    fn format_content_type_warnings(
1250        lines: &mut Vec<String>,
1251        content_type_warnings: &[&crate::spec::validator::ValidationWarning],
1252        total_operations: Option<usize>,
1253        total_skipped: usize,
1254        indent: &str,
1255    ) {
1256        if content_type_warnings.is_empty() {
1257            return;
1258        }
1259
1260        let warning_msg = total_operations.map_or_else(
1261            || {
1262                format!(
1263                    "{}Skipping {} endpoints with unsupported content types:",
1264                    indent,
1265                    content_type_warnings.len()
1266                )
1267            },
1268            |total| {
1269                let available = total.saturating_sub(total_skipped);
1270                format!(
1271                    "{}Skipping {} endpoints with unsupported content types ({} of {} endpoints will be available):",
1272                    indent,
1273                    content_type_warnings.len(),
1274                    available,
1275                    total
1276                )
1277            },
1278        );
1279        lines.push(warning_msg);
1280
1281        for warning in content_type_warnings {
1282            lines.push(format!(
1283                "{}  - {} {} ({}) - {}",
1284                indent,
1285                warning.endpoint.method,
1286                warning.endpoint.path,
1287                warning.endpoint.content_type,
1288                warning.reason
1289            ));
1290        }
1291    }
1292
1293    /// Formats authentication warnings
1294    fn format_auth_warnings(
1295        lines: &mut Vec<String>,
1296        auth_warnings: &[&crate::spec::validator::ValidationWarning],
1297        total_operations: Option<usize>,
1298        total_skipped: usize,
1299        indent: &str,
1300        add_blank_line: bool,
1301    ) {
1302        if auth_warnings.is_empty() {
1303            return;
1304        }
1305
1306        if add_blank_line {
1307            lines.push(String::new()); // Add blank line between sections
1308        }
1309
1310        let warning_msg = total_operations.map_or_else(
1311            || {
1312                format!(
1313                    "{}Skipping {} endpoints with unsupported authentication:",
1314                    indent,
1315                    auth_warnings.len()
1316                )
1317            },
1318            |total| {
1319                let available = total.saturating_sub(total_skipped);
1320                format!(
1321                    "{}Skipping {} endpoints with unsupported authentication ({} of {} endpoints will be available):",
1322                    indent,
1323                    auth_warnings.len(),
1324                    available,
1325                    total
1326                )
1327            },
1328        );
1329        lines.push(warning_msg);
1330
1331        for warning in auth_warnings {
1332            lines.push(format!(
1333                "{}  - {} {} - {}",
1334                indent, warning.endpoint.method, warning.endpoint.path, warning.reason
1335            ));
1336        }
1337    }
1338
1339    /// Formats mixed content warnings
1340    fn format_mixed_content_warnings(
1341        lines: &mut Vec<String>,
1342        mixed_content_warnings: &[&crate::spec::validator::ValidationWarning],
1343        indent: &str,
1344        add_blank_line: bool,
1345    ) {
1346        if mixed_content_warnings.is_empty() {
1347            return;
1348        }
1349
1350        if add_blank_line {
1351            lines.push(String::new()); // Add blank line between sections
1352        }
1353
1354        lines.push(format!(
1355            "{indent}Endpoints with partial content type support:"
1356        ));
1357        for warning in mixed_content_warnings {
1358            lines.push(format!(
1359                "{}  - {} {} supports JSON but not: {}",
1360                indent,
1361                warning.endpoint.method,
1362                warning.endpoint.path,
1363                warning.endpoint.content_type
1364            ));
1365        }
1366    }
1367}
1368
1369/// Gets the default configuration directory path.
1370///
1371/// # Errors
1372///
1373/// Returns an error if the home directory cannot be determined.
1374pub fn get_config_dir() -> Result<PathBuf, Error> {
1375    let home_dir = dirs::home_dir().ok_or_else(Error::home_directory_not_found)?;
1376    let config_dir = home_dir.join(".config").join("aperture");
1377    Ok(config_dir)
1378}
1379
1380/// Determines if the input string is a URL (starts with http:// or https://)
1381#[must_use]
1382pub fn is_url(input: &str) -> bool {
1383    input.starts_with("http://") || input.starts_with("https://")
1384}
1385
1386/// Fetches `OpenAPI` specification content from a URL with security limits
1387///
1388/// # Errors
1389///
1390/// Returns an error if:
1391/// - Network request fails
1392/// - Response status is not successful
1393/// - Response size exceeds 10MB limit
1394/// - Request times out (30 seconds)
1395const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
1396
1397#[allow(clippy::future_not_send)]
1398async fn fetch_spec_from_url(url: &str) -> Result<String, Error> {
1399    fetch_spec_from_url_with_timeout(url, std::time::Duration::from_secs(30)).await
1400}
1401
1402#[allow(clippy::future_not_send)]
1403async fn fetch_spec_from_url_with_timeout(
1404    url: &str,
1405    timeout: std::time::Duration,
1406) -> Result<String, Error> {
1407    // Create HTTP client with timeout and security limits
1408    let client = reqwest::Client::builder()
1409        .timeout(timeout)
1410        .build()
1411        .map_err(|e| Error::network_request_failed(format!("Failed to create HTTP client: {e}")))?;
1412
1413    // Make the request
1414    let response = client.get(url).send().await.map_err(|e| {
1415        if e.is_timeout() {
1416            Error::network_request_failed(format!(
1417                "Request timed out after {} seconds",
1418                timeout.as_secs()
1419            ))
1420        } else if e.is_connect() {
1421            Error::network_request_failed(format!("Failed to connect to {url}: {e}"))
1422        } else {
1423            Error::network_request_failed(format!("Network error: {e}"))
1424        }
1425    })?;
1426
1427    // Check response status
1428    if !response.status().is_success() {
1429        return Err(Error::request_failed(
1430            response.status(),
1431            format!("HTTP {} from {url}", response.status()),
1432        ));
1433    }
1434
1435    // Check content length before downloading
1436    if let Some(content_length) = response.content_length() {
1437        if content_length > MAX_RESPONSE_SIZE {
1438            return Err(Error::network_request_failed(format!(
1439                "Response too large: {content_length} bytes (max {MAX_RESPONSE_SIZE} bytes)"
1440            )));
1441        }
1442    }
1443
1444    // Read response body with size limit
1445    let bytes = response
1446        .bytes()
1447        .await
1448        .map_err(|e| Error::network_request_failed(format!("Failed to read response body: {e}")))?;
1449
1450    // Double-check size after download
1451    if bytes.len() > usize::try_from(MAX_RESPONSE_SIZE).unwrap_or(usize::MAX) {
1452        return Err(Error::network_request_failed(format!(
1453            "Response too large: {} bytes (max {MAX_RESPONSE_SIZE} bytes)",
1454            bytes.len()
1455        )));
1456    }
1457
1458    // Convert to string
1459    String::from_utf8(bytes.to_vec())
1460        .map_err(|e| Error::network_request_failed(format!("Invalid UTF-8 in response: {e}")))
1461}