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;
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    /// Adds a new `OpenAPI` specification to the configuration from a local file.
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if:
43    /// - The spec already exists and `force` is false
44    /// - File I/O operations fail
45    /// - The `OpenAPI` spec is invalid YAML
46    /// - The spec contains unsupported features
47    ///
48    /// # Panics
49    ///
50    /// Panics if the spec path parent directory is None (should not happen in normal usage).
51    pub fn add_spec(&self, name: &str, file_path: &Path, force: bool) -> Result<(), Error> {
52        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
53        let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
54
55        if self.fs.exists(&spec_path) && !force {
56            return Err(Error::SpecAlreadyExists {
57                name: name.to_string(),
58            });
59        }
60
61        let content = self.fs.read_to_string(file_path)?;
62        let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
63
64        // Validate against Aperture's supported feature set using SpecValidator
65        let validator = SpecValidator::new();
66        validator.validate(&openapi_spec)?;
67
68        // Transform into internal cached representation using SpecTransformer
69        let transformer = SpecTransformer::new();
70        let cached_spec = transformer.transform(name, &openapi_spec);
71
72        // Create directories
73        let spec_parent = spec_path.parent().ok_or_else(|| Error::InvalidPath {
74            path: spec_path.display().to_string(),
75            reason: "Path has no parent directory".to_string(),
76        })?;
77        let cache_parent = cache_path.parent().ok_or_else(|| Error::InvalidPath {
78            path: cache_path.display().to_string(),
79            reason: "Path has no parent directory".to_string(),
80        })?;
81        self.fs.create_dir_all(spec_parent)?;
82        self.fs.create_dir_all(cache_parent)?;
83
84        // Write original spec file
85        self.fs.write_all(&spec_path, content.as_bytes())?;
86
87        // Serialize and write cached representation
88        let cached_data =
89            bincode::serialize(&cached_spec).map_err(|e| Error::SerializationError {
90                reason: e.to_string(),
91            })?;
92        self.fs.write_all(&cache_path, &cached_data)?;
93
94        // Update cache metadata for optimized version checking
95        let cache_dir = self.config_dir.join(".cache");
96        let metadata_manager = CacheMetadataManager::new(&self.fs);
97        metadata_manager.update_spec_metadata(&cache_dir, name, cached_data.len() as u64)?;
98
99        Ok(())
100    }
101
102    /// Adds a new `OpenAPI` specification to the configuration from a URL.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if:
107    /// - The spec already exists and `force` is false
108    /// - Network requests fail
109    /// - The `OpenAPI` spec is invalid YAML
110    /// - The spec contains unsupported features
111    /// - Response size exceeds 10MB limit
112    /// - Request times out (30 seconds)
113    ///
114    /// # Panics
115    ///
116    /// Panics if the spec path parent directory is None (should not happen in normal usage).
117    #[allow(clippy::future_not_send)]
118    pub async fn add_spec_from_url(&self, name: &str, url: &str, force: bool) -> Result<(), Error> {
119        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
120        let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
121
122        if self.fs.exists(&spec_path) && !force {
123            return Err(Error::SpecAlreadyExists {
124                name: name.to_string(),
125            });
126        }
127
128        // Fetch content from URL
129        let content = fetch_spec_from_url(url).await?;
130        let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
131
132        // Validate against Aperture's supported feature set using SpecValidator
133        let validator = SpecValidator::new();
134        validator.validate(&openapi_spec)?;
135
136        // Transform into internal cached representation using SpecTransformer
137        let transformer = SpecTransformer::new();
138        let cached_spec = transformer.transform(name, &openapi_spec);
139
140        // Create directories
141        let spec_parent = spec_path.parent().ok_or_else(|| Error::InvalidPath {
142            path: spec_path.display().to_string(),
143            reason: "Path has no parent directory".to_string(),
144        })?;
145        let cache_parent = cache_path.parent().ok_or_else(|| Error::InvalidPath {
146            path: cache_path.display().to_string(),
147            reason: "Path has no parent directory".to_string(),
148        })?;
149        self.fs.create_dir_all(spec_parent)?;
150        self.fs.create_dir_all(cache_parent)?;
151
152        // Write original spec file
153        self.fs.write_all(&spec_path, content.as_bytes())?;
154
155        // Serialize and write cached representation
156        let cached_data =
157            bincode::serialize(&cached_spec).map_err(|e| Error::SerializationError {
158                reason: e.to_string(),
159            })?;
160        self.fs.write_all(&cache_path, &cached_data)?;
161
162        // Update cache metadata for optimized version checking
163        let cache_dir = self.config_dir.join(".cache");
164        let metadata_manager = CacheMetadataManager::new(&self.fs);
165        metadata_manager.update_spec_metadata(&cache_dir, name, cached_data.len() as u64)?;
166
167        Ok(())
168    }
169
170    /// Adds a new `OpenAPI` specification from either a file path or URL.
171    ///
172    /// This is a convenience method that automatically detects whether the input
173    /// is a URL or file path and calls the appropriate method.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if:
178    /// - The spec already exists and `force` is false
179    /// - File I/O operations fail (for local files)
180    /// - Network requests fail (for URLs)
181    /// - The `OpenAPI` spec is invalid YAML
182    /// - The spec contains unsupported features
183    /// - Response size exceeds 10MB limit (for URLs)
184    /// - Request times out (for URLs)
185    #[allow(clippy::future_not_send)]
186    pub async fn add_spec_auto(
187        &self,
188        name: &str,
189        file_or_url: &str,
190        force: bool,
191    ) -> Result<(), Error> {
192        if is_url(file_or_url) {
193            self.add_spec_from_url(name, file_or_url, force).await
194        } else {
195            // Convert file path string to Path and call sync method
196            let path = std::path::Path::new(file_or_url);
197            self.add_spec(name, path, force)
198        }
199    }
200
201    /// Lists all registered API contexts.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if the specs directory cannot be read.
206    pub fn list_specs(&self) -> Result<Vec<String>, Error> {
207        let specs_dir = self.config_dir.join("specs");
208        if !self.fs.exists(&specs_dir) {
209            return Ok(Vec::new());
210        }
211
212        let mut specs = Vec::new();
213        for entry in self.fs.read_dir(&specs_dir)? {
214            if self.fs.is_file(&entry) {
215                if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
216                    if std::path::Path::new(file_name)
217                        .extension()
218                        .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
219                    {
220                        specs.push(file_name.trim_end_matches(".yaml").to_string());
221                    }
222                }
223            }
224        }
225        Ok(specs)
226    }
227
228    /// Removes an API specification from the configuration.
229    ///
230    /// # Errors
231    ///
232    /// Returns an error if the spec does not exist or cannot be removed.
233    pub fn remove_spec(&self, name: &str) -> Result<(), Error> {
234        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
235        let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
236
237        if !self.fs.exists(&spec_path) {
238            return Err(Error::SpecNotFound {
239                name: name.to_string(),
240            });
241        }
242
243        self.fs.remove_file(&spec_path)?;
244        if self.fs.exists(&cache_path) {
245            self.fs.remove_file(&cache_path)?;
246        }
247
248        // Remove from cache metadata
249        let cache_dir = self.config_dir.join(".cache");
250        let metadata_manager = CacheMetadataManager::new(&self.fs);
251        // Ignore errors if metadata removal fails - the important files are already removed
252        let _ = metadata_manager.remove_spec_metadata(&cache_dir, name);
253
254        Ok(())
255    }
256
257    /// Opens an API specification in the default editor.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if:
262    /// - The spec does not exist.
263    /// - The `$EDITOR` environment variable is not set.
264    /// - The editor command fails to execute.
265    pub fn edit_spec(&self, name: &str) -> Result<(), Error> {
266        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
267
268        if !self.fs.exists(&spec_path) {
269            return Err(Error::SpecNotFound {
270                name: name.to_string(),
271            });
272        }
273
274        let editor = std::env::var("EDITOR").map_err(|_| Error::EditorNotSet)?;
275
276        Command::new(editor)
277            .arg(&spec_path)
278            .status()
279            .map_err(Error::Io)?
280            .success()
281            .then_some(()) // Convert bool to Option<()>
282            .ok_or_else(|| Error::EditorFailed {
283                name: name.to_string(),
284            })
285    }
286
287    /// Loads the global configuration from `config.toml`.
288    ///
289    /// # Errors
290    ///
291    /// Returns an error if the configuration file exists but cannot be read or parsed.
292    pub fn load_global_config(&self) -> Result<GlobalConfig, Error> {
293        let config_path = self.config_dir.join("config.toml");
294        if self.fs.exists(&config_path) {
295            let content = self.fs.read_to_string(&config_path)?;
296            toml::from_str(&content).map_err(|e| Error::InvalidConfig {
297                reason: e.to_string(),
298            })
299        } else {
300            Ok(GlobalConfig::default())
301        }
302    }
303
304    /// Saves the global configuration to `config.toml`.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if the configuration cannot be serialized or written.
309    pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
310        let config_path = self.config_dir.join("config.toml");
311
312        // Ensure config directory exists
313        self.fs.create_dir_all(&self.config_dir)?;
314
315        let content = toml::to_string_pretty(config).map_err(|e| Error::SerializationError {
316            reason: format!("Failed to serialize config: {e}"),
317        })?;
318
319        self.fs.write_all(&config_path, content.as_bytes())?;
320        Ok(())
321    }
322
323    /// Sets the base URL for an API specification.
324    ///
325    /// # Arguments
326    /// * `api_name` - The name of the API specification
327    /// * `url` - The base URL to set
328    /// * `environment` - Optional environment name for environment-specific URLs
329    ///
330    /// # Errors
331    ///
332    /// Returns an error if the spec doesn't exist or config cannot be saved.
333    pub fn set_url(
334        &self,
335        api_name: &str,
336        url: &str,
337        environment: Option<&str>,
338    ) -> Result<(), Error> {
339        // Verify the spec exists
340        let spec_path = self
341            .config_dir
342            .join("specs")
343            .join(format!("{api_name}.yaml"));
344        if !self.fs.exists(&spec_path) {
345            return Err(Error::SpecNotFound {
346                name: api_name.to_string(),
347            });
348        }
349
350        // Load current config
351        let mut config = self.load_global_config()?;
352
353        // Get or create API config
354        let api_config = config
355            .api_configs
356            .entry(api_name.to_string())
357            .or_insert_with(|| ApiConfig {
358                base_url_override: None,
359                environment_urls: HashMap::new(),
360            });
361
362        // Set the URL
363        if let Some(env) = environment {
364            api_config
365                .environment_urls
366                .insert(env.to_string(), url.to_string());
367        } else {
368            api_config.base_url_override = Some(url.to_string());
369        }
370
371        // Save updated config
372        self.save_global_config(&config)?;
373        Ok(())
374    }
375
376    /// Gets the base URL configuration for an API specification.
377    ///
378    /// # Arguments
379    /// * `api_name` - The name of the API specification
380    ///
381    /// # Returns
382    /// A tuple of (`base_url_override`, `environment_urls`, `resolved_url`)
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if the spec doesn't exist.
387    #[allow(clippy::type_complexity)]
388    pub fn get_url(
389        &self,
390        api_name: &str,
391    ) -> Result<(Option<String>, HashMap<String, String>, String), Error> {
392        // Verify the spec exists
393        let spec_path = self
394            .config_dir
395            .join("specs")
396            .join(format!("{api_name}.yaml"));
397        if !self.fs.exists(&spec_path) {
398            return Err(Error::SpecNotFound {
399                name: api_name.to_string(),
400            });
401        }
402
403        // Load the cached spec to get its base URL
404        let cache_dir = self.config_dir.join(".cache");
405        let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
406
407        // Load global config
408        let config = self.load_global_config()?;
409
410        // Get API config
411        let api_config = config.api_configs.get(api_name);
412
413        let base_url_override = api_config.and_then(|c| c.base_url_override.clone());
414        let environment_urls = api_config
415            .map(|c| c.environment_urls.clone())
416            .unwrap_or_default();
417
418        // Resolve the URL that would actually be used
419        let resolved_url = cached_spec.map_or_else(
420            || "https://api.example.com".to_string(),
421            |spec| {
422                let resolver = BaseUrlResolver::new(&spec);
423                let resolver = if api_config.is_some() {
424                    resolver.with_global_config(&config)
425                } else {
426                    resolver
427                };
428                resolver.resolve(None)
429            },
430        );
431
432        Ok((base_url_override, environment_urls, resolved_url))
433    }
434
435    /// Lists all configured base URLs across all API specifications.
436    ///
437    /// # Returns
438    /// A map of API names to their URL configurations
439    ///
440    /// # Errors
441    ///
442    /// Returns an error if the config cannot be loaded.
443    #[allow(clippy::type_complexity)]
444    pub fn list_urls(
445        &self,
446    ) -> Result<HashMap<String, (Option<String>, HashMap<String, String>)>, Error> {
447        let config = self.load_global_config()?;
448
449        let mut result = HashMap::new();
450        for (api_name, api_config) in config.api_configs {
451            result.insert(
452                api_name,
453                (api_config.base_url_override, api_config.environment_urls),
454            );
455        }
456
457        Ok(result)
458    }
459
460    /// Test-only method to add spec from URL with custom timeout
461    #[doc(hidden)]
462    #[allow(clippy::future_not_send)]
463    pub async fn add_spec_from_url_with_timeout(
464        &self,
465        name: &str,
466        url: &str,
467        force: bool,
468        timeout: std::time::Duration,
469    ) -> Result<(), Error> {
470        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
471        let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
472
473        if self.fs.exists(&spec_path) && !force {
474            return Err(Error::SpecAlreadyExists {
475                name: name.to_string(),
476            });
477        }
478
479        // Fetch content from URL with custom timeout
480        let content = fetch_spec_from_url_with_timeout(url, timeout).await?;
481        let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
482
483        // Validate against Aperture's supported feature set using SpecValidator
484        let validator = SpecValidator::new();
485        validator.validate(&openapi_spec)?;
486
487        // Transform into internal cached representation using SpecTransformer
488        let transformer = SpecTransformer::new();
489        let cached_spec = transformer.transform(name, &openapi_spec);
490
491        // Create directories
492        let spec_parent = spec_path.parent().ok_or_else(|| Error::InvalidPath {
493            path: spec_path.display().to_string(),
494            reason: "Path has no parent directory".to_string(),
495        })?;
496        let cache_parent = cache_path.parent().ok_or_else(|| Error::InvalidPath {
497            path: cache_path.display().to_string(),
498            reason: "Path has no parent directory".to_string(),
499        })?;
500        self.fs.create_dir_all(spec_parent)?;
501        self.fs.create_dir_all(cache_parent)?;
502
503        // Write original spec file
504        self.fs.write_all(&spec_path, content.as_bytes())?;
505
506        // Serialize and write cached representation
507        let cached_data = bincode::serialize(&cached_spec)
508            .map_err(|e| Error::Config(format!("Failed to serialize cached spec: {e}")))?;
509        self.fs.write_all(&cache_path, &cached_data)?;
510
511        Ok(())
512    }
513}
514
515/// Gets the default configuration directory path.
516///
517/// # Errors
518///
519/// Returns an error if the home directory cannot be determined.
520pub fn get_config_dir() -> Result<PathBuf, Error> {
521    let home_dir = dirs::home_dir().ok_or_else(|| Error::HomeDirectoryNotFound)?;
522    let config_dir = home_dir.join(".config").join("aperture");
523    Ok(config_dir)
524}
525
526/// Determines if the input string is a URL (starts with http:// or https://)
527#[must_use]
528pub fn is_url(input: &str) -> bool {
529    input.starts_with("http://") || input.starts_with("https://")
530}
531
532/// Fetches `OpenAPI` specification content from a URL with security limits
533///
534/// # Errors
535///
536/// Returns an error if:
537/// - Network request fails
538/// - Response status is not successful
539/// - Response size exceeds 10MB limit
540/// - Request times out (30 seconds)
541const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
542
543#[allow(clippy::future_not_send)]
544async fn fetch_spec_from_url(url: &str) -> Result<String, Error> {
545    fetch_spec_from_url_with_timeout(url, std::time::Duration::from_secs(30)).await
546}
547
548#[allow(clippy::future_not_send)]
549async fn fetch_spec_from_url_with_timeout(
550    url: &str,
551    timeout: std::time::Duration,
552) -> Result<String, Error> {
553    // Create HTTP client with timeout and security limits
554    let client = reqwest::Client::builder()
555        .timeout(timeout)
556        .build()
557        .map_err(|e| Error::RequestFailed {
558            reason: format!("Failed to create HTTP client: {e}"),
559        })?;
560
561    // Make the request
562    let response = client.get(url).send().await.map_err(|e| {
563        if e.is_timeout() {
564            Error::RequestFailed {
565                reason: format!("Request timed out after {} seconds", timeout.as_secs()),
566            }
567        } else if e.is_connect() {
568            Error::RequestFailed {
569                reason: format!("Failed to connect to {url}: {e}"),
570            }
571        } else {
572            Error::RequestFailed {
573                reason: format!("Network error: {e}"),
574            }
575        }
576    })?;
577
578    // Check response status
579    if !response.status().is_success() {
580        return Err(Error::RequestFailed {
581            reason: format!("HTTP {} from {url}", response.status()),
582        });
583    }
584
585    // Check content length before downloading
586    if let Some(content_length) = response.content_length() {
587        if content_length > MAX_RESPONSE_SIZE {
588            return Err(Error::RequestFailed {
589                reason: format!(
590                    "Response too large: {content_length} bytes (max {MAX_RESPONSE_SIZE} bytes)"
591                ),
592            });
593        }
594    }
595
596    // Read response body with size limit
597    let bytes = response.bytes().await.map_err(|e| Error::RequestFailed {
598        reason: format!("Failed to read response body: {e}"),
599    })?;
600
601    // Double-check size after download
602    if bytes.len() > usize::try_from(MAX_RESPONSE_SIZE).unwrap_or(usize::MAX) {
603        return Err(Error::RequestFailed {
604            reason: format!(
605                "Response too large: {} bytes (max {MAX_RESPONSE_SIZE} bytes)",
606                bytes.len()
607            ),
608        });
609    }
610
611    // Convert to string
612    String::from_utf8(bytes.to_vec()).map_err(|e| Error::RequestFailed {
613        reason: format!("Invalid UTF-8 in response: {e}"),
614    })
615}