1use crate::cache::metadata::CacheMetadataManager;
2use crate::config::models::{ApertureSecret, ApiConfig, GlobalConfig, SecretSource};
3use crate::config::url_resolver::BaseUrlResolver;
4use crate::constants;
5use crate::engine::loader;
6use crate::error::Error;
7use crate::fs::{FileSystem, OsFileSystem};
8use crate::interactive::{confirm, prompt_for_input, select_from_options};
9use crate::spec::{SpecTransformer, SpecValidator};
10use openapiv3::{OpenAPI, ReferenceOr};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15struct CategorizedWarnings<'a> {
17 content_type: Vec<&'a crate::spec::validator::ValidationWarning>,
18 auth: Vec<&'a crate::spec::validator::ValidationWarning>,
19 mixed_content: Vec<&'a crate::spec::validator::ValidationWarning>,
20}
21
22pub struct ConfigManager<F: FileSystem> {
23 fs: F,
24 config_dir: PathBuf,
25}
26
27impl ConfigManager<OsFileSystem> {
28 pub fn new() -> Result<Self, Error> {
34 let config_dir = get_config_dir()?;
35 Ok(Self {
36 fs: OsFileSystem,
37 config_dir,
38 })
39 }
40}
41
42impl<F: FileSystem> ConfigManager<F> {
43 pub const fn with_fs(fs: F, config_dir: PathBuf) -> Self {
44 Self { fs, config_dir }
45 }
46
47 pub fn config_dir(&self) -> &Path {
49 &self.config_dir
50 }
51
52 #[must_use]
54 pub fn skipped_endpoints_to_warnings(
55 skipped_endpoints: &[crate::cache::models::SkippedEndpoint],
56 ) -> Vec<crate::spec::validator::ValidationWarning> {
57 skipped_endpoints
58 .iter()
59 .map(|endpoint| crate::spec::validator::ValidationWarning {
60 endpoint: crate::spec::validator::UnsupportedEndpoint {
61 path: endpoint.path.clone(),
62 method: endpoint.method.clone(),
63 content_type: endpoint.content_type.clone(),
64 },
65 reason: endpoint.reason.clone(),
66 })
67 .collect()
68 }
69
70 fn save_strict_preference(&self, api_name: &str, strict: bool) -> Result<(), Error> {
72 let mut config = self.load_global_config()?;
73 let api_config = config
74 .api_configs
75 .entry(api_name.to_string())
76 .or_insert_with(|| ApiConfig {
77 base_url_override: None,
78 environment_urls: HashMap::new(),
79 strict_mode: false,
80 secrets: HashMap::new(),
81 });
82 api_config.strict_mode = strict;
83 self.save_global_config(&config)?;
84 Ok(())
85 }
86
87 pub fn get_strict_preference(&self, api_name: &str) -> Result<bool, Error> {
93 let config = self.load_global_config()?;
94 Ok(config
95 .api_configs
96 .get(api_name)
97 .is_some_and(|c| c.strict_mode))
98 }
99
100 fn count_total_operations(spec: &OpenAPI) -> usize {
102 spec.paths
103 .iter()
104 .filter_map(|(_, path_item)| match path_item {
105 ReferenceOr::Item(item) => Some(item),
106 ReferenceOr::Reference { .. } => None,
107 })
108 .map(|item| {
109 let mut count = 0;
110 if item.get.is_some() {
111 count += 1;
112 }
113 if item.post.is_some() {
114 count += 1;
115 }
116 if item.put.is_some() {
117 count += 1;
118 }
119 if item.delete.is_some() {
120 count += 1;
121 }
122 if item.patch.is_some() {
123 count += 1;
124 }
125 if item.head.is_some() {
126 count += 1;
127 }
128 if item.options.is_some() {
129 count += 1;
130 }
131 if item.trace.is_some() {
132 count += 1;
133 }
134 count
135 })
136 .sum()
137 }
138
139 #[must_use]
141 pub fn format_validation_warnings(
142 warnings: &[crate::spec::validator::ValidationWarning],
143 total_operations: Option<usize>,
144 indent: &str,
145 ) -> Vec<String> {
146 let mut lines = Vec::new();
147
148 if !warnings.is_empty() {
149 let categorized_warnings = Self::categorize_warnings(warnings);
150 let total_skipped =
151 categorized_warnings.content_type.len() + categorized_warnings.auth.len();
152
153 Self::format_content_type_warnings(
154 &mut lines,
155 &categorized_warnings.content_type,
156 total_operations,
157 total_skipped,
158 indent,
159 );
160 Self::format_auth_warnings(
161 &mut lines,
162 &categorized_warnings.auth,
163 total_operations,
164 total_skipped,
165 indent,
166 !categorized_warnings.content_type.is_empty(),
167 );
168 Self::format_mixed_content_warnings(
169 &mut lines,
170 &categorized_warnings.mixed_content,
171 indent,
172 !categorized_warnings.content_type.is_empty()
173 || !categorized_warnings.auth.is_empty(),
174 );
175 }
176
177 lines
178 }
179
180 pub fn display_validation_warnings(
182 warnings: &[crate::spec::validator::ValidationWarning],
183 total_operations: Option<usize>,
184 ) {
185 if !warnings.is_empty() {
186 let lines = Self::format_validation_warnings(warnings, total_operations, "");
187 for line in lines {
188 if line.is_empty() {
189 eprintln!();
190 } else if line.starts_with("Skipping") || line.starts_with("Endpoints") {
191 eprintln!("{} {line}", crate::constants::MSG_WARNING_PREFIX);
192 } else {
193 eprintln!("{line}");
194 }
195 }
196 eprintln!("\nUse --strict to reject specs with unsupported features.");
197 }
198 }
199
200 pub fn add_spec(
214 &self,
215 name: &str,
216 file_path: &Path,
217 force: bool,
218 strict: bool,
219 ) -> Result<(), Error> {
220 self.check_spec_exists(name, force)?;
221
222 let content = self.fs.read_to_string(file_path)?;
223 let openapi_spec = crate::spec::parse_openapi(&content)?;
224
225 let validator = SpecValidator::new();
227 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
228
229 if !validation_result.is_valid() {
231 return validation_result.into_result();
232 }
233
234 let total_operations = Self::count_total_operations(&openapi_spec);
236
237 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
239
240 self.add_spec_from_validated_openapi(
241 name,
242 &openapi_spec,
243 &content,
244 &validation_result,
245 strict,
246 )
247 }
248
249 #[allow(clippy::future_not_send)]
265 pub async fn add_spec_from_url(
266 &self,
267 name: &str,
268 url: &str,
269 force: bool,
270 strict: bool,
271 ) -> Result<(), Error> {
272 self.check_spec_exists(name, force)?;
273
274 let content = fetch_spec_from_url(url).await?;
276 let openapi_spec = crate::spec::parse_openapi(&content)?;
277
278 let validator = SpecValidator::new();
280 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
281
282 if !validation_result.is_valid() {
284 return validation_result.into_result();
285 }
286
287 let total_operations = Self::count_total_operations(&openapi_spec);
289
290 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
292
293 self.add_spec_from_validated_openapi(
294 name,
295 &openapi_spec,
296 &content,
297 &validation_result,
298 strict,
299 )
300 }
301
302 #[allow(clippy::future_not_send)]
318 pub async fn add_spec_auto(
319 &self,
320 name: &str,
321 file_or_url: &str,
322 force: bool,
323 strict: bool,
324 ) -> Result<(), Error> {
325 if is_url(file_or_url) {
326 self.add_spec_from_url(name, file_or_url, force, strict)
327 .await
328 } else {
329 let path = std::path::Path::new(file_or_url);
331 self.add_spec(name, path, force, strict)
332 }
333 }
334
335 pub fn list_specs(&self) -> Result<Vec<String>, Error> {
341 let specs_dir = self.config_dir.join(crate::constants::DIR_SPECS);
342 if !self.fs.exists(&specs_dir) {
343 return Ok(Vec::new());
344 }
345
346 let mut specs = Vec::new();
347 for entry in self.fs.read_dir(&specs_dir)? {
348 if self.fs.is_file(&entry) {
349 if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
350 if std::path::Path::new(file_name)
351 .extension()
352 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
353 {
354 specs.push(
355 file_name
356 .trim_end_matches(crate::constants::FILE_EXT_YAML)
357 .to_string(),
358 );
359 }
360 }
361 }
362 }
363 Ok(specs)
364 }
365
366 pub fn remove_spec(&self, name: &str) -> Result<(), Error> {
372 let spec_path = self
373 .config_dir
374 .join(crate::constants::DIR_SPECS)
375 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
376 let cache_path = self
377 .config_dir
378 .join(crate::constants::DIR_CACHE)
379 .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
380
381 if !self.fs.exists(&spec_path) {
382 return Err(Error::spec_not_found(name));
383 }
384
385 self.fs.remove_file(&spec_path)?;
386 if self.fs.exists(&cache_path) {
387 self.fs.remove_file(&cache_path)?;
388 }
389
390 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
392 let metadata_manager = CacheMetadataManager::new(&self.fs);
393 let _ = metadata_manager.remove_spec_metadata(&cache_dir, name);
395
396 Ok(())
397 }
398
399 pub fn edit_spec(&self, name: &str) -> Result<(), Error> {
408 let spec_path = self
409 .config_dir
410 .join(crate::constants::DIR_SPECS)
411 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
412
413 if !self.fs.exists(&spec_path) {
414 return Err(Error::spec_not_found(name));
415 }
416
417 let editor = std::env::var("EDITOR").map_err(|_| Error::editor_not_set())?;
418
419 Command::new(editor)
420 .arg(&spec_path)
421 .status()
422 .map_err(|e| Error::io_error(format!("Failed to get editor process status: {e}")))?
423 .success()
424 .then_some(()) .ok_or_else(|| Error::editor_failed(name))
426 }
427
428 pub fn load_global_config(&self) -> Result<GlobalConfig, Error> {
434 let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
435 if self.fs.exists(&config_path) {
436 let content = self.fs.read_to_string(&config_path)?;
437 toml::from_str(&content).map_err(|e| Error::invalid_config(e.to_string()))
438 } else {
439 Ok(GlobalConfig::default())
440 }
441 }
442
443 pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
449 let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
450
451 self.fs.create_dir_all(&self.config_dir)?;
453
454 let content = toml::to_string_pretty(config)
455 .map_err(|e| Error::serialization_error(format!("Failed to serialize config: {e}")))?;
456
457 self.fs.write_all(&config_path, content.as_bytes())?;
458 Ok(())
459 }
460
461 pub fn set_url(
472 &self,
473 api_name: &str,
474 url: &str,
475 environment: Option<&str>,
476 ) -> Result<(), Error> {
477 let spec_path = self
479 .config_dir
480 .join(crate::constants::DIR_SPECS)
481 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
482 if !self.fs.exists(&spec_path) {
483 return Err(Error::spec_not_found(api_name));
484 }
485
486 let mut config = self.load_global_config()?;
488
489 let api_config = config
491 .api_configs
492 .entry(api_name.to_string())
493 .or_insert_with(|| ApiConfig {
494 base_url_override: None,
495 environment_urls: HashMap::new(),
496 strict_mode: false,
497 secrets: HashMap::new(),
498 });
499
500 if let Some(env) = environment {
502 api_config
503 .environment_urls
504 .insert(env.to_string(), url.to_string());
505 } else {
506 api_config.base_url_override = Some(url.to_string());
507 }
508
509 self.save_global_config(&config)?;
511 Ok(())
512 }
513
514 #[allow(clippy::type_complexity)]
526 pub fn get_url(
527 &self,
528 api_name: &str,
529 ) -> Result<(Option<String>, HashMap<String, String>, String), Error> {
530 let spec_path = self
532 .config_dir
533 .join(crate::constants::DIR_SPECS)
534 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
535 if !self.fs.exists(&spec_path) {
536 return Err(Error::spec_not_found(api_name));
537 }
538
539 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
541 let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
542
543 let config = self.load_global_config()?;
545
546 let api_config = config.api_configs.get(api_name);
548
549 let base_url_override = api_config.and_then(|c| c.base_url_override.clone());
550 let environment_urls = api_config
551 .map(|c| c.environment_urls.clone())
552 .unwrap_or_default();
553
554 let resolved_url = cached_spec.map_or_else(
556 || "https://api.example.com".to_string(),
557 |spec| {
558 let resolver = BaseUrlResolver::new(&spec);
559 let resolver = if api_config.is_some() {
560 resolver.with_global_config(&config)
561 } else {
562 resolver
563 };
564 resolver.resolve(None)
565 },
566 );
567
568 Ok((base_url_override, environment_urls, resolved_url))
569 }
570
571 #[allow(clippy::type_complexity)]
580 pub fn list_urls(
581 &self,
582 ) -> Result<HashMap<String, (Option<String>, HashMap<String, String>)>, Error> {
583 let config = self.load_global_config()?;
584
585 let mut result = HashMap::new();
586 for (api_name, api_config) in config.api_configs {
587 result.insert(
588 api_name,
589 (api_config.base_url_override, api_config.environment_urls),
590 );
591 }
592
593 Ok(result)
594 }
595
596 #[doc(hidden)]
598 #[allow(clippy::future_not_send)]
599 pub async fn add_spec_from_url_with_timeout(
600 &self,
601 name: &str,
602 url: &str,
603 force: bool,
604 timeout: std::time::Duration,
605 ) -> Result<(), Error> {
606 self.add_spec_from_url_with_timeout_and_mode(name, url, force, timeout, false)
608 .await
609 }
610
611 #[doc(hidden)]
613 #[allow(clippy::future_not_send)]
614 async fn add_spec_from_url_with_timeout_and_mode(
615 &self,
616 name: &str,
617 url: &str,
618 force: bool,
619 timeout: std::time::Duration,
620 strict: bool,
621 ) -> Result<(), Error> {
622 self.check_spec_exists(name, force)?;
623
624 let content = fetch_spec_from_url_with_timeout(url, timeout).await?;
626 let openapi_spec = crate::spec::parse_openapi(&content)?;
627
628 let validator = SpecValidator::new();
630 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
631
632 if !validation_result.is_valid() {
634 return validation_result.into_result();
635 }
636
637 self.add_spec_from_validated_openapi(
640 name,
641 &openapi_spec,
642 &content,
643 &validation_result,
644 strict,
645 )
646 }
647
648 pub fn set_secret(
659 &self,
660 api_name: &str,
661 scheme_name: &str,
662 env_var_name: &str,
663 ) -> Result<(), Error> {
664 let spec_path = self
666 .config_dir
667 .join(crate::constants::DIR_SPECS)
668 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
669 if !self.fs.exists(&spec_path) {
670 return Err(Error::spec_not_found(api_name));
671 }
672
673 let mut config = self.load_global_config()?;
675
676 let api_config = config
678 .api_configs
679 .entry(api_name.to_string())
680 .or_insert_with(|| ApiConfig {
681 base_url_override: None,
682 environment_urls: HashMap::new(),
683 strict_mode: false,
684 secrets: HashMap::new(),
685 });
686
687 api_config.secrets.insert(
689 scheme_name.to_string(),
690 ApertureSecret {
691 source: SecretSource::Env,
692 name: env_var_name.to_string(),
693 },
694 );
695
696 self.save_global_config(&config)?;
698 Ok(())
699 }
700
701 pub fn list_secrets(&self, api_name: &str) -> Result<HashMap<String, ApertureSecret>, Error> {
713 let spec_path = self
715 .config_dir
716 .join(crate::constants::DIR_SPECS)
717 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
718 if !self.fs.exists(&spec_path) {
719 return Err(Error::spec_not_found(api_name));
720 }
721
722 let config = self.load_global_config()?;
724
725 let secrets = config
727 .api_configs
728 .get(api_name)
729 .map(|c| c.secrets.clone())
730 .unwrap_or_default();
731
732 Ok(secrets)
733 }
734
735 pub fn get_secret(
748 &self,
749 api_name: &str,
750 scheme_name: &str,
751 ) -> Result<Option<ApertureSecret>, Error> {
752 let secrets = self.list_secrets(api_name)?;
753 Ok(secrets.get(scheme_name).cloned())
754 }
755
756 pub fn remove_secret(&self, api_name: &str, scheme_name: &str) -> Result<(), Error> {
765 let spec_path = self
767 .config_dir
768 .join(crate::constants::DIR_SPECS)
769 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
770 if !self.fs.exists(&spec_path) {
771 return Err(Error::spec_not_found(api_name));
772 }
773
774 let mut config = self.load_global_config()?;
776
777 let Some(api_config) = config.api_configs.get_mut(api_name) else {
779 return Err(Error::invalid_config(format!(
780 "No secrets configured for API '{api_name}'"
781 )));
782 };
783
784 if api_config.secrets.is_empty() {
786 return Err(Error::invalid_config(format!(
787 "No secrets configured for API '{api_name}'"
788 )));
789 }
790
791 if !api_config.secrets.contains_key(scheme_name) {
793 return Err(Error::invalid_config(format!(
794 "Secret for scheme '{scheme_name}' is not configured for API '{api_name}'"
795 )));
796 }
797
798 api_config.secrets.remove(scheme_name);
800
801 if api_config.secrets.is_empty() && api_config.base_url_override.is_none() {
803 config.api_configs.remove(api_name);
804 }
805
806 self.save_global_config(&config)?;
808
809 Ok(())
810 }
811
812 pub fn clear_secrets(&self, api_name: &str) -> Result<(), Error> {
820 let spec_path = self
822 .config_dir
823 .join(crate::constants::DIR_SPECS)
824 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
825 if !self.fs.exists(&spec_path) {
826 return Err(Error::spec_not_found(api_name));
827 }
828
829 let mut config = self.load_global_config()?;
831
832 if let Some(api_config) = config.api_configs.get_mut(api_name) {
834 api_config.secrets.clear();
836
837 if api_config.base_url_override.is_none() {
839 config.api_configs.remove(api_name);
840 }
841
842 self.save_global_config(&config)?;
844 }
845 Ok(())
848 }
849
850 pub fn set_secret_interactive(&self, api_name: &str) -> Result<(), Error> {
871 let (cached_spec, current_secrets) = self.load_spec_for_interactive_config(api_name)?;
873
874 if cached_spec.security_schemes.is_empty() {
875 println!("No security schemes found in API '{api_name}'.");
876 return Ok(());
877 }
878
879 Self::display_interactive_header(api_name, &cached_spec);
880
881 let options = Self::build_security_scheme_options(&cached_spec, ¤t_secrets);
883
884 self.run_interactive_configuration_loop(
886 api_name,
887 &cached_spec,
888 ¤t_secrets,
889 &options,
890 )?;
891
892 println!("\nInteractive configuration complete!");
893 Ok(())
894 }
895
896 fn check_spec_exists(&self, name: &str, force: bool) -> Result<(), Error> {
902 let spec_path = self
903 .config_dir
904 .join(crate::constants::DIR_SPECS)
905 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
906
907 if self.fs.exists(&spec_path) && !force {
908 return Err(Error::spec_already_exists(name));
909 }
910
911 Ok(())
912 }
913
914 fn transform_spec_to_cached(
920 name: &str,
921 openapi_spec: &OpenAPI,
922 validation_result: &crate::spec::validator::ValidationResult,
923 ) -> Result<crate::cache::models::CachedSpec, Error> {
924 let transformer = SpecTransformer::new();
925
926 let skip_endpoints: Vec<(String, String)> = validation_result
928 .warnings
929 .iter()
930 .filter_map(super::super::spec::validator::ValidationWarning::to_skip_endpoint)
931 .collect();
932
933 transformer.transform_with_warnings(
934 name,
935 openapi_spec,
936 &skip_endpoints,
937 &validation_result.warnings,
938 )
939 }
940
941 fn create_spec_directories(&self, name: &str) -> Result<(PathBuf, PathBuf), Error> {
947 let spec_path = self
948 .config_dir
949 .join(crate::constants::DIR_SPECS)
950 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
951 let cache_path = self
952 .config_dir
953 .join(crate::constants::DIR_CACHE)
954 .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
955
956 let spec_parent = spec_path.parent().ok_or_else(|| {
957 Error::invalid_path(
958 spec_path.display().to_string(),
959 "Path has no parent directory",
960 )
961 })?;
962 let cache_parent = cache_path.parent().ok_or_else(|| {
963 Error::invalid_path(
964 cache_path.display().to_string(),
965 "Path has no parent directory",
966 )
967 })?;
968
969 self.fs.create_dir_all(spec_parent)?;
970 self.fs.create_dir_all(cache_parent)?;
971
972 Ok((spec_path, cache_path))
973 }
974
975 fn write_spec_files(
981 &self,
982 name: &str,
983 content: &str,
984 cached_spec: &crate::cache::models::CachedSpec,
985 spec_path: &Path,
986 cache_path: &Path,
987 ) -> Result<(), Error> {
988 self.fs.write_all(spec_path, content.as_bytes())?;
990
991 let cached_data = bincode::serialize(cached_spec)
993 .map_err(|e| Error::serialization_error(e.to_string()))?;
994 self.fs.write_all(cache_path, &cached_data)?;
995
996 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
998 let metadata_manager = CacheMetadataManager::new(&self.fs);
999 metadata_manager.update_spec_metadata(&cache_dir, name, cached_data.len() as u64)?;
1000
1001 Ok(())
1002 }
1003
1004 fn add_spec_from_validated_openapi(
1010 &self,
1011 name: &str,
1012 openapi_spec: &OpenAPI,
1013 content: &str,
1014 validation_result: &crate::spec::validator::ValidationResult,
1015 strict: bool,
1016 ) -> Result<(), Error> {
1017 let cached_spec = Self::transform_spec_to_cached(name, openapi_spec, validation_result)?;
1019
1020 let (spec_path, cache_path) = self.create_spec_directories(name)?;
1022
1023 self.write_spec_files(name, content, &cached_spec, &spec_path, &cache_path)?;
1025
1026 self.save_strict_preference(name, strict)?;
1028
1029 Ok(())
1030 }
1031
1032 fn load_spec_for_interactive_config(
1038 &self,
1039 api_name: &str,
1040 ) -> Result<
1041 (
1042 crate::cache::models::CachedSpec,
1043 std::collections::HashMap<String, ApertureSecret>,
1044 ),
1045 Error,
1046 > {
1047 let spec_path = self
1049 .config_dir
1050 .join(crate::constants::DIR_SPECS)
1051 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
1052 if !self.fs.exists(&spec_path) {
1053 return Err(Error::spec_not_found(api_name));
1054 }
1055
1056 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
1058 let cached_spec = loader::load_cached_spec(&cache_dir, api_name)?;
1059
1060 let current_secrets = self.list_secrets(api_name)?;
1062
1063 Ok((cached_spec, current_secrets))
1064 }
1065
1066 fn display_interactive_header(api_name: &str, cached_spec: &crate::cache::models::CachedSpec) {
1068 println!("Interactive Secret Configuration for API: {api_name}");
1069 println!(
1070 "Found {} security scheme(s):\n",
1071 cached_spec.security_schemes.len()
1072 );
1073 }
1074
1075 fn build_security_scheme_options(
1077 cached_spec: &crate::cache::models::CachedSpec,
1078 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1079 ) -> Vec<(String, String)> {
1080 cached_spec
1081 .security_schemes
1082 .values()
1083 .map(|scheme| {
1084 let mut description = format!("{} ({})", scheme.scheme_type, scheme.name);
1085
1086 match scheme.scheme_type.as_str() {
1088 constants::AUTH_SCHEME_APIKEY => {
1089 if let (Some(location), Some(param)) =
1090 (&scheme.location, &scheme.parameter_name)
1091 {
1092 description = format!("{description} - {location} parameter: {param}");
1093 }
1094 }
1095 "http" => {
1096 if let Some(http_scheme) = &scheme.scheme {
1097 description = format!("{description} - {http_scheme} authentication");
1098 }
1099 }
1100 _ => {}
1101 }
1102
1103 if current_secrets.contains_key(&scheme.name) {
1105 description = format!("{description} [CONFIGURED]");
1106 } else if scheme.aperture_secret.is_some() {
1107 description = format!("{description} [x-aperture-secret]");
1108 } else {
1109 description = format!("{description} [NOT CONFIGURED]");
1110 }
1111
1112 if let Some(openapi_desc) = &scheme.description {
1114 description = format!("{description} - {openapi_desc}");
1115 }
1116
1117 (scheme.name.clone(), description)
1118 })
1119 .collect()
1120 }
1121
1122 fn run_interactive_configuration_loop(
1128 &self,
1129 api_name: &str,
1130 cached_spec: &crate::cache::models::CachedSpec,
1131 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1132 options: &[(String, String)],
1133 ) -> Result<(), Error> {
1134 loop {
1135 let selected_scheme =
1136 select_from_options("\nSelect a security scheme to configure:", options)?;
1137
1138 let scheme = cached_spec.security_schemes.get(&selected_scheme).expect(
1139 "Selected scheme should exist in cached spec - menu validation ensures this",
1140 );
1141
1142 Self::display_scheme_configuration_details(&selected_scheme, scheme, current_secrets);
1143
1144 let env_var = prompt_for_input(&format!(
1146 "\nEnter environment variable name for '{selected_scheme}' (or press Enter to skip): "
1147 ))?;
1148
1149 if env_var.is_empty() {
1150 println!("Skipping configuration for '{selected_scheme}'");
1151 } else {
1152 self.handle_secret_configuration(api_name, &selected_scheme, &env_var)?;
1153 }
1154
1155 if !confirm("\nConfigure another security scheme?")? {
1157 break;
1158 }
1159 }
1160
1161 Ok(())
1162 }
1163
1164 fn display_scheme_configuration_details(
1166 selected_scheme: &str,
1167 scheme: &crate::cache::models::CachedSecurityScheme,
1168 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1169 ) {
1170 println!("\nConfiguration for '{selected_scheme}':");
1171 println!(" Type: {}", scheme.scheme_type);
1172 if let Some(desc) = &scheme.description {
1173 println!(" Description: {desc}");
1174 }
1175
1176 if let Some(current_secret) = current_secrets.get(selected_scheme) {
1178 println!(" Current: environment variable '{}'", current_secret.name);
1179 } else if let Some(aperture_secret) = &scheme.aperture_secret {
1180 println!(
1181 " Current: x-aperture-secret -> '{}'",
1182 aperture_secret.name
1183 );
1184 } else {
1185 println!(" Current: not configured");
1186 }
1187 }
1188
1189 fn handle_secret_configuration(
1195 &self,
1196 api_name: &str,
1197 selected_scheme: &str,
1198 env_var: &str,
1199 ) -> Result<(), Error> {
1200 if let Err(e) = crate::interactive::validate_env_var_name(env_var) {
1202 println!("Invalid environment variable name: {e}");
1203 return Ok(()); }
1205
1206 println!("\nConfiguration Preview:");
1208 println!(" API: {api_name}");
1209 println!(" Scheme: {selected_scheme}");
1210 println!(" Environment Variable: {env_var}");
1211
1212 if confirm("Apply this configuration?")? {
1213 self.set_secret(api_name, selected_scheme, env_var)?;
1214 println!("Configuration saved successfully!");
1215 } else {
1216 println!("Configuration cancelled.");
1217 }
1218
1219 Ok(())
1220 }
1221
1222 fn categorize_warnings(
1224 warnings: &[crate::spec::validator::ValidationWarning],
1225 ) -> CategorizedWarnings<'_> {
1226 let mut categorized = CategorizedWarnings {
1227 content_type: Vec::new(),
1228 auth: Vec::new(),
1229 mixed_content: Vec::new(),
1230 };
1231
1232 for warning in warnings {
1233 if warning.reason.contains("no supported content types") {
1234 categorized.content_type.push(warning);
1235 } else if warning.reason.contains("unsupported authentication") {
1236 categorized.auth.push(warning);
1237 } else if warning
1238 .reason
1239 .contains("unsupported content types alongside JSON")
1240 {
1241 categorized.mixed_content.push(warning);
1242 }
1243 }
1244
1245 categorized
1246 }
1247
1248 fn format_content_type_warnings(
1250 lines: &mut Vec<String>,
1251 content_type_warnings: &[&crate::spec::validator::ValidationWarning],
1252 total_operations: Option<usize>,
1253 total_skipped: usize,
1254 indent: &str,
1255 ) {
1256 if content_type_warnings.is_empty() {
1257 return;
1258 }
1259
1260 let warning_msg = total_operations.map_or_else(
1261 || {
1262 format!(
1263 "{}Skipping {} endpoints with unsupported content types:",
1264 indent,
1265 content_type_warnings.len()
1266 )
1267 },
1268 |total| {
1269 let available = total.saturating_sub(total_skipped);
1270 format!(
1271 "{}Skipping {} endpoints with unsupported content types ({} of {} endpoints will be available):",
1272 indent,
1273 content_type_warnings.len(),
1274 available,
1275 total
1276 )
1277 },
1278 );
1279 lines.push(warning_msg);
1280
1281 for warning in content_type_warnings {
1282 lines.push(format!(
1283 "{} - {} {} ({}) - {}",
1284 indent,
1285 warning.endpoint.method,
1286 warning.endpoint.path,
1287 warning.endpoint.content_type,
1288 warning.reason
1289 ));
1290 }
1291 }
1292
1293 fn format_auth_warnings(
1295 lines: &mut Vec<String>,
1296 auth_warnings: &[&crate::spec::validator::ValidationWarning],
1297 total_operations: Option<usize>,
1298 total_skipped: usize,
1299 indent: &str,
1300 add_blank_line: bool,
1301 ) {
1302 if auth_warnings.is_empty() {
1303 return;
1304 }
1305
1306 if add_blank_line {
1307 lines.push(String::new()); }
1309
1310 let warning_msg = total_operations.map_or_else(
1311 || {
1312 format!(
1313 "{}Skipping {} endpoints with unsupported authentication:",
1314 indent,
1315 auth_warnings.len()
1316 )
1317 },
1318 |total| {
1319 let available = total.saturating_sub(total_skipped);
1320 format!(
1321 "{}Skipping {} endpoints with unsupported authentication ({} of {} endpoints will be available):",
1322 indent,
1323 auth_warnings.len(),
1324 available,
1325 total
1326 )
1327 },
1328 );
1329 lines.push(warning_msg);
1330
1331 for warning in auth_warnings {
1332 lines.push(format!(
1333 "{} - {} {} - {}",
1334 indent, warning.endpoint.method, warning.endpoint.path, warning.reason
1335 ));
1336 }
1337 }
1338
1339 fn format_mixed_content_warnings(
1341 lines: &mut Vec<String>,
1342 mixed_content_warnings: &[&crate::spec::validator::ValidationWarning],
1343 indent: &str,
1344 add_blank_line: bool,
1345 ) {
1346 if mixed_content_warnings.is_empty() {
1347 return;
1348 }
1349
1350 if add_blank_line {
1351 lines.push(String::new()); }
1353
1354 lines.push(format!(
1355 "{indent}Endpoints with partial content type support:"
1356 ));
1357 for warning in mixed_content_warnings {
1358 lines.push(format!(
1359 "{} - {} {} supports JSON but not: {}",
1360 indent,
1361 warning.endpoint.method,
1362 warning.endpoint.path,
1363 warning.endpoint.content_type
1364 ));
1365 }
1366 }
1367}
1368
1369pub fn get_config_dir() -> Result<PathBuf, Error> {
1375 let home_dir = dirs::home_dir().ok_or_else(Error::home_directory_not_found)?;
1376 let config_dir = home_dir.join(".config").join("aperture");
1377 Ok(config_dir)
1378}
1379
1380#[must_use]
1382pub fn is_url(input: &str) -> bool {
1383 input.starts_with("http://") || input.starts_with("https://")
1384}
1385
1386const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; #[allow(clippy::future_not_send)]
1398async fn fetch_spec_from_url(url: &str) -> Result<String, Error> {
1399 fetch_spec_from_url_with_timeout(url, std::time::Duration::from_secs(30)).await
1400}
1401
1402#[allow(clippy::future_not_send)]
1403async fn fetch_spec_from_url_with_timeout(
1404 url: &str,
1405 timeout: std::time::Duration,
1406) -> Result<String, Error> {
1407 let client = reqwest::Client::builder()
1409 .timeout(timeout)
1410 .build()
1411 .map_err(|e| Error::network_request_failed(format!("Failed to create HTTP client: {e}")))?;
1412
1413 let response = client.get(url).send().await.map_err(|e| {
1415 if e.is_timeout() {
1416 Error::network_request_failed(format!(
1417 "Request timed out after {} seconds",
1418 timeout.as_secs()
1419 ))
1420 } else if e.is_connect() {
1421 Error::network_request_failed(format!("Failed to connect to {url}: {e}"))
1422 } else {
1423 Error::network_request_failed(format!("Network error: {e}"))
1424 }
1425 })?;
1426
1427 if !response.status().is_success() {
1429 return Err(Error::request_failed(
1430 response.status(),
1431 format!("HTTP {} from {url}", response.status()),
1432 ));
1433 }
1434
1435 if let Some(content_length) = response.content_length() {
1437 if content_length > MAX_RESPONSE_SIZE {
1438 return Err(Error::network_request_failed(format!(
1439 "Response too large: {content_length} bytes (max {MAX_RESPONSE_SIZE} bytes)"
1440 )));
1441 }
1442 }
1443
1444 let bytes = response
1446 .bytes()
1447 .await
1448 .map_err(|e| Error::network_request_failed(format!("Failed to read response body: {e}")))?;
1449
1450 if bytes.len() > usize::try_from(MAX_RESPONSE_SIZE).unwrap_or(usize::MAX) {
1452 return Err(Error::network_request_failed(format!(
1453 "Response too large: {} bytes (max {MAX_RESPONSE_SIZE} bytes)",
1454 bytes.len()
1455 )));
1456 }
1457
1458 String::from_utf8(bytes.to_vec())
1460 .map_err(|e| Error::network_request_failed(format!("Invalid UTF-8 in response: {e}")))
1461}