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