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 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 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 let validator = SpecValidator::new();
66 validator.validate(&openapi_spec)?;
67
68 let transformer = SpecTransformer::new();
70 let cached_spec = transformer.transform(name, &openapi_spec);
71
72 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 self.fs.write_all(&spec_path, content.as_bytes())?;
86
87 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 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 #[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 let content = fetch_spec_from_url(url).await?;
130 let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
131
132 let validator = SpecValidator::new();
134 validator.validate(&openapi_spec)?;
135
136 let transformer = SpecTransformer::new();
138 let cached_spec = transformer.transform(name, &openapi_spec);
139
140 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 self.fs.write_all(&spec_path, content.as_bytes())?;
154
155 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 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 #[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 let path = std::path::Path::new(file_or_url);
197 self.add_spec(name, path, force)
198 }
199 }
200
201 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 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 let cache_dir = self.config_dir.join(".cache");
250 let metadata_manager = CacheMetadataManager::new(&self.fs);
251 let _ = metadata_manager.remove_spec_metadata(&cache_dir, name);
253
254 Ok(())
255 }
256
257 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(()) .ok_or_else(|| Error::EditorFailed {
283 name: name.to_string(),
284 })
285 }
286
287 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 pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
310 let config_path = self.config_dir.join("config.toml");
311
312 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 pub fn set_url(
334 &self,
335 api_name: &str,
336 url: &str,
337 environment: Option<&str>,
338 ) -> Result<(), Error> {
339 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 let mut config = self.load_global_config()?;
352
353 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 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 self.save_global_config(&config)?;
373 Ok(())
374 }
375
376 #[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 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 let cache_dir = self.config_dir.join(".cache");
405 let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
406
407 let config = self.load_global_config()?;
409
410 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 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 #[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 #[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 let content = fetch_spec_from_url_with_timeout(url, timeout).await?;
481 let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
482
483 let validator = SpecValidator::new();
485 validator.validate(&openapi_spec)?;
486
487 let transformer = SpecTransformer::new();
489 let cached_spec = transformer.transform(name, &openapi_spec);
490
491 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 self.fs.write_all(&spec_path, content.as_bytes())?;
505
506 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
515pub 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#[must_use]
528pub fn is_url(input: &str) -> bool {
529 input.starts_with("http://") || input.starts_with("https://")
530}
531
532const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; #[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 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 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 if !response.status().is_success() {
580 return Err(Error::RequestFailed {
581 reason: format!("HTTP {} from {url}", response.status()),
582 });
583 }
584
585 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 let bytes = response.bytes().await.map_err(|e| Error::RequestFailed {
598 reason: format!("Failed to read response body: {e}"),
599 })?;
600
601 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 String::from_utf8(bytes.to_vec()).map_err(|e| Error::RequestFailed {
613 reason: format!("Invalid UTF-8 in response: {e}"),
614 })
615}