aperture_cli/config/
manager.rs

1use crate::config::models::{ApiConfig, GlobalConfig};
2use crate::config::url_resolver::BaseUrlResolver;
3use crate::engine::loader;
4use crate::error::Error;
5use crate::fs::{FileSystem, OsFileSystem};
6use crate::spec::{SpecTransformer, SpecValidator};
7use openapiv3::OpenAPI;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12pub struct ConfigManager<F: FileSystem> {
13    fs: F,
14    config_dir: PathBuf,
15}
16
17impl ConfigManager<OsFileSystem> {
18    /// Creates a new `ConfigManager` with the default filesystem and config directory.
19    ///
20    /// # Errors
21    ///
22    /// Returns an error if the home directory cannot be determined.
23    pub fn new() -> Result<Self, Error> {
24        let config_dir = get_config_dir()?;
25        Ok(Self {
26            fs: OsFileSystem,
27            config_dir,
28        })
29    }
30}
31
32impl<F: FileSystem> ConfigManager<F> {
33    pub const fn with_fs(fs: F, config_dir: PathBuf) -> Self {
34        Self { fs, config_dir }
35    }
36
37    /// Adds a new `OpenAPI` specification to the configuration.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error if:
42    /// - The spec already exists and `force` is false
43    /// - File I/O operations fail
44    /// - The `OpenAPI` spec is invalid YAML
45    /// - The spec contains unsupported features
46    ///
47    /// # Panics
48    ///
49    /// Panics if the spec path parent directory is None (should not happen in normal usage).
50    pub fn add_spec(&self, name: &str, file_path: &Path, force: bool) -> Result<(), Error> {
51        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
52        let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
53
54        if self.fs.exists(&spec_path) && !force {
55            return Err(Error::SpecAlreadyExists {
56                name: name.to_string(),
57            });
58        }
59
60        let content = self.fs.read_to_string(file_path)?;
61        let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
62
63        // Validate against Aperture's supported feature set using SpecValidator
64        let validator = SpecValidator::new();
65        validator.validate(&openapi_spec)?;
66
67        // Transform into internal cached representation using SpecTransformer
68        let transformer = SpecTransformer::new();
69        let cached_spec = transformer.transform(name, &openapi_spec);
70
71        // Create directories
72        self.fs.create_dir_all(spec_path.parent().unwrap())?;
73        self.fs.create_dir_all(cache_path.parent().unwrap())?;
74
75        // Write original spec file
76        self.fs.write_all(&spec_path, content.as_bytes())?;
77
78        // Serialize and write cached representation
79        let cached_data =
80            bincode::serialize(&cached_spec).map_err(|e| Error::SerializationError {
81                reason: e.to_string(),
82            })?;
83        self.fs.write_all(&cache_path, &cached_data)?;
84
85        Ok(())
86    }
87
88    /// Lists all registered API contexts.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the specs directory cannot be read.
93    pub fn list_specs(&self) -> Result<Vec<String>, Error> {
94        let specs_dir = self.config_dir.join("specs");
95        if !self.fs.exists(&specs_dir) {
96            return Ok(Vec::new());
97        }
98
99        let mut specs = Vec::new();
100        for entry in self.fs.read_dir(&specs_dir)? {
101            if self.fs.is_file(&entry) {
102                if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
103                    if std::path::Path::new(file_name)
104                        .extension()
105                        .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
106                    {
107                        specs.push(file_name.trim_end_matches(".yaml").to_string());
108                    }
109                }
110            }
111        }
112        Ok(specs)
113    }
114
115    /// Removes an API specification from the configuration.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if the spec does not exist or cannot be removed.
120    pub fn remove_spec(&self, name: &str) -> Result<(), Error> {
121        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
122        let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
123
124        if !self.fs.exists(&spec_path) {
125            return Err(Error::SpecNotFound {
126                name: name.to_string(),
127            });
128        }
129
130        self.fs.remove_file(&spec_path)?;
131        if self.fs.exists(&cache_path) {
132            self.fs.remove_file(&cache_path)?;
133        }
134
135        Ok(())
136    }
137
138    /// Opens an API specification in the default editor.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if:
143    /// - The spec does not exist.
144    /// - The `$EDITOR` environment variable is not set.
145    /// - The editor command fails to execute.
146    pub fn edit_spec(&self, name: &str) -> Result<(), Error> {
147        let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
148
149        if !self.fs.exists(&spec_path) {
150            return Err(Error::SpecNotFound {
151                name: name.to_string(),
152            });
153        }
154
155        let editor = std::env::var("EDITOR").map_err(|_| Error::EditorNotSet)?;
156
157        Command::new(editor)
158            .arg(&spec_path)
159            .status()
160            .map_err(Error::Io)?
161            .success()
162            .then_some(()) // Convert bool to Option<()>
163            .ok_or_else(|| Error::EditorFailed {
164                name: name.to_string(),
165            })
166    }
167
168    /// Loads the global configuration from `config.toml`.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if the configuration file exists but cannot be read or parsed.
173    pub fn load_global_config(&self) -> Result<GlobalConfig, Error> {
174        let config_path = self.config_dir.join("config.toml");
175        if self.fs.exists(&config_path) {
176            let content = self.fs.read_to_string(&config_path)?;
177            toml::from_str(&content).map_err(|e| Error::InvalidConfig {
178                reason: e.to_string(),
179            })
180        } else {
181            Ok(GlobalConfig::default())
182        }
183    }
184
185    /// Saves the global configuration to `config.toml`.
186    ///
187    /// # Errors
188    ///
189    /// Returns an error if the configuration cannot be serialized or written.
190    pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
191        let config_path = self.config_dir.join("config.toml");
192
193        // Ensure config directory exists
194        self.fs.create_dir_all(&self.config_dir)?;
195
196        let content = toml::to_string_pretty(config).map_err(|e| Error::SerializationError {
197            reason: format!("Failed to serialize config: {e}"),
198        })?;
199
200        self.fs.write_all(&config_path, content.as_bytes())?;
201        Ok(())
202    }
203
204    /// Sets the base URL for an API specification.
205    ///
206    /// # Arguments
207    /// * `api_name` - The name of the API specification
208    /// * `url` - The base URL to set
209    /// * `environment` - Optional environment name for environment-specific URLs
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if the spec doesn't exist or config cannot be saved.
214    pub fn set_url(
215        &self,
216        api_name: &str,
217        url: &str,
218        environment: Option<&str>,
219    ) -> Result<(), Error> {
220        // Verify the spec exists
221        let spec_path = self
222            .config_dir
223            .join("specs")
224            .join(format!("{api_name}.yaml"));
225        if !self.fs.exists(&spec_path) {
226            return Err(Error::SpecNotFound {
227                name: api_name.to_string(),
228            });
229        }
230
231        // Load current config
232        let mut config = self.load_global_config()?;
233
234        // Get or create API config
235        let api_config = config
236            .api_configs
237            .entry(api_name.to_string())
238            .or_insert_with(|| ApiConfig {
239                base_url_override: None,
240                environment_urls: HashMap::new(),
241            });
242
243        // Set the URL
244        if let Some(env) = environment {
245            api_config
246                .environment_urls
247                .insert(env.to_string(), url.to_string());
248        } else {
249            api_config.base_url_override = Some(url.to_string());
250        }
251
252        // Save updated config
253        self.save_global_config(&config)?;
254        Ok(())
255    }
256
257    /// Gets the base URL configuration for an API specification.
258    ///
259    /// # Arguments
260    /// * `api_name` - The name of the API specification
261    ///
262    /// # Returns
263    /// A tuple of (`base_url_override`, `environment_urls`, `resolved_url`)
264    ///
265    /// # Errors
266    ///
267    /// Returns an error if the spec doesn't exist.
268    #[allow(clippy::type_complexity)]
269    pub fn get_url(
270        &self,
271        api_name: &str,
272    ) -> Result<(Option<String>, HashMap<String, String>, String), Error> {
273        // Verify the spec exists
274        let spec_path = self
275            .config_dir
276            .join("specs")
277            .join(format!("{api_name}.yaml"));
278        if !self.fs.exists(&spec_path) {
279            return Err(Error::SpecNotFound {
280                name: api_name.to_string(),
281            });
282        }
283
284        // Load the cached spec to get its base URL
285        let cache_dir = self.config_dir.join(".cache");
286        let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
287
288        // Load global config
289        let config = self.load_global_config()?;
290
291        // Get API config
292        let api_config = config.api_configs.get(api_name);
293
294        let base_url_override = api_config.and_then(|c| c.base_url_override.clone());
295        let environment_urls = api_config
296            .map(|c| c.environment_urls.clone())
297            .unwrap_or_default();
298
299        // Resolve the URL that would actually be used
300        let resolved_url = cached_spec.map_or_else(
301            || "https://api.example.com".to_string(),
302            |spec| {
303                let resolver = BaseUrlResolver::new(&spec);
304                let resolver = if api_config.is_some() {
305                    resolver.with_global_config(&config)
306                } else {
307                    resolver
308                };
309                resolver.resolve(None)
310            },
311        );
312
313        Ok((base_url_override, environment_urls, resolved_url))
314    }
315
316    /// Lists all configured base URLs across all API specifications.
317    ///
318    /// # Returns
319    /// A map of API names to their URL configurations
320    ///
321    /// # Errors
322    ///
323    /// Returns an error if the config cannot be loaded.
324    #[allow(clippy::type_complexity)]
325    pub fn list_urls(
326        &self,
327    ) -> Result<HashMap<String, (Option<String>, HashMap<String, String>)>, Error> {
328        let config = self.load_global_config()?;
329
330        let mut result = HashMap::new();
331        for (api_name, api_config) in config.api_configs {
332            result.insert(
333                api_name,
334                (api_config.base_url_override, api_config.environment_urls),
335            );
336        }
337
338        Ok(result)
339    }
340}
341
342/// Gets the default configuration directory path.
343///
344/// # Errors
345///
346/// Returns an error if the home directory cannot be determined.
347pub fn get_config_dir() -> Result<PathBuf, Error> {
348    let home_dir = dirs::home_dir().ok_or_else(|| Error::HomeDirectoryNotFound)?;
349    let config_dir = home_dir.join(".config").join("aperture");
350    Ok(config_dir)
351}