aperture_cli/config/
manager.rs

1use crate::cache::metadata::CacheMetadataManager;
2use crate::config::models::{ApiConfig, GlobalConfig};
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
13pub struct ConfigManager<F: FileSystem> {
14    fs: F,
15    config_dir: PathBuf,
16}
17
18impl ConfigManager<OsFileSystem> {
19    /// Creates a new `ConfigManager` with the default filesystem and config directory.
20    ///
21    /// # Errors
22    ///
23    /// Returns an error if the home directory cannot be determined.
24    pub fn new() -> Result<Self, Error> {
25        let config_dir = get_config_dir()?;
26        Ok(Self {
27            fs: OsFileSystem,
28            config_dir,
29        })
30    }
31}
32
33impl<F: FileSystem> ConfigManager<F> {
34    pub const fn with_fs(fs: F, config_dir: PathBuf) -> Self {
35        Self { fs, config_dir }
36    }
37
38    /// Get the configuration directory path
39    pub fn config_dir(&self) -> &Path {
40        &self.config_dir
41    }
42
43    /// Convert skipped endpoints to validation warnings for display
44    #[must_use]
45    pub fn skipped_endpoints_to_warnings(
46        skipped_endpoints: &[crate::cache::models::SkippedEndpoint],
47    ) -> Vec<crate::spec::validator::ValidationWarning> {
48        skipped_endpoints
49            .iter()
50            .map(|endpoint| crate::spec::validator::ValidationWarning {
51                endpoint: crate::spec::validator::UnsupportedEndpoint {
52                    path: endpoint.path.clone(),
53                    method: endpoint.method.clone(),
54                    content_type: endpoint.content_type.clone(),
55                },
56                reason: endpoint.reason.clone(),
57            })
58            .collect()
59    }
60
61    /// Save the strict mode preference for an API
62    fn save_strict_preference(&self, api_name: &str, strict: bool) -> Result<(), Error> {
63        let mut config = self.load_global_config()?;
64        let api_config = config
65            .api_configs
66            .entry(api_name.to_string())
67            .or_insert_with(|| ApiConfig {
68                base_url_override: None,
69                environment_urls: HashMap::new(),
70                strict_mode: false,
71            });
72        api_config.strict_mode = strict;
73        self.save_global_config(&config)?;
74        Ok(())
75    }
76
77    /// Get the strict mode preference for an API
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if the global config cannot be loaded
82    pub fn get_strict_preference(&self, api_name: &str) -> Result<bool, Error> {
83        let config = self.load_global_config()?;
84        Ok(config
85            .api_configs
86            .get(api_name)
87            .is_some_and(|c| c.strict_mode))
88    }
89
90    /// Count total operations in an `OpenAPI` spec
91    fn count_total_operations(spec: &OpenAPI) -> usize {
92        spec.paths
93            .iter()
94            .filter_map(|(_, path_item)| match path_item {
95                ReferenceOr::Item(item) => Some(item),
96                ReferenceOr::Reference { .. } => None,
97            })
98            .map(|item| {
99                let mut count = 0;
100                if item.get.is_some() {
101                    count += 1;
102                }
103                if item.post.is_some() {
104                    count += 1;
105                }
106                if item.put.is_some() {
107                    count += 1;
108                }
109                if item.delete.is_some() {
110                    count += 1;
111                }
112                if item.patch.is_some() {
113                    count += 1;
114                }
115                if item.head.is_some() {
116                    count += 1;
117                }
118                if item.options.is_some() {
119                    count += 1;
120                }
121                if item.trace.is_some() {
122                    count += 1;
123                }
124                count
125            })
126            .sum()
127    }
128
129    /// Display validation warnings with custom prefix
130    #[must_use]
131    pub fn format_validation_warnings(
132        warnings: &[crate::spec::validator::ValidationWarning],
133        total_operations: Option<usize>,
134        indent: &str,
135    ) -> Vec<String> {
136        let mut lines = Vec::new();
137
138        if !warnings.is_empty() {
139            // Separate warnings into skipped endpoints and mixed content warnings
140            let (skipped_warnings, mixed_warnings): (Vec<_>, Vec<_>) = warnings
141                .iter()
142                .partition(|w| w.reason.contains("no supported content types"));
143
144            // Format skipped endpoints warning if any
145            if !skipped_warnings.is_empty() {
146                let warning_msg = total_operations.map_or_else(
147                    || {
148                        format!(
149                            "{}Skipping {} endpoints with unsupported content types:",
150                            indent,
151                            skipped_warnings.len()
152                        )
153                    },
154                    |total| {
155                        let available = total.saturating_sub(skipped_warnings.len());
156                        format!(
157                            "{}Skipping {} endpoints with unsupported content types ({} of {} endpoints will be available):",
158                            indent,
159                            skipped_warnings.len(),
160                            available,
161                            total
162                        )
163                    },
164                );
165                lines.push(warning_msg);
166
167                for warning in &skipped_warnings {
168                    lines.push(format!(
169                        "{}  - {} {} ({}) - {}",
170                        indent,
171                        warning.endpoint.method,
172                        warning.endpoint.path,
173                        warning.endpoint.content_type,
174                        warning.reason
175                    ));
176                }
177            }
178
179            // Format mixed content warnings if any
180            if !mixed_warnings.is_empty() {
181                if !skipped_warnings.is_empty() {
182                    lines.push(String::new()); // Add blank line between sections
183                }
184                lines.push(format!(
185                    "{indent}Endpoints with partial content type support:"
186                ));
187                for warning in &mixed_warnings {
188                    lines.push(format!(
189                        "{}  - {} {} supports JSON but not: {}",
190                        indent,
191                        warning.endpoint.method,
192                        warning.endpoint.path,
193                        warning.endpoint.content_type
194                    ));
195                }
196            }
197        }
198
199        lines
200    }
201
202    /// Display validation warnings to stderr
203    pub fn display_validation_warnings(
204        warnings: &[crate::spec::validator::ValidationWarning],
205        total_operations: Option<usize>,
206    ) {
207        if !warnings.is_empty() {
208            let lines = Self::format_validation_warnings(warnings, total_operations, "");
209            for line in lines {
210                if line.is_empty() {
211                    eprintln!();
212                } else if line.starts_with("Skipping") || line.starts_with("Endpoints") {
213                    eprintln!("Warning: {line}");
214                } else {
215                    eprintln!("{line}");
216                }
217            }
218            eprintln!("\nUse --strict to reject specs with unsupported content types.");
219        }
220    }
221
222    /// Adds a new `OpenAPI` specification to the configuration from a local file.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if:
227    /// - The spec already exists and `force` is false
228    /// - File I/O operations fail
229    /// - The `OpenAPI` spec is invalid YAML
230    /// - The spec contains unsupported features
231    ///
232    /// # Panics
233    ///
234    /// Panics if the spec path parent directory is None (should not happen in normal usage).
235    pub fn add_spec(
236        &self,
237        name: &str,
238        file_path: &Path,
239        force: bool,
240        strict: bool,
241    ) -> Result<(), Error> {
242        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
243        let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
244
245        if self.fs.exists(&spec_path) && !force {
246            return Err(Error::SpecAlreadyExists {
247                name: name.to_string(),
248            });
249        }
250
251        let content = self.fs.read_to_string(file_path)?;
252        let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
253
254        // Validate against Aperture's supported feature set using SpecValidator
255        let validator = SpecValidator::new();
256        let validation_result = validator.validate_with_mode(&openapi_spec, strict);
257
258        // Check for errors first
259        if !validation_result.is_valid() {
260            return validation_result.into_result();
261        }
262
263        // Count total operations for better UX
264        let total_operations = Self::count_total_operations(&openapi_spec);
265
266        // Display warnings if any
267        Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
268
269        // Transform into internal cached representation using SpecTransformer
270        let transformer = SpecTransformer::new();
271
272        // Convert warnings to skip_endpoints format - only skip endpoints with NO JSON support
273        let skip_endpoints: Vec<(String, String)> = validation_result
274            .warnings
275            .iter()
276            .filter(|w| w.reason.contains("no supported content types"))
277            .map(|w| (w.endpoint.path.clone(), w.endpoint.method.clone()))
278            .collect();
279
280        let cached_spec = transformer.transform_with_warnings(
281            name,
282            &openapi_spec,
283            &skip_endpoints,
284            &validation_result.warnings,
285        )?;
286
287        // Create directories
288        let spec_parent = spec_path.parent().ok_or_else(|| Error::InvalidPath {
289            path: spec_path.display().to_string(),
290            reason: "Path has no parent directory".to_string(),
291        })?;
292        let cache_parent = cache_path.parent().ok_or_else(|| Error::InvalidPath {
293            path: cache_path.display().to_string(),
294            reason: "Path has no parent directory".to_string(),
295        })?;
296        self.fs.create_dir_all(spec_parent)?;
297        self.fs.create_dir_all(cache_parent)?;
298
299        // Write original spec file
300        self.fs.write_all(&spec_path, content.as_bytes())?;
301
302        // Serialize and write cached representation
303        let cached_data =
304            bincode::serialize(&cached_spec).map_err(|e| Error::SerializationError {
305                reason: e.to_string(),
306            })?;
307        self.fs.write_all(&cache_path, &cached_data)?;
308
309        // Update cache metadata for optimized version checking
310        let cache_dir = self.config_dir.join(".cache");
311        let metadata_manager = CacheMetadataManager::new(&self.fs);
312        metadata_manager.update_spec_metadata(&cache_dir, name, cached_data.len() as u64)?;
313
314        // Save strict mode preference
315        self.save_strict_preference(name, strict)?;
316
317        Ok(())
318    }
319
320    /// Adds a new `OpenAPI` specification to the configuration from a URL.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if:
325    /// - The spec already exists and `force` is false
326    /// - Network requests fail
327    /// - The `OpenAPI` spec is invalid YAML
328    /// - The spec contains unsupported features
329    /// - Response size exceeds 10MB limit
330    /// - Request times out (30 seconds)
331    ///
332    /// # Panics
333    ///
334    /// Panics if the spec path parent directory is None (should not happen in normal usage).
335    #[allow(clippy::future_not_send)]
336    pub async fn add_spec_from_url(
337        &self,
338        name: &str,
339        url: &str,
340        force: bool,
341        strict: bool,
342    ) -> Result<(), Error> {
343        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
344        let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
345
346        if self.fs.exists(&spec_path) && !force {
347            return Err(Error::SpecAlreadyExists {
348                name: name.to_string(),
349            });
350        }
351
352        // Fetch content from URL
353        let content = fetch_spec_from_url(url).await?;
354        let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
355
356        // Validate against Aperture's supported feature set using SpecValidator
357        let validator = SpecValidator::new();
358        let validation_result = validator.validate_with_mode(&openapi_spec, strict);
359
360        // Check for errors first
361        if !validation_result.is_valid() {
362            return validation_result.into_result();
363        }
364
365        // Count total operations for better UX
366        let total_operations = Self::count_total_operations(&openapi_spec);
367
368        // Display warnings if any
369        Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
370
371        // Transform into internal cached representation using SpecTransformer
372        let transformer = SpecTransformer::new();
373
374        // Convert warnings to skip_endpoints format - only skip endpoints with NO JSON support
375        let skip_endpoints: Vec<(String, String)> = validation_result
376            .warnings
377            .iter()
378            .filter(|w| w.reason.contains("no supported content types"))
379            .map(|w| (w.endpoint.path.clone(), w.endpoint.method.clone()))
380            .collect();
381
382        let cached_spec = transformer.transform_with_warnings(
383            name,
384            &openapi_spec,
385            &skip_endpoints,
386            &validation_result.warnings,
387        )?;
388
389        // Create directories
390        let spec_parent = spec_path.parent().ok_or_else(|| Error::InvalidPath {
391            path: spec_path.display().to_string(),
392            reason: "Path has no parent directory".to_string(),
393        })?;
394        let cache_parent = cache_path.parent().ok_or_else(|| Error::InvalidPath {
395            path: cache_path.display().to_string(),
396            reason: "Path has no parent directory".to_string(),
397        })?;
398        self.fs.create_dir_all(spec_parent)?;
399        self.fs.create_dir_all(cache_parent)?;
400
401        // Write original spec file
402        self.fs.write_all(&spec_path, content.as_bytes())?;
403
404        // Serialize and write cached representation
405        let cached_data =
406            bincode::serialize(&cached_spec).map_err(|e| Error::SerializationError {
407                reason: e.to_string(),
408            })?;
409        self.fs.write_all(&cache_path, &cached_data)?;
410
411        // Update cache metadata for optimized version checking
412        let cache_dir = self.config_dir.join(".cache");
413        let metadata_manager = CacheMetadataManager::new(&self.fs);
414        metadata_manager.update_spec_metadata(&cache_dir, name, cached_data.len() as u64)?;
415
416        // Save strict mode preference
417        self.save_strict_preference(name, strict)?;
418
419        Ok(())
420    }
421
422    /// Adds a new `OpenAPI` specification from either a file path or URL.
423    ///
424    /// This is a convenience method that automatically detects whether the input
425    /// is a URL or file path and calls the appropriate method.
426    ///
427    /// # Errors
428    ///
429    /// Returns an error if:
430    /// - The spec already exists and `force` is false
431    /// - File I/O operations fail (for local files)
432    /// - Network requests fail (for URLs)
433    /// - The `OpenAPI` spec is invalid YAML
434    /// - The spec contains unsupported features
435    /// - Response size exceeds 10MB limit (for URLs)
436    /// - Request times out (for URLs)
437    #[allow(clippy::future_not_send)]
438    pub async fn add_spec_auto(
439        &self,
440        name: &str,
441        file_or_url: &str,
442        force: bool,
443        strict: bool,
444    ) -> Result<(), Error> {
445        if is_url(file_or_url) {
446            self.add_spec_from_url(name, file_or_url, force, strict)
447                .await
448        } else {
449            // Convert file path string to Path and call sync method
450            let path = std::path::Path::new(file_or_url);
451            self.add_spec(name, path, force, strict)
452        }
453    }
454
455    /// Lists all registered API contexts.
456    ///
457    /// # Errors
458    ///
459    /// Returns an error if the specs directory cannot be read.
460    pub fn list_specs(&self) -> Result<Vec<String>, Error> {
461        let specs_dir = self.config_dir.join("specs");
462        if !self.fs.exists(&specs_dir) {
463            return Ok(Vec::new());
464        }
465
466        let mut specs = Vec::new();
467        for entry in self.fs.read_dir(&specs_dir)? {
468            if self.fs.is_file(&entry) {
469                if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
470                    if std::path::Path::new(file_name)
471                        .extension()
472                        .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
473                    {
474                        specs.push(file_name.trim_end_matches(".yaml").to_string());
475                    }
476                }
477            }
478        }
479        Ok(specs)
480    }
481
482    /// Removes an API specification from the configuration.
483    ///
484    /// # Errors
485    ///
486    /// Returns an error if the spec does not exist or cannot be removed.
487    pub fn remove_spec(&self, name: &str) -> Result<(), Error> {
488        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
489        let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
490
491        if !self.fs.exists(&spec_path) {
492            return Err(Error::SpecNotFound {
493                name: name.to_string(),
494            });
495        }
496
497        self.fs.remove_file(&spec_path)?;
498        if self.fs.exists(&cache_path) {
499            self.fs.remove_file(&cache_path)?;
500        }
501
502        // Remove from cache metadata
503        let cache_dir = self.config_dir.join(".cache");
504        let metadata_manager = CacheMetadataManager::new(&self.fs);
505        // Ignore errors if metadata removal fails - the important files are already removed
506        let _ = metadata_manager.remove_spec_metadata(&cache_dir, name);
507
508        Ok(())
509    }
510
511    /// Opens an API specification in the default editor.
512    ///
513    /// # Errors
514    ///
515    /// Returns an error if:
516    /// - The spec does not exist.
517    /// - The `$EDITOR` environment variable is not set.
518    /// - The editor command fails to execute.
519    pub fn edit_spec(&self, name: &str) -> Result<(), Error> {
520        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
521
522        if !self.fs.exists(&spec_path) {
523            return Err(Error::SpecNotFound {
524                name: name.to_string(),
525            });
526        }
527
528        let editor = std::env::var("EDITOR").map_err(|_| Error::EditorNotSet)?;
529
530        Command::new(editor)
531            .arg(&spec_path)
532            .status()
533            .map_err(Error::Io)?
534            .success()
535            .then_some(()) // Convert bool to Option<()>
536            .ok_or_else(|| Error::EditorFailed {
537                name: name.to_string(),
538            })
539    }
540
541    /// Loads the global configuration from `config.toml`.
542    ///
543    /// # Errors
544    ///
545    /// Returns an error if the configuration file exists but cannot be read or parsed.
546    pub fn load_global_config(&self) -> Result<GlobalConfig, Error> {
547        let config_path = self.config_dir.join("config.toml");
548        if self.fs.exists(&config_path) {
549            let content = self.fs.read_to_string(&config_path)?;
550            toml::from_str(&content).map_err(|e| Error::InvalidConfig {
551                reason: e.to_string(),
552            })
553        } else {
554            Ok(GlobalConfig::default())
555        }
556    }
557
558    /// Saves the global configuration to `config.toml`.
559    ///
560    /// # Errors
561    ///
562    /// Returns an error if the configuration cannot be serialized or written.
563    pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
564        let config_path = self.config_dir.join("config.toml");
565
566        // Ensure config directory exists
567        self.fs.create_dir_all(&self.config_dir)?;
568
569        let content = toml::to_string_pretty(config).map_err(|e| Error::SerializationError {
570            reason: format!("Failed to serialize config: {e}"),
571        })?;
572
573        self.fs.write_all(&config_path, content.as_bytes())?;
574        Ok(())
575    }
576
577    /// Sets the base URL for an API specification.
578    ///
579    /// # Arguments
580    /// * `api_name` - The name of the API specification
581    /// * `url` - The base URL to set
582    /// * `environment` - Optional environment name for environment-specific URLs
583    ///
584    /// # Errors
585    ///
586    /// Returns an error if the spec doesn't exist or config cannot be saved.
587    pub fn set_url(
588        &self,
589        api_name: &str,
590        url: &str,
591        environment: Option<&str>,
592    ) -> Result<(), Error> {
593        // Verify the spec exists
594        let spec_path = self
595            .config_dir
596            .join("specs")
597            .join(format!("{api_name}.yaml"));
598        if !self.fs.exists(&spec_path) {
599            return Err(Error::SpecNotFound {
600                name: api_name.to_string(),
601            });
602        }
603
604        // Load current config
605        let mut config = self.load_global_config()?;
606
607        // Get or create API config
608        let api_config = config
609            .api_configs
610            .entry(api_name.to_string())
611            .or_insert_with(|| ApiConfig {
612                base_url_override: None,
613                environment_urls: HashMap::new(),
614                strict_mode: false,
615            });
616
617        // Set the URL
618        if let Some(env) = environment {
619            api_config
620                .environment_urls
621                .insert(env.to_string(), url.to_string());
622        } else {
623            api_config.base_url_override = Some(url.to_string());
624        }
625
626        // Save updated config
627        self.save_global_config(&config)?;
628        Ok(())
629    }
630
631    /// Gets the base URL configuration for an API specification.
632    ///
633    /// # Arguments
634    /// * `api_name` - The name of the API specification
635    ///
636    /// # Returns
637    /// A tuple of (`base_url_override`, `environment_urls`, `resolved_url`)
638    ///
639    /// # Errors
640    ///
641    /// Returns an error if the spec doesn't exist.
642    #[allow(clippy::type_complexity)]
643    pub fn get_url(
644        &self,
645        api_name: &str,
646    ) -> Result<(Option<String>, HashMap<String, String>, String), Error> {
647        // Verify the spec exists
648        let spec_path = self
649            .config_dir
650            .join("specs")
651            .join(format!("{api_name}.yaml"));
652        if !self.fs.exists(&spec_path) {
653            return Err(Error::SpecNotFound {
654                name: api_name.to_string(),
655            });
656        }
657
658        // Load the cached spec to get its base URL
659        let cache_dir = self.config_dir.join(".cache");
660        let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
661
662        // Load global config
663        let config = self.load_global_config()?;
664
665        // Get API config
666        let api_config = config.api_configs.get(api_name);
667
668        let base_url_override = api_config.and_then(|c| c.base_url_override.clone());
669        let environment_urls = api_config
670            .map(|c| c.environment_urls.clone())
671            .unwrap_or_default();
672
673        // Resolve the URL that would actually be used
674        let resolved_url = cached_spec.map_or_else(
675            || "https://api.example.com".to_string(),
676            |spec| {
677                let resolver = BaseUrlResolver::new(&spec);
678                let resolver = if api_config.is_some() {
679                    resolver.with_global_config(&config)
680                } else {
681                    resolver
682                };
683                resolver.resolve(None)
684            },
685        );
686
687        Ok((base_url_override, environment_urls, resolved_url))
688    }
689
690    /// Lists all configured base URLs across all API specifications.
691    ///
692    /// # Returns
693    /// A map of API names to their URL configurations
694    ///
695    /// # Errors
696    ///
697    /// Returns an error if the config cannot be loaded.
698    #[allow(clippy::type_complexity)]
699    pub fn list_urls(
700        &self,
701    ) -> Result<HashMap<String, (Option<String>, HashMap<String, String>)>, Error> {
702        let config = self.load_global_config()?;
703
704        let mut result = HashMap::new();
705        for (api_name, api_config) in config.api_configs {
706            result.insert(
707                api_name,
708                (api_config.base_url_override, api_config.environment_urls),
709            );
710        }
711
712        Ok(result)
713    }
714
715    /// Test-only method to add spec from URL with custom timeout
716    #[doc(hidden)]
717    #[allow(clippy::future_not_send)]
718    pub async fn add_spec_from_url_with_timeout(
719        &self,
720        name: &str,
721        url: &str,
722        force: bool,
723        timeout: std::time::Duration,
724    ) -> Result<(), Error> {
725        // Default to non-strict mode to match CLI behavior
726        self.add_spec_from_url_with_timeout_and_mode(name, url, force, timeout, false)
727            .await
728    }
729
730    /// Test-only method to add spec from URL with custom timeout and validation mode
731    #[doc(hidden)]
732    #[allow(clippy::future_not_send)]
733    async fn add_spec_from_url_with_timeout_and_mode(
734        &self,
735        name: &str,
736        url: &str,
737        force: bool,
738        timeout: std::time::Duration,
739        strict: bool,
740    ) -> Result<(), Error> {
741        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
742        let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
743
744        if self.fs.exists(&spec_path) && !force {
745            return Err(Error::SpecAlreadyExists {
746                name: name.to_string(),
747            });
748        }
749
750        // Fetch content from URL with custom timeout
751        let content = fetch_spec_from_url_with_timeout(url, timeout).await?;
752        let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
753
754        // Validate against Aperture's supported feature set using SpecValidator
755        let validator = SpecValidator::new();
756        let validation_result = validator.validate_with_mode(&openapi_spec, strict);
757
758        // Check for errors first
759        if !validation_result.is_valid() {
760            return validation_result.into_result();
761        }
762
763        // Note: Not displaying warnings in test method
764
765        // Transform into internal cached representation using SpecTransformer
766        let transformer = SpecTransformer::new();
767
768        // Convert warnings to skip_endpoints format - only skip endpoints with NO JSON support
769        let skip_endpoints: Vec<(String, String)> = validation_result
770            .warnings
771            .iter()
772            .filter(|w| w.reason.contains("no supported content types"))
773            .map(|w| (w.endpoint.path.clone(), w.endpoint.method.clone()))
774            .collect();
775
776        let cached_spec = transformer.transform_with_warnings(
777            name,
778            &openapi_spec,
779            &skip_endpoints,
780            &validation_result.warnings,
781        )?;
782
783        // Create directories
784        let spec_parent = spec_path.parent().ok_or_else(|| Error::InvalidPath {
785            path: spec_path.display().to_string(),
786            reason: "Path has no parent directory".to_string(),
787        })?;
788        let cache_parent = cache_path.parent().ok_or_else(|| Error::InvalidPath {
789            path: cache_path.display().to_string(),
790            reason: "Path has no parent directory".to_string(),
791        })?;
792        self.fs.create_dir_all(spec_parent)?;
793        self.fs.create_dir_all(cache_parent)?;
794
795        // Write original spec file
796        self.fs.write_all(&spec_path, content.as_bytes())?;
797
798        // Serialize and write cached representation
799        let cached_data =
800            bincode::serialize(&cached_spec).map_err(|e| Error::SerializationError {
801                reason: e.to_string(),
802            })?;
803        self.fs.write_all(&cache_path, &cached_data)?;
804
805        // Save strict mode preference
806        self.save_strict_preference(name, strict)?;
807
808        Ok(())
809    }
810}
811
812/// Gets the default configuration directory path.
813///
814/// # Errors
815///
816/// Returns an error if the home directory cannot be determined.
817pub fn get_config_dir() -> Result<PathBuf, Error> {
818    let home_dir = dirs::home_dir().ok_or_else(|| Error::HomeDirectoryNotFound)?;
819    let config_dir = home_dir.join(".config").join("aperture");
820    Ok(config_dir)
821}
822
823/// Determines if the input string is a URL (starts with http:// or https://)
824#[must_use]
825pub fn is_url(input: &str) -> bool {
826    input.starts_with("http://") || input.starts_with("https://")
827}
828
829/// Fetches `OpenAPI` specification content from a URL with security limits
830///
831/// # Errors
832///
833/// Returns an error if:
834/// - Network request fails
835/// - Response status is not successful
836/// - Response size exceeds 10MB limit
837/// - Request times out (30 seconds)
838const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
839
840#[allow(clippy::future_not_send)]
841async fn fetch_spec_from_url(url: &str) -> Result<String, Error> {
842    fetch_spec_from_url_with_timeout(url, std::time::Duration::from_secs(30)).await
843}
844
845#[allow(clippy::future_not_send)]
846async fn fetch_spec_from_url_with_timeout(
847    url: &str,
848    timeout: std::time::Duration,
849) -> Result<String, Error> {
850    // Create HTTP client with timeout and security limits
851    let client = reqwest::Client::builder()
852        .timeout(timeout)
853        .build()
854        .map_err(|e| Error::RequestFailed {
855            reason: format!("Failed to create HTTP client: {e}"),
856        })?;
857
858    // Make the request
859    let response = client.get(url).send().await.map_err(|e| {
860        if e.is_timeout() {
861            Error::RequestFailed {
862                reason: format!("Request timed out after {} seconds", timeout.as_secs()),
863            }
864        } else if e.is_connect() {
865            Error::RequestFailed {
866                reason: format!("Failed to connect to {url}: {e}"),
867            }
868        } else {
869            Error::RequestFailed {
870                reason: format!("Network error: {e}"),
871            }
872        }
873    })?;
874
875    // Check response status
876    if !response.status().is_success() {
877        return Err(Error::RequestFailed {
878            reason: format!("HTTP {} from {url}", response.status()),
879        });
880    }
881
882    // Check content length before downloading
883    if let Some(content_length) = response.content_length() {
884        if content_length > MAX_RESPONSE_SIZE {
885            return Err(Error::RequestFailed {
886                reason: format!(
887                    "Response too large: {content_length} bytes (max {MAX_RESPONSE_SIZE} bytes)"
888                ),
889            });
890        }
891    }
892
893    // Read response body with size limit
894    let bytes = response.bytes().await.map_err(|e| Error::RequestFailed {
895        reason: format!("Failed to read response body: {e}"),
896    })?;
897
898    // Double-check size after download
899    if bytes.len() > usize::try_from(MAX_RESPONSE_SIZE).unwrap_or(usize::MAX) {
900        return Err(Error::RequestFailed {
901            reason: format!(
902                "Response too large: {} bytes (max {MAX_RESPONSE_SIZE} bytes)",
903                bytes.len()
904            ),
905        });
906    }
907
908    // Convert to string
909    String::from_utf8(bytes.to_vec()).map_err(|e| Error::RequestFailed {
910        reason: format!("Invalid UTF-8 in response: {e}"),
911    })
912}