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