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 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 config_dir(&self) -> &Path {
40 &self.config_dir
41 }
42
43 #[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 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 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 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 #[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 let (skipped_warnings, mixed_warnings): (Vec<_>, Vec<_>) = warnings
141 .iter()
142 .partition(|w| w.reason.contains("no supported content types"));
143
144 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 if !mixed_warnings.is_empty() {
181 if !skipped_warnings.is_empty() {
182 lines.push(String::new()); }
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 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 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 let validator = SpecValidator::new();
256 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
257
258 if !validation_result.is_valid() {
260 return validation_result.into_result();
261 }
262
263 let total_operations = Self::count_total_operations(&openapi_spec);
265
266 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
268
269 let transformer = SpecTransformer::new();
271
272 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 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 self.fs.write_all(&spec_path, content.as_bytes())?;
301
302 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 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 self.save_strict_preference(name, strict)?;
316
317 Ok(())
318 }
319
320 #[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 let content = fetch_spec_from_url(url).await?;
354 let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
355
356 let validator = SpecValidator::new();
358 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
359
360 if !validation_result.is_valid() {
362 return validation_result.into_result();
363 }
364
365 let total_operations = Self::count_total_operations(&openapi_spec);
367
368 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
370
371 let transformer = SpecTransformer::new();
373
374 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 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 self.fs.write_all(&spec_path, content.as_bytes())?;
403
404 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 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 self.save_strict_preference(name, strict)?;
418
419 Ok(())
420 }
421
422 #[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 let path = std::path::Path::new(file_or_url);
451 self.add_spec(name, path, force, strict)
452 }
453 }
454
455 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 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 let cache_dir = self.config_dir.join(".cache");
504 let metadata_manager = CacheMetadataManager::new(&self.fs);
505 let _ = metadata_manager.remove_spec_metadata(&cache_dir, name);
507
508 Ok(())
509 }
510
511 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(()) .ok_or_else(|| Error::EditorFailed {
537 name: name.to_string(),
538 })
539 }
540
541 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 pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
564 let config_path = self.config_dir.join("config.toml");
565
566 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 pub fn set_url(
588 &self,
589 api_name: &str,
590 url: &str,
591 environment: Option<&str>,
592 ) -> Result<(), Error> {
593 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 let mut config = self.load_global_config()?;
606
607 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 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 self.save_global_config(&config)?;
628 Ok(())
629 }
630
631 #[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 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 let cache_dir = self.config_dir.join(".cache");
660 let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
661
662 let config = self.load_global_config()?;
664
665 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 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 #[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 #[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 self.add_spec_from_url_with_timeout_and_mode(name, url, force, timeout, false)
727 .await
728 }
729
730 #[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 let content = fetch_spec_from_url_with_timeout(url, timeout).await?;
752 let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
753
754 let validator = SpecValidator::new();
756 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
757
758 if !validation_result.is_valid() {
760 return validation_result.into_result();
761 }
762
763 let transformer = SpecTransformer::new();
767
768 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 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 self.fs.write_all(&spec_path, content.as_bytes())?;
797
798 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 self.save_strict_preference(name, strict)?;
807
808 Ok(())
809 }
810}
811
812pub 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#[must_use]
825pub fn is_url(input: &str) -> bool {
826 input.starts_with("http://") || input.starts_with("https://")
827}
828
829const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; #[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 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 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 if !response.status().is_success() {
877 return Err(Error::RequestFailed {
878 reason: format!("HTTP {} from {url}", response.status()),
879 });
880 }
881
882 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 let bytes = response.bytes().await.map_err(|e| Error::RequestFailed {
895 reason: format!("Failed to read response body: {e}"),
896 })?;
897
898 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 String::from_utf8(bytes.to_vec()).map_err(|e| Error::RequestFailed {
910 reason: format!("Invalid UTF-8 in response: {e}"),
911 })
912}