Skip to main content

aperture_cli/config/
manager.rs

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