1use crate::cache::metadata::CacheMetadataManager;
2use crate::config::models::{ApertureSecret, ApiConfig, GlobalConfig, SecretSource};
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
13struct CategorizedWarnings<'a> {
15 content_type: Vec<&'a crate::spec::validator::ValidationWarning>,
16 auth: Vec<&'a crate::spec::validator::ValidationWarning>,
17 mixed_content: Vec<&'a crate::spec::validator::ValidationWarning>,
18}
19
20pub struct ConfigManager<F: FileSystem> {
21 fs: F,
22 config_dir: PathBuf,
23}
24
25impl ConfigManager<OsFileSystem> {
26 pub fn new() -> Result<Self, Error> {
32 let config_dir = get_config_dir()?;
33 Ok(Self {
34 fs: OsFileSystem,
35 config_dir,
36 })
37 }
38}
39
40impl<F: FileSystem> ConfigManager<F> {
41 pub const fn with_fs(fs: F, config_dir: PathBuf) -> Self {
42 Self { fs, config_dir }
43 }
44
45 pub fn config_dir(&self) -> &Path {
47 &self.config_dir
48 }
49
50 #[must_use]
52 pub fn skipped_endpoints_to_warnings(
53 skipped_endpoints: &[crate::cache::models::SkippedEndpoint],
54 ) -> Vec<crate::spec::validator::ValidationWarning> {
55 skipped_endpoints
56 .iter()
57 .map(|endpoint| crate::spec::validator::ValidationWarning {
58 endpoint: crate::spec::validator::UnsupportedEndpoint {
59 path: endpoint.path.clone(),
60 method: endpoint.method.clone(),
61 content_type: endpoint.content_type.clone(),
62 },
63 reason: endpoint.reason.clone(),
64 })
65 .collect()
66 }
67
68 fn save_strict_preference(&self, api_name: &str, strict: bool) -> Result<(), Error> {
70 let mut config = self.load_global_config()?;
71 let api_config = config
72 .api_configs
73 .entry(api_name.to_string())
74 .or_insert_with(|| ApiConfig {
75 base_url_override: None,
76 environment_urls: HashMap::new(),
77 strict_mode: false,
78 secrets: HashMap::new(),
79 });
80 api_config.strict_mode = strict;
81 self.save_global_config(&config)?;
82 Ok(())
83 }
84
85 pub fn get_strict_preference(&self, api_name: &str) -> Result<bool, Error> {
91 let config = self.load_global_config()?;
92 Ok(config
93 .api_configs
94 .get(api_name)
95 .is_some_and(|c| c.strict_mode))
96 }
97
98 fn count_total_operations(spec: &OpenAPI) -> usize {
100 spec.paths
101 .iter()
102 .filter_map(|(_, path_item)| match path_item {
103 ReferenceOr::Item(item) => Some(item),
104 ReferenceOr::Reference { .. } => None,
105 })
106 .map(|item| {
107 let mut count = 0;
108 if item.get.is_some() {
109 count += 1;
110 }
111 if item.post.is_some() {
112 count += 1;
113 }
114 if item.put.is_some() {
115 count += 1;
116 }
117 if item.delete.is_some() {
118 count += 1;
119 }
120 if item.patch.is_some() {
121 count += 1;
122 }
123 if item.head.is_some() {
124 count += 1;
125 }
126 if item.options.is_some() {
127 count += 1;
128 }
129 if item.trace.is_some() {
130 count += 1;
131 }
132 count
133 })
134 .sum()
135 }
136
137 #[must_use]
139 pub fn format_validation_warnings(
140 warnings: &[crate::spec::validator::ValidationWarning],
141 total_operations: Option<usize>,
142 indent: &str,
143 ) -> Vec<String> {
144 let mut lines = Vec::new();
145
146 if !warnings.is_empty() {
147 let categorized_warnings = Self::categorize_warnings(warnings);
148 let total_skipped =
149 categorized_warnings.content_type.len() + categorized_warnings.auth.len();
150
151 Self::format_content_type_warnings(
152 &mut lines,
153 &categorized_warnings.content_type,
154 total_operations,
155 total_skipped,
156 indent,
157 );
158 Self::format_auth_warnings(
159 &mut lines,
160 &categorized_warnings.auth,
161 total_operations,
162 total_skipped,
163 indent,
164 !categorized_warnings.content_type.is_empty(),
165 );
166 Self::format_mixed_content_warnings(
167 &mut lines,
168 &categorized_warnings.mixed_content,
169 indent,
170 !categorized_warnings.content_type.is_empty()
171 || !categorized_warnings.auth.is_empty(),
172 );
173 }
174
175 lines
176 }
177
178 pub fn display_validation_warnings(
180 warnings: &[crate::spec::validator::ValidationWarning],
181 total_operations: Option<usize>,
182 ) {
183 if !warnings.is_empty() {
184 let lines = Self::format_validation_warnings(warnings, total_operations, "");
185 for line in lines {
186 if line.is_empty() {
187 eprintln!();
188 } else if line.starts_with("Skipping") || line.starts_with("Endpoints") {
189 eprintln!("Warning: {line}");
190 } else {
191 eprintln!("{line}");
192 }
193 }
194 eprintln!("\nUse --strict to reject specs with unsupported features.");
195 }
196 }
197
198 pub fn add_spec(
212 &self,
213 name: &str,
214 file_path: &Path,
215 force: bool,
216 strict: bool,
217 ) -> Result<(), Error> {
218 self.check_spec_exists(name, force)?;
219
220 let content = self.fs.read_to_string(file_path)?;
221 let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
222
223 let validator = SpecValidator::new();
225 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
226
227 if !validation_result.is_valid() {
229 return validation_result.into_result();
230 }
231
232 let total_operations = Self::count_total_operations(&openapi_spec);
234
235 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
237
238 self.add_spec_from_validated_openapi(
239 name,
240 &openapi_spec,
241 &content,
242 &validation_result,
243 strict,
244 )
245 }
246
247 #[allow(clippy::future_not_send)]
263 pub async fn add_spec_from_url(
264 &self,
265 name: &str,
266 url: &str,
267 force: bool,
268 strict: bool,
269 ) -> Result<(), Error> {
270 self.check_spec_exists(name, force)?;
271
272 let content = fetch_spec_from_url(url).await?;
274 let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
275
276 let validator = SpecValidator::new();
278 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
279
280 if !validation_result.is_valid() {
282 return validation_result.into_result();
283 }
284
285 let total_operations = Self::count_total_operations(&openapi_spec);
287
288 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
290
291 self.add_spec_from_validated_openapi(
292 name,
293 &openapi_spec,
294 &content,
295 &validation_result,
296 strict,
297 )
298 }
299
300 #[allow(clippy::future_not_send)]
316 pub async fn add_spec_auto(
317 &self,
318 name: &str,
319 file_or_url: &str,
320 force: bool,
321 strict: bool,
322 ) -> Result<(), Error> {
323 if is_url(file_or_url) {
324 self.add_spec_from_url(name, file_or_url, force, strict)
325 .await
326 } else {
327 let path = std::path::Path::new(file_or_url);
329 self.add_spec(name, path, force, strict)
330 }
331 }
332
333 pub fn list_specs(&self) -> Result<Vec<String>, Error> {
339 let specs_dir = self.config_dir.join("specs");
340 if !self.fs.exists(&specs_dir) {
341 return Ok(Vec::new());
342 }
343
344 let mut specs = Vec::new();
345 for entry in self.fs.read_dir(&specs_dir)? {
346 if self.fs.is_file(&entry) {
347 if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
348 if std::path::Path::new(file_name)
349 .extension()
350 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
351 {
352 specs.push(file_name.trim_end_matches(".yaml").to_string());
353 }
354 }
355 }
356 }
357 Ok(specs)
358 }
359
360 pub fn remove_spec(&self, name: &str) -> Result<(), Error> {
366 let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
367 let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
368
369 if !self.fs.exists(&spec_path) {
370 return Err(Error::SpecNotFound {
371 name: name.to_string(),
372 });
373 }
374
375 self.fs.remove_file(&spec_path)?;
376 if self.fs.exists(&cache_path) {
377 self.fs.remove_file(&cache_path)?;
378 }
379
380 let cache_dir = self.config_dir.join(".cache");
382 let metadata_manager = CacheMetadataManager::new(&self.fs);
383 let _ = metadata_manager.remove_spec_metadata(&cache_dir, name);
385
386 Ok(())
387 }
388
389 pub fn edit_spec(&self, name: &str) -> Result<(), Error> {
398 let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
399
400 if !self.fs.exists(&spec_path) {
401 return Err(Error::SpecNotFound {
402 name: name.to_string(),
403 });
404 }
405
406 let editor = std::env::var("EDITOR").map_err(|_| Error::EditorNotSet)?;
407
408 Command::new(editor)
409 .arg(&spec_path)
410 .status()
411 .map_err(Error::Io)?
412 .success()
413 .then_some(()) .ok_or_else(|| Error::EditorFailed {
415 name: name.to_string(),
416 })
417 }
418
419 pub fn load_global_config(&self) -> Result<GlobalConfig, Error> {
425 let config_path = self.config_dir.join("config.toml");
426 if self.fs.exists(&config_path) {
427 let content = self.fs.read_to_string(&config_path)?;
428 toml::from_str(&content).map_err(|e| Error::InvalidConfig {
429 reason: e.to_string(),
430 })
431 } else {
432 Ok(GlobalConfig::default())
433 }
434 }
435
436 pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
442 let config_path = self.config_dir.join("config.toml");
443
444 self.fs.create_dir_all(&self.config_dir)?;
446
447 let content = toml::to_string_pretty(config).map_err(|e| Error::SerializationError {
448 reason: format!("Failed to serialize config: {e}"),
449 })?;
450
451 self.fs.write_all(&config_path, content.as_bytes())?;
452 Ok(())
453 }
454
455 pub fn set_url(
466 &self,
467 api_name: &str,
468 url: &str,
469 environment: Option<&str>,
470 ) -> Result<(), Error> {
471 let spec_path = self
473 .config_dir
474 .join("specs")
475 .join(format!("{api_name}.yaml"));
476 if !self.fs.exists(&spec_path) {
477 return Err(Error::SpecNotFound {
478 name: api_name.to_string(),
479 });
480 }
481
482 let mut config = self.load_global_config()?;
484
485 let api_config = config
487 .api_configs
488 .entry(api_name.to_string())
489 .or_insert_with(|| ApiConfig {
490 base_url_override: None,
491 environment_urls: HashMap::new(),
492 strict_mode: false,
493 secrets: HashMap::new(),
494 });
495
496 if let Some(env) = environment {
498 api_config
499 .environment_urls
500 .insert(env.to_string(), url.to_string());
501 } else {
502 api_config.base_url_override = Some(url.to_string());
503 }
504
505 self.save_global_config(&config)?;
507 Ok(())
508 }
509
510 #[allow(clippy::type_complexity)]
522 pub fn get_url(
523 &self,
524 api_name: &str,
525 ) -> Result<(Option<String>, HashMap<String, String>, String), Error> {
526 let spec_path = self
528 .config_dir
529 .join("specs")
530 .join(format!("{api_name}.yaml"));
531 if !self.fs.exists(&spec_path) {
532 return Err(Error::SpecNotFound {
533 name: api_name.to_string(),
534 });
535 }
536
537 let cache_dir = self.config_dir.join(".cache");
539 let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
540
541 let config = self.load_global_config()?;
543
544 let api_config = config.api_configs.get(api_name);
546
547 let base_url_override = api_config.and_then(|c| c.base_url_override.clone());
548 let environment_urls = api_config
549 .map(|c| c.environment_urls.clone())
550 .unwrap_or_default();
551
552 let resolved_url = cached_spec.map_or_else(
554 || "https://api.example.com".to_string(),
555 |spec| {
556 let resolver = BaseUrlResolver::new(&spec);
557 let resolver = if api_config.is_some() {
558 resolver.with_global_config(&config)
559 } else {
560 resolver
561 };
562 resolver.resolve(None)
563 },
564 );
565
566 Ok((base_url_override, environment_urls, resolved_url))
567 }
568
569 #[allow(clippy::type_complexity)]
578 pub fn list_urls(
579 &self,
580 ) -> Result<HashMap<String, (Option<String>, HashMap<String, String>)>, Error> {
581 let config = self.load_global_config()?;
582
583 let mut result = HashMap::new();
584 for (api_name, api_config) in config.api_configs {
585 result.insert(
586 api_name,
587 (api_config.base_url_override, api_config.environment_urls),
588 );
589 }
590
591 Ok(result)
592 }
593
594 #[doc(hidden)]
596 #[allow(clippy::future_not_send)]
597 pub async fn add_spec_from_url_with_timeout(
598 &self,
599 name: &str,
600 url: &str,
601 force: bool,
602 timeout: std::time::Duration,
603 ) -> Result<(), Error> {
604 self.add_spec_from_url_with_timeout_and_mode(name, url, force, timeout, false)
606 .await
607 }
608
609 #[doc(hidden)]
611 #[allow(clippy::future_not_send)]
612 async fn add_spec_from_url_with_timeout_and_mode(
613 &self,
614 name: &str,
615 url: &str,
616 force: bool,
617 timeout: std::time::Duration,
618 strict: bool,
619 ) -> Result<(), Error> {
620 self.check_spec_exists(name, force)?;
621
622 let content = fetch_spec_from_url_with_timeout(url, timeout).await?;
624 let openapi_spec: OpenAPI = serde_yaml::from_str(&content)?;
625
626 let validator = SpecValidator::new();
628 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
629
630 if !validation_result.is_valid() {
632 return validation_result.into_result();
633 }
634
635 self.add_spec_from_validated_openapi(
638 name,
639 &openapi_spec,
640 &content,
641 &validation_result,
642 strict,
643 )
644 }
645
646 pub fn set_secret(
657 &self,
658 api_name: &str,
659 scheme_name: &str,
660 env_var_name: &str,
661 ) -> Result<(), Error> {
662 let spec_path = self
664 .config_dir
665 .join("specs")
666 .join(format!("{api_name}.yaml"));
667 if !self.fs.exists(&spec_path) {
668 return Err(Error::SpecNotFound {
669 name: api_name.to_string(),
670 });
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("specs")
717 .join(format!("{api_name}.yaml"));
718 if !self.fs.exists(&spec_path) {
719 return Err(Error::SpecNotFound {
720 name: api_name.to_string(),
721 });
722 }
723
724 let config = self.load_global_config()?;
726
727 let secrets = config
729 .api_configs
730 .get(api_name)
731 .map(|c| c.secrets.clone())
732 .unwrap_or_default();
733
734 Ok(secrets)
735 }
736
737 pub fn get_secret(
750 &self,
751 api_name: &str,
752 scheme_name: &str,
753 ) -> Result<Option<ApertureSecret>, Error> {
754 let secrets = self.list_secrets(api_name)?;
755 Ok(secrets.get(scheme_name).cloned())
756 }
757
758 pub fn remove_secret(&self, api_name: &str, scheme_name: &str) -> Result<(), Error> {
767 let spec_path = self
769 .config_dir
770 .join("specs")
771 .join(format!("{api_name}.yaml"));
772 if !self.fs.exists(&spec_path) {
773 return Err(Error::SpecNotFound {
774 name: api_name.to_string(),
775 });
776 }
777
778 let mut config = self.load_global_config()?;
780
781 let Some(api_config) = config.api_configs.get_mut(api_name) else {
783 return Err(Error::InvalidConfig {
784 reason: format!("No secrets configured for API '{api_name}'"),
785 });
786 };
787
788 if api_config.secrets.is_empty() {
790 return Err(Error::InvalidConfig {
791 reason: format!("No secrets configured for API '{api_name}'"),
792 });
793 }
794
795 if !api_config.secrets.contains_key(scheme_name) {
797 return Err(Error::InvalidConfig {
798 reason: format!(
799 "Secret for scheme '{scheme_name}' is not configured for API '{api_name}'"
800 ),
801 });
802 }
803
804 api_config.secrets.remove(scheme_name);
806
807 if api_config.secrets.is_empty() && api_config.base_url_override.is_none() {
809 config.api_configs.remove(api_name);
810 }
811
812 self.save_global_config(&config)?;
814
815 Ok(())
816 }
817
818 pub fn clear_secrets(&self, api_name: &str) -> Result<(), Error> {
826 let spec_path = self
828 .config_dir
829 .join("specs")
830 .join(format!("{api_name}.yaml"));
831 if !self.fs.exists(&spec_path) {
832 return Err(Error::SpecNotFound {
833 name: api_name.to_string(),
834 });
835 }
836
837 let mut config = self.load_global_config()?;
839
840 if let Some(api_config) = config.api_configs.get_mut(api_name) {
842 api_config.secrets.clear();
844
845 if api_config.base_url_override.is_none() {
847 config.api_configs.remove(api_name);
848 }
849
850 self.save_global_config(&config)?;
852 }
853 Ok(())
856 }
857
858 pub fn set_secret_interactive(&self, api_name: &str) -> Result<(), Error> {
879 let (cached_spec, current_secrets) = self.load_spec_for_interactive_config(api_name)?;
881
882 if cached_spec.security_schemes.is_empty() {
883 println!("No security schemes found in API '{api_name}'.");
884 return Ok(());
885 }
886
887 Self::display_interactive_header(api_name, &cached_spec);
888
889 let options = Self::build_security_scheme_options(&cached_spec, ¤t_secrets);
891
892 self.run_interactive_configuration_loop(
894 api_name,
895 &cached_spec,
896 ¤t_secrets,
897 &options,
898 )?;
899
900 println!("\nInteractive configuration complete!");
901 Ok(())
902 }
903
904 fn check_spec_exists(&self, name: &str, force: bool) -> Result<(), Error> {
910 let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
911
912 if self.fs.exists(&spec_path) && !force {
913 return Err(Error::SpecAlreadyExists {
914 name: name.to_string(),
915 });
916 }
917
918 Ok(())
919 }
920
921 fn transform_spec_to_cached(
927 name: &str,
928 openapi_spec: &OpenAPI,
929 validation_result: &crate::spec::validator::ValidationResult,
930 ) -> Result<crate::cache::models::CachedSpec, Error> {
931 let transformer = SpecTransformer::new();
932
933 let skip_endpoints: Vec<(String, String)> = validation_result
935 .warnings
936 .iter()
937 .filter_map(super::super::spec::validator::ValidationWarning::to_skip_endpoint)
938 .collect();
939
940 transformer.transform_with_warnings(
941 name,
942 openapi_spec,
943 &skip_endpoints,
944 &validation_result.warnings,
945 )
946 }
947
948 fn create_spec_directories(&self, name: &str) -> Result<(PathBuf, PathBuf), Error> {
954 let spec_path = self.config_dir.join("specs").join(format!("{name}.yaml"));
955 let cache_path = self.config_dir.join(".cache").join(format!("{name}.bin"));
956
957 let spec_parent = spec_path.parent().ok_or_else(|| Error::InvalidPath {
958 path: spec_path.display().to_string(),
959 reason: "Path has no parent directory".to_string(),
960 })?;
961 let cache_parent = cache_path.parent().ok_or_else(|| Error::InvalidPath {
962 path: cache_path.display().to_string(),
963 reason: "Path has no parent directory".to_string(),
964 })?;
965
966 self.fs.create_dir_all(spec_parent)?;
967 self.fs.create_dir_all(cache_parent)?;
968
969 Ok((spec_path, cache_path))
970 }
971
972 fn write_spec_files(
978 &self,
979 name: &str,
980 content: &str,
981 cached_spec: &crate::cache::models::CachedSpec,
982 spec_path: &Path,
983 cache_path: &Path,
984 ) -> Result<(), Error> {
985 self.fs.write_all(spec_path, content.as_bytes())?;
987
988 let cached_data =
990 bincode::serialize(cached_spec).map_err(|e| Error::SerializationError {
991 reason: e.to_string(),
992 })?;
993 self.fs.write_all(cache_path, &cached_data)?;
994
995 let cache_dir = self.config_dir.join(".cache");
997 let metadata_manager = CacheMetadataManager::new(&self.fs);
998 metadata_manager.update_spec_metadata(&cache_dir, name, cached_data.len() as u64)?;
999
1000 Ok(())
1001 }
1002
1003 fn add_spec_from_validated_openapi(
1009 &self,
1010 name: &str,
1011 openapi_spec: &OpenAPI,
1012 content: &str,
1013 validation_result: &crate::spec::validator::ValidationResult,
1014 strict: bool,
1015 ) -> Result<(), Error> {
1016 let cached_spec = Self::transform_spec_to_cached(name, openapi_spec, validation_result)?;
1018
1019 let (spec_path, cache_path) = self.create_spec_directories(name)?;
1021
1022 self.write_spec_files(name, content, &cached_spec, &spec_path, &cache_path)?;
1024
1025 self.save_strict_preference(name, strict)?;
1027
1028 Ok(())
1029 }
1030
1031 fn load_spec_for_interactive_config(
1037 &self,
1038 api_name: &str,
1039 ) -> Result<
1040 (
1041 crate::cache::models::CachedSpec,
1042 std::collections::HashMap<String, ApertureSecret>,
1043 ),
1044 Error,
1045 > {
1046 let spec_path = self
1048 .config_dir
1049 .join("specs")
1050 .join(format!("{api_name}.yaml"));
1051 if !self.fs.exists(&spec_path) {
1052 return Err(Error::SpecNotFound {
1053 name: api_name.to_string(),
1054 });
1055 }
1056
1057 let cache_dir = self.config_dir.join(".cache");
1059 let cached_spec = loader::load_cached_spec(&cache_dir, api_name)?;
1060
1061 let current_secrets = self.list_secrets(api_name)?;
1063
1064 Ok((cached_spec, current_secrets))
1065 }
1066
1067 fn display_interactive_header(api_name: &str, cached_spec: &crate::cache::models::CachedSpec) {
1069 println!("Interactive Secret Configuration for API: {api_name}");
1070 println!(
1071 "Found {} security scheme(s):\n",
1072 cached_spec.security_schemes.len()
1073 );
1074 }
1075
1076 fn build_security_scheme_options(
1078 cached_spec: &crate::cache::models::CachedSpec,
1079 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1080 ) -> Vec<(String, String)> {
1081 cached_spec
1082 .security_schemes
1083 .values()
1084 .map(|scheme| {
1085 let mut description = format!("{} ({})", scheme.scheme_type, scheme.name);
1086
1087 match scheme.scheme_type.as_str() {
1089 "apiKey" => {
1090 if let (Some(location), Some(param)) =
1091 (&scheme.location, &scheme.parameter_name)
1092 {
1093 description = format!("{description} - {location} parameter: {param}");
1094 }
1095 }
1096 "http" => {
1097 if let Some(http_scheme) = &scheme.scheme {
1098 description = format!("{description} - {http_scheme} authentication");
1099 }
1100 }
1101 _ => {}
1102 }
1103
1104 if current_secrets.contains_key(&scheme.name) {
1106 description = format!("{description} [CONFIGURED]");
1107 } else if scheme.aperture_secret.is_some() {
1108 description = format!("{description} [x-aperture-secret]");
1109 } else {
1110 description = format!("{description} [NOT CONFIGURED]");
1111 }
1112
1113 if let Some(openapi_desc) = &scheme.description {
1115 description = format!("{description} - {openapi_desc}");
1116 }
1117
1118 (scheme.name.clone(), description)
1119 })
1120 .collect()
1121 }
1122
1123 fn run_interactive_configuration_loop(
1129 &self,
1130 api_name: &str,
1131 cached_spec: &crate::cache::models::CachedSpec,
1132 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1133 options: &[(String, String)],
1134 ) -> Result<(), Error> {
1135 use crate::interactive::{confirm, prompt_for_input, select_from_options};
1136
1137 loop {
1138 let selected_scheme =
1139 select_from_options("\nSelect a security scheme to configure:", options)?;
1140
1141 let scheme = cached_spec.security_schemes.get(&selected_scheme).expect(
1142 "Selected scheme should exist in cached spec - menu validation ensures this",
1143 );
1144
1145 Self::display_scheme_configuration_details(&selected_scheme, scheme, current_secrets);
1146
1147 let env_var = prompt_for_input(&format!(
1149 "\nEnter environment variable name for '{selected_scheme}' (or press Enter to skip): "
1150 ))?;
1151
1152 if env_var.is_empty() {
1153 println!("Skipping configuration for '{selected_scheme}'");
1154 } else {
1155 self.handle_secret_configuration(api_name, &selected_scheme, &env_var)?;
1156 }
1157
1158 if !confirm("\nConfigure another security scheme?")? {
1160 break;
1161 }
1162 }
1163
1164 Ok(())
1165 }
1166
1167 fn display_scheme_configuration_details(
1169 selected_scheme: &str,
1170 scheme: &crate::cache::models::CachedSecurityScheme,
1171 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1172 ) {
1173 println!("\nConfiguration for '{selected_scheme}':");
1174 println!(" Type: {}", scheme.scheme_type);
1175 if let Some(desc) = &scheme.description {
1176 println!(" Description: {desc}");
1177 }
1178
1179 if let Some(current_secret) = current_secrets.get(selected_scheme) {
1181 println!(" Current: environment variable '{}'", current_secret.name);
1182 } else if let Some(aperture_secret) = &scheme.aperture_secret {
1183 println!(
1184 " Current: x-aperture-secret -> '{}'",
1185 aperture_secret.name
1186 );
1187 } else {
1188 println!(" Current: not configured");
1189 }
1190 }
1191
1192 fn handle_secret_configuration(
1198 &self,
1199 api_name: &str,
1200 selected_scheme: &str,
1201 env_var: &str,
1202 ) -> Result<(), Error> {
1203 use crate::interactive::confirm;
1204
1205 if let Err(e) = crate::interactive::validate_env_var_name(env_var) {
1207 println!("Invalid environment variable name: {e}");
1208 return Ok(()); }
1210
1211 println!("\nConfiguration Preview:");
1213 println!(" API: {api_name}");
1214 println!(" Scheme: {selected_scheme}");
1215 println!(" Environment Variable: {env_var}");
1216
1217 if confirm("Apply this configuration?")? {
1218 self.set_secret(api_name, selected_scheme, env_var)?;
1219 println!("Configuration saved successfully!");
1220 } else {
1221 println!("Configuration cancelled.");
1222 }
1223
1224 Ok(())
1225 }
1226
1227 fn categorize_warnings(
1229 warnings: &[crate::spec::validator::ValidationWarning],
1230 ) -> CategorizedWarnings<'_> {
1231 let mut categorized = CategorizedWarnings {
1232 content_type: Vec::new(),
1233 auth: Vec::new(),
1234 mixed_content: Vec::new(),
1235 };
1236
1237 for warning in warnings {
1238 if warning.reason.contains("no supported content types") {
1239 categorized.content_type.push(warning);
1240 } else if warning.reason.contains("unsupported authentication") {
1241 categorized.auth.push(warning);
1242 } else if warning
1243 .reason
1244 .contains("unsupported content types alongside JSON")
1245 {
1246 categorized.mixed_content.push(warning);
1247 }
1248 }
1249
1250 categorized
1251 }
1252
1253 fn format_content_type_warnings(
1255 lines: &mut Vec<String>,
1256 content_type_warnings: &[&crate::spec::validator::ValidationWarning],
1257 total_operations: Option<usize>,
1258 total_skipped: usize,
1259 indent: &str,
1260 ) {
1261 if content_type_warnings.is_empty() {
1262 return;
1263 }
1264
1265 let warning_msg = total_operations.map_or_else(
1266 || {
1267 format!(
1268 "{}Skipping {} endpoints with unsupported content types:",
1269 indent,
1270 content_type_warnings.len()
1271 )
1272 },
1273 |total| {
1274 let available = total.saturating_sub(total_skipped);
1275 format!(
1276 "{}Skipping {} endpoints with unsupported content types ({} of {} endpoints will be available):",
1277 indent,
1278 content_type_warnings.len(),
1279 available,
1280 total
1281 )
1282 },
1283 );
1284 lines.push(warning_msg);
1285
1286 for warning in content_type_warnings {
1287 lines.push(format!(
1288 "{} - {} {} ({}) - {}",
1289 indent,
1290 warning.endpoint.method,
1291 warning.endpoint.path,
1292 warning.endpoint.content_type,
1293 warning.reason
1294 ));
1295 }
1296 }
1297
1298 fn format_auth_warnings(
1300 lines: &mut Vec<String>,
1301 auth_warnings: &[&crate::spec::validator::ValidationWarning],
1302 total_operations: Option<usize>,
1303 total_skipped: usize,
1304 indent: &str,
1305 add_blank_line: bool,
1306 ) {
1307 if auth_warnings.is_empty() {
1308 return;
1309 }
1310
1311 if add_blank_line {
1312 lines.push(String::new()); }
1314
1315 let warning_msg = total_operations.map_or_else(
1316 || {
1317 format!(
1318 "{}Skipping {} endpoints with unsupported authentication:",
1319 indent,
1320 auth_warnings.len()
1321 )
1322 },
1323 |total| {
1324 let available = total.saturating_sub(total_skipped);
1325 format!(
1326 "{}Skipping {} endpoints with unsupported authentication ({} of {} endpoints will be available):",
1327 indent,
1328 auth_warnings.len(),
1329 available,
1330 total
1331 )
1332 },
1333 );
1334 lines.push(warning_msg);
1335
1336 for warning in auth_warnings {
1337 lines.push(format!(
1338 "{} - {} {} - {}",
1339 indent, warning.endpoint.method, warning.endpoint.path, warning.reason
1340 ));
1341 }
1342 }
1343
1344 fn format_mixed_content_warnings(
1346 lines: &mut Vec<String>,
1347 mixed_content_warnings: &[&crate::spec::validator::ValidationWarning],
1348 indent: &str,
1349 add_blank_line: bool,
1350 ) {
1351 if mixed_content_warnings.is_empty() {
1352 return;
1353 }
1354
1355 if add_blank_line {
1356 lines.push(String::new()); }
1358
1359 lines.push(format!(
1360 "{indent}Endpoints with partial content type support:"
1361 ));
1362 for warning in mixed_content_warnings {
1363 lines.push(format!(
1364 "{} - {} {} supports JSON but not: {}",
1365 indent,
1366 warning.endpoint.method,
1367 warning.endpoint.path,
1368 warning.endpoint.content_type
1369 ));
1370 }
1371 }
1372}
1373
1374pub fn get_config_dir() -> Result<PathBuf, Error> {
1380 let home_dir = dirs::home_dir().ok_or_else(|| Error::HomeDirectoryNotFound)?;
1381 let config_dir = home_dir.join(".config").join("aperture");
1382 Ok(config_dir)
1383}
1384
1385#[must_use]
1387pub fn is_url(input: &str) -> bool {
1388 input.starts_with("http://") || input.starts_with("https://")
1389}
1390
1391const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; #[allow(clippy::future_not_send)]
1403async fn fetch_spec_from_url(url: &str) -> Result<String, Error> {
1404 fetch_spec_from_url_with_timeout(url, std::time::Duration::from_secs(30)).await
1405}
1406
1407#[allow(clippy::future_not_send)]
1408async fn fetch_spec_from_url_with_timeout(
1409 url: &str,
1410 timeout: std::time::Duration,
1411) -> Result<String, Error> {
1412 let client = reqwest::Client::builder()
1414 .timeout(timeout)
1415 .build()
1416 .map_err(|e| Error::RequestFailed {
1417 reason: format!("Failed to create HTTP client: {e}"),
1418 })?;
1419
1420 let response = client.get(url).send().await.map_err(|e| {
1422 if e.is_timeout() {
1423 Error::RequestFailed {
1424 reason: format!("Request timed out after {} seconds", timeout.as_secs()),
1425 }
1426 } else if e.is_connect() {
1427 Error::RequestFailed {
1428 reason: format!("Failed to connect to {url}: {e}"),
1429 }
1430 } else {
1431 Error::RequestFailed {
1432 reason: format!("Network error: {e}"),
1433 }
1434 }
1435 })?;
1436
1437 if !response.status().is_success() {
1439 return Err(Error::RequestFailed {
1440 reason: format!("HTTP {} from {url}", response.status()),
1441 });
1442 }
1443
1444 if let Some(content_length) = response.content_length() {
1446 if content_length > MAX_RESPONSE_SIZE {
1447 return Err(Error::RequestFailed {
1448 reason: format!(
1449 "Response too large: {content_length} bytes (max {MAX_RESPONSE_SIZE} bytes)"
1450 ),
1451 });
1452 }
1453 }
1454
1455 let bytes = response.bytes().await.map_err(|e| Error::RequestFailed {
1457 reason: format!("Failed to read response body: {e}"),
1458 })?;
1459
1460 if bytes.len() > usize::try_from(MAX_RESPONSE_SIZE).unwrap_or(usize::MAX) {
1462 return Err(Error::RequestFailed {
1463 reason: format!(
1464 "Response too large: {} bytes (max {MAX_RESPONSE_SIZE} bytes)",
1465 bytes.len()
1466 ),
1467 });
1468 }
1469
1470 String::from_utf8(bytes.to_vec()).map_err(|e| Error::RequestFailed {
1472 reason: format!("Invalid UTF-8 in response: {e}"),
1473 })
1474}