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 match line.as_str() {
190 "" => {
191 eprintln!();
193 }
194 s if s.starts_with("Skipping") || s.starts_with("Endpoints") => {
195 eprintln!("{} {line}", crate::constants::MSG_WARNING_PREFIX);
197 }
198 _ => {
199 eprintln!("{line}");
201 }
202 }
203 }
204 eprintln!("\nUse --strict to reject specs with unsupported features.");
206 }
207 }
208
209 pub fn add_spec(
223 &self,
224 name: &str,
225 file_path: &Path,
226 force: bool,
227 strict: bool,
228 ) -> Result<(), Error> {
229 self.check_spec_exists(name, force)?;
230
231 let content = self.fs.read_to_string(file_path)?;
232 let openapi_spec = crate::spec::parse_openapi(&content)?;
233
234 let validator = SpecValidator::new();
236 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
237
238 if !validation_result.is_valid() {
240 return validation_result.into_result();
241 }
242
243 let total_operations = Self::count_total_operations(&openapi_spec);
245
246 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
248
249 self.add_spec_from_validated_openapi(
250 name,
251 &openapi_spec,
252 &content,
253 &validation_result,
254 strict,
255 )
256 }
257
258 #[allow(clippy::future_not_send)]
274 pub async fn add_spec_from_url(
275 &self,
276 name: &str,
277 url: &str,
278 force: bool,
279 strict: bool,
280 ) -> Result<(), Error> {
281 self.check_spec_exists(name, force)?;
282
283 let content = fetch_spec_from_url(url).await?;
285 let openapi_spec = crate::spec::parse_openapi(&content)?;
286
287 let validator = SpecValidator::new();
289 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
290
291 if !validation_result.is_valid() {
293 return validation_result.into_result();
294 }
295
296 let total_operations = Self::count_total_operations(&openapi_spec);
298
299 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
301
302 self.add_spec_from_validated_openapi(
303 name,
304 &openapi_spec,
305 &content,
306 &validation_result,
307 strict,
308 )
309 }
310
311 #[allow(clippy::future_not_send)]
327 pub async fn add_spec_auto(
328 &self,
329 name: &str,
330 file_or_url: &str,
331 force: bool,
332 strict: bool,
333 ) -> Result<(), Error> {
334 if is_url(file_or_url) {
335 self.add_spec_from_url(name, file_or_url, force, strict)
336 .await
337 } else {
338 let path = std::path::Path::new(file_or_url);
340 self.add_spec(name, path, force, strict)
341 }
342 }
343
344 pub fn list_specs(&self) -> Result<Vec<String>, Error> {
350 let specs_dir = self.config_dir.join(crate::constants::DIR_SPECS);
351 if !self.fs.exists(&specs_dir) {
352 return Ok(Vec::new());
353 }
354
355 let mut specs = Vec::new();
356 for entry in self.fs.read_dir(&specs_dir)? {
357 if !self.fs.is_file(&entry) {
359 continue;
360 }
361
362 let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) else {
364 continue;
365 };
366
367 if std::path::Path::new(file_name)
369 .extension()
370 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
371 {
372 specs.push(
373 file_name
374 .trim_end_matches(crate::constants::FILE_EXT_YAML)
375 .to_string(),
376 );
377 }
378 }
379 Ok(specs)
380 }
381
382 pub fn remove_spec(&self, name: &str) -> Result<(), Error> {
388 let spec_path = self
389 .config_dir
390 .join(crate::constants::DIR_SPECS)
391 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
392 let cache_path = self
393 .config_dir
394 .join(crate::constants::DIR_CACHE)
395 .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
396
397 if !self.fs.exists(&spec_path) {
398 return Err(Error::spec_not_found(name));
399 }
400
401 self.fs.remove_file(&spec_path)?;
402 if self.fs.exists(&cache_path) {
403 self.fs.remove_file(&cache_path)?;
404 }
405
406 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
408 let metadata_manager = CacheMetadataManager::new(&self.fs);
409 let _ = metadata_manager.remove_spec_metadata(&cache_dir, name);
411
412 Ok(())
413 }
414
415 pub fn edit_spec(&self, name: &str) -> Result<(), Error> {
424 let spec_path = self
425 .config_dir
426 .join(crate::constants::DIR_SPECS)
427 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
428
429 if !self.fs.exists(&spec_path) {
430 return Err(Error::spec_not_found(name));
431 }
432
433 let editor = std::env::var("EDITOR").map_err(|_| Error::editor_not_set())?;
434
435 let mut parts = editor.split_whitespace();
437 let program = parts.next().ok_or_else(Error::editor_not_set)?;
438 let args: Vec<&str> = parts.collect();
439
440 Command::new(program)
441 .args(&args)
442 .arg(&spec_path)
443 .status()
444 .map_err(|e| Error::io_error(format!("Failed to get editor process status: {e}")))?
445 .success()
446 .then_some(()) .ok_or_else(|| Error::editor_failed(name))
448 }
449
450 pub fn load_global_config(&self) -> Result<GlobalConfig, Error> {
456 let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
457 if self.fs.exists(&config_path) {
458 let content = self.fs.read_to_string(&config_path)?;
459 toml::from_str(&content).map_err(|e| Error::invalid_config(e.to_string()))
460 } else {
461 Ok(GlobalConfig::default())
462 }
463 }
464
465 pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
471 let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
472
473 self.fs.create_dir_all(&self.config_dir)?;
475
476 let content = toml::to_string_pretty(config)
477 .map_err(|e| Error::serialization_error(format!("Failed to serialize config: {e}")))?;
478
479 self.fs.write_all(&config_path, content.as_bytes())?;
480 Ok(())
481 }
482
483 pub fn set_setting(
497 &self,
498 key: &crate::config::settings::SettingKey,
499 value: &crate::config::settings::SettingValue,
500 ) -> Result<(), Error> {
501 use crate::config::settings::{SettingKey, SettingValue};
502 use toml_edit::DocumentMut;
503
504 let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
505
506 let content = if self.fs.exists(&config_path) {
508 self.fs.read_to_string(&config_path)?
509 } else {
510 String::new()
511 };
512
513 let mut doc: DocumentMut = content
514 .parse()
515 .map_err(|e| Error::invalid_config(format!("Failed to parse config: {e}")))?;
516
517 match (key, value) {
521 (SettingKey::DefaultTimeoutSecs, SettingValue::U64(v)) => {
522 doc["default_timeout_secs"] =
523 toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
524 }
525 (SettingKey::AgentDefaultsJsonErrors, SettingValue::Bool(v)) => {
526 if doc.get("agent_defaults").is_none() {
528 doc["agent_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
529 }
530 doc["agent_defaults"]["json_errors"] = toml_edit::value(*v);
531 }
532 (SettingKey::RetryDefaultsMaxAttempts, SettingValue::U64(v)) => {
533 if doc.get("retry_defaults").is_none() {
535 doc["retry_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
536 }
537 doc["retry_defaults"]["max_attempts"] =
538 toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
539 }
540 (SettingKey::RetryDefaultsInitialDelayMs, SettingValue::U64(v)) => {
541 if doc.get("retry_defaults").is_none() {
543 doc["retry_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
544 }
545 doc["retry_defaults"]["initial_delay_ms"] =
546 toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
547 }
548 (SettingKey::RetryDefaultsMaxDelayMs, SettingValue::U64(v)) => {
549 if doc.get("retry_defaults").is_none() {
551 doc["retry_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
552 }
553 doc["retry_defaults"]["max_delay_ms"] =
554 toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
555 }
556 (
558 SettingKey::DefaultTimeoutSecs
559 | SettingKey::RetryDefaultsMaxAttempts
560 | SettingKey::RetryDefaultsInitialDelayMs
561 | SettingKey::RetryDefaultsMaxDelayMs,
562 _,
563 ) => {
564 debug_assert!(false, "Integer settings require U64 value");
565 }
566 (SettingKey::AgentDefaultsJsonErrors, _) => {
567 debug_assert!(false, "AgentDefaultsJsonErrors requires Bool value");
568 }
569 }
570
571 self.fs.create_dir_all(&self.config_dir)?;
573
574 self.fs
576 .write_all(&config_path, doc.to_string().as_bytes())?;
577 Ok(())
578 }
579
580 pub fn get_setting(
589 &self,
590 key: &crate::config::settings::SettingKey,
591 ) -> Result<crate::config::settings::SettingValue, Error> {
592 let config = self.load_global_config()?;
593 Ok(key.value_from_config(&config))
594 }
595
596 pub fn list_settings(&self) -> Result<Vec<crate::config::settings::SettingInfo>, Error> {
602 use crate::config::settings::{SettingInfo, SettingKey};
603
604 let config = self.load_global_config()?;
605 let settings = SettingKey::ALL
606 .iter()
607 .map(|key| SettingInfo::new(*key, &key.value_from_config(&config)))
608 .collect();
609
610 Ok(settings)
611 }
612
613 pub fn set_url(
624 &self,
625 api_name: &str,
626 url: &str,
627 environment: Option<&str>,
628 ) -> Result<(), Error> {
629 let spec_path = self
631 .config_dir
632 .join(crate::constants::DIR_SPECS)
633 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
634 if !self.fs.exists(&spec_path) {
635 return Err(Error::spec_not_found(api_name));
636 }
637
638 let mut config = self.load_global_config()?;
640
641 let api_config = config
643 .api_configs
644 .entry(api_name.to_string())
645 .or_insert_with(|| ApiConfig {
646 base_url_override: None,
647 environment_urls: HashMap::new(),
648 strict_mode: false,
649 secrets: HashMap::new(),
650 });
651
652 if let Some(env) = environment {
654 api_config
655 .environment_urls
656 .insert(env.to_string(), url.to_string());
657 } else {
658 api_config.base_url_override = Some(url.to_string());
659 }
660
661 self.save_global_config(&config)?;
663 Ok(())
664 }
665
666 #[allow(clippy::type_complexity)]
678 pub fn get_url(
679 &self,
680 api_name: &str,
681 ) -> Result<(Option<String>, HashMap<String, String>, String), Error> {
682 let spec_path = self
684 .config_dir
685 .join(crate::constants::DIR_SPECS)
686 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
687 if !self.fs.exists(&spec_path) {
688 return Err(Error::spec_not_found(api_name));
689 }
690
691 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
693 let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
694
695 let config = self.load_global_config()?;
697
698 let api_config = config.api_configs.get(api_name);
700
701 let base_url_override = api_config.and_then(|c| c.base_url_override.clone());
702 let environment_urls = api_config
703 .map(|c| c.environment_urls.clone())
704 .unwrap_or_default();
705
706 let resolved_url = cached_spec.map_or_else(
708 || "https://api.example.com".to_string(),
709 |spec| {
710 let resolver = BaseUrlResolver::new(&spec);
711 let resolver = if api_config.is_some() {
712 resolver.with_global_config(&config)
713 } else {
714 resolver
715 };
716 resolver.resolve(None)
717 },
718 );
719
720 Ok((base_url_override, environment_urls, resolved_url))
721 }
722
723 #[allow(clippy::type_complexity)]
732 pub fn list_urls(
733 &self,
734 ) -> Result<HashMap<String, (Option<String>, HashMap<String, String>)>, Error> {
735 let config = self.load_global_config()?;
736
737 let mut result = HashMap::new();
738 for (api_name, api_config) in config.api_configs {
739 result.insert(
740 api_name,
741 (api_config.base_url_override, api_config.environment_urls),
742 );
743 }
744
745 Ok(result)
746 }
747
748 #[doc(hidden)]
750 #[allow(clippy::future_not_send)]
751 pub async fn add_spec_from_url_with_timeout(
752 &self,
753 name: &str,
754 url: &str,
755 force: bool,
756 timeout: std::time::Duration,
757 ) -> Result<(), Error> {
758 self.add_spec_from_url_with_timeout_and_mode(name, url, force, timeout, false)
760 .await
761 }
762
763 #[doc(hidden)]
765 #[allow(clippy::future_not_send)]
766 async fn add_spec_from_url_with_timeout_and_mode(
767 &self,
768 name: &str,
769 url: &str,
770 force: bool,
771 timeout: std::time::Duration,
772 strict: bool,
773 ) -> Result<(), Error> {
774 self.check_spec_exists(name, force)?;
775
776 let content = fetch_spec_from_url_with_timeout(url, timeout).await?;
778 let openapi_spec = crate::spec::parse_openapi(&content)?;
779
780 let validator = SpecValidator::new();
782 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
783
784 if !validation_result.is_valid() {
786 return validation_result.into_result();
787 }
788
789 self.add_spec_from_validated_openapi(
792 name,
793 &openapi_spec,
794 &content,
795 &validation_result,
796 strict,
797 )
798 }
799
800 pub fn set_secret(
811 &self,
812 api_name: &str,
813 scheme_name: &str,
814 env_var_name: &str,
815 ) -> Result<(), Error> {
816 let spec_path = self
818 .config_dir
819 .join(crate::constants::DIR_SPECS)
820 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
821 if !self.fs.exists(&spec_path) {
822 return Err(Error::spec_not_found(api_name));
823 }
824
825 let mut config = self.load_global_config()?;
827
828 let api_config = config
830 .api_configs
831 .entry(api_name.to_string())
832 .or_insert_with(|| ApiConfig {
833 base_url_override: None,
834 environment_urls: HashMap::new(),
835 strict_mode: false,
836 secrets: HashMap::new(),
837 });
838
839 api_config.secrets.insert(
841 scheme_name.to_string(),
842 ApertureSecret {
843 source: SecretSource::Env,
844 name: env_var_name.to_string(),
845 },
846 );
847
848 self.save_global_config(&config)?;
850 Ok(())
851 }
852
853 pub fn list_secrets(&self, api_name: &str) -> Result<HashMap<String, ApertureSecret>, Error> {
865 let spec_path = self
867 .config_dir
868 .join(crate::constants::DIR_SPECS)
869 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
870 if !self.fs.exists(&spec_path) {
871 return Err(Error::spec_not_found(api_name));
872 }
873
874 let config = self.load_global_config()?;
876
877 let secrets = config
879 .api_configs
880 .get(api_name)
881 .map(|c| c.secrets.clone())
882 .unwrap_or_default();
883
884 Ok(secrets)
885 }
886
887 pub fn get_secret(
900 &self,
901 api_name: &str,
902 scheme_name: &str,
903 ) -> Result<Option<ApertureSecret>, Error> {
904 let secrets = self.list_secrets(api_name)?;
905 Ok(secrets.get(scheme_name).cloned())
906 }
907
908 pub fn remove_secret(&self, api_name: &str, scheme_name: &str) -> Result<(), Error> {
917 let spec_path = self
919 .config_dir
920 .join(crate::constants::DIR_SPECS)
921 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
922 if !self.fs.exists(&spec_path) {
923 return Err(Error::spec_not_found(api_name));
924 }
925
926 let mut config = self.load_global_config()?;
928
929 let Some(api_config) = config.api_configs.get_mut(api_name) else {
931 return Err(Error::invalid_config(format!(
932 "No secrets configured for API '{api_name}'"
933 )));
934 };
935
936 if api_config.secrets.is_empty() {
938 return Err(Error::invalid_config(format!(
939 "No secrets configured for API '{api_name}'"
940 )));
941 }
942
943 if !api_config.secrets.contains_key(scheme_name) {
945 return Err(Error::invalid_config(format!(
946 "Secret for scheme '{scheme_name}' is not configured for API '{api_name}'"
947 )));
948 }
949
950 api_config.secrets.remove(scheme_name);
952
953 if api_config.secrets.is_empty() && api_config.base_url_override.is_none() {
955 config.api_configs.remove(api_name);
956 }
957
958 self.save_global_config(&config)?;
960
961 Ok(())
962 }
963
964 pub fn clear_secrets(&self, api_name: &str) -> Result<(), Error> {
972 let spec_path = self
974 .config_dir
975 .join(crate::constants::DIR_SPECS)
976 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
977 if !self.fs.exists(&spec_path) {
978 return Err(Error::spec_not_found(api_name));
979 }
980
981 let mut config = self.load_global_config()?;
983
984 let Some(api_config) = config.api_configs.get_mut(api_name) else {
986 return Ok(());
988 };
989
990 api_config.secrets.clear();
992
993 if api_config.base_url_override.is_none() {
995 config.api_configs.remove(api_name);
996 }
997
998 self.save_global_config(&config)?;
1000
1001 Ok(())
1002 }
1003
1004 pub fn set_secret_interactive(&self, api_name: &str) -> Result<(), Error> {
1025 let (cached_spec, current_secrets) = self.load_spec_for_interactive_config(api_name)?;
1027
1028 if cached_spec.security_schemes.is_empty() {
1029 println!("No security schemes found in API '{api_name}'.");
1031 return Ok(());
1032 }
1033
1034 Self::display_interactive_header(api_name, &cached_spec);
1035
1036 let options = Self::build_security_scheme_options(&cached_spec, ¤t_secrets);
1038
1039 self.run_interactive_configuration_loop(
1041 api_name,
1042 &cached_spec,
1043 ¤t_secrets,
1044 &options,
1045 )?;
1046
1047 println!("\nInteractive configuration complete!");
1049 Ok(())
1050 }
1051
1052 fn check_spec_exists(&self, name: &str, force: bool) -> Result<(), Error> {
1058 let spec_path = self
1059 .config_dir
1060 .join(crate::constants::DIR_SPECS)
1061 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
1062
1063 if self.fs.exists(&spec_path) && !force {
1064 return Err(Error::spec_already_exists(name));
1065 }
1066
1067 Ok(())
1068 }
1069
1070 fn transform_spec_to_cached(
1076 name: &str,
1077 openapi_spec: &OpenAPI,
1078 validation_result: &crate::spec::validator::ValidationResult,
1079 ) -> Result<crate::cache::models::CachedSpec, Error> {
1080 let transformer = SpecTransformer::new();
1081
1082 let skip_endpoints: Vec<(String, String)> = validation_result
1084 .warnings
1085 .iter()
1086 .filter_map(super::super::spec::validator::ValidationWarning::to_skip_endpoint)
1087 .collect();
1088
1089 transformer.transform_with_warnings(
1090 name,
1091 openapi_spec,
1092 &skip_endpoints,
1093 &validation_result.warnings,
1094 )
1095 }
1096
1097 fn create_spec_directories(&self, name: &str) -> Result<(PathBuf, PathBuf), Error> {
1103 let spec_path = self
1104 .config_dir
1105 .join(crate::constants::DIR_SPECS)
1106 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
1107 let cache_path = self
1108 .config_dir
1109 .join(crate::constants::DIR_CACHE)
1110 .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
1111
1112 let spec_parent = spec_path.parent().ok_or_else(|| {
1113 Error::invalid_path(
1114 spec_path.display().to_string(),
1115 "Path has no parent directory",
1116 )
1117 })?;
1118 let cache_parent = cache_path.parent().ok_or_else(|| {
1119 Error::invalid_path(
1120 cache_path.display().to_string(),
1121 "Path has no parent directory",
1122 )
1123 })?;
1124
1125 self.fs.create_dir_all(spec_parent)?;
1126 self.fs.create_dir_all(cache_parent)?;
1127
1128 Ok((spec_path, cache_path))
1129 }
1130
1131 fn write_spec_files(
1137 &self,
1138 name: &str,
1139 content: &str,
1140 cached_spec: &crate::cache::models::CachedSpec,
1141 spec_path: &Path,
1142 cache_path: &Path,
1143 ) -> Result<(), Error> {
1144 self.fs.write_all(spec_path, content.as_bytes())?;
1146
1147 let cached_data = bincode::serialize(cached_spec)
1149 .map_err(|e| Error::serialization_error(e.to_string()))?;
1150 self.fs.write_all(cache_path, &cached_data)?;
1151
1152 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
1154 let metadata_manager = CacheMetadataManager::new(&self.fs);
1155 metadata_manager.update_spec_metadata(&cache_dir, name, cached_data.len() as u64)?;
1156
1157 Ok(())
1158 }
1159
1160 fn add_spec_from_validated_openapi(
1166 &self,
1167 name: &str,
1168 openapi_spec: &OpenAPI,
1169 content: &str,
1170 validation_result: &crate::spec::validator::ValidationResult,
1171 strict: bool,
1172 ) -> Result<(), Error> {
1173 let cached_spec = Self::transform_spec_to_cached(name, openapi_spec, validation_result)?;
1175
1176 let (spec_path, cache_path) = self.create_spec_directories(name)?;
1178
1179 self.write_spec_files(name, content, &cached_spec, &spec_path, &cache_path)?;
1181
1182 self.save_strict_preference(name, strict)?;
1184
1185 Ok(())
1186 }
1187
1188 fn load_spec_for_interactive_config(
1194 &self,
1195 api_name: &str,
1196 ) -> Result<
1197 (
1198 crate::cache::models::CachedSpec,
1199 std::collections::HashMap<String, ApertureSecret>,
1200 ),
1201 Error,
1202 > {
1203 let spec_path = self
1205 .config_dir
1206 .join(crate::constants::DIR_SPECS)
1207 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
1208 if !self.fs.exists(&spec_path) {
1209 return Err(Error::spec_not_found(api_name));
1210 }
1211
1212 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
1214 let cached_spec = loader::load_cached_spec(&cache_dir, api_name)?;
1215
1216 let current_secrets = self.list_secrets(api_name)?;
1218
1219 Ok((cached_spec, current_secrets))
1220 }
1221
1222 fn display_interactive_header(api_name: &str, cached_spec: &crate::cache::models::CachedSpec) {
1224 println!("Interactive Secret Configuration for API: {api_name}");
1226 println!(
1228 "Found {} security scheme(s):\n",
1229 cached_spec.security_schemes.len()
1230 );
1231 }
1232
1233 fn build_security_scheme_options(
1235 cached_spec: &crate::cache::models::CachedSpec,
1236 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1237 ) -> Vec<(String, String)> {
1238 cached_spec
1239 .security_schemes
1240 .values()
1241 .map(|scheme| {
1242 let mut description = format!("{} ({})", scheme.scheme_type, scheme.name);
1243
1244 match scheme.scheme_type.as_str() {
1246 constants::AUTH_SCHEME_APIKEY => {
1247 if let (Some(location), Some(param)) =
1248 (&scheme.location, &scheme.parameter_name)
1249 {
1250 description = format!("{description} - {location} parameter: {param}");
1251 }
1252 }
1253 "http" => {
1254 if let Some(http_scheme) = &scheme.scheme {
1255 description = format!("{description} - {http_scheme} authentication");
1256 }
1257 }
1258 _ => {}
1259 }
1260
1261 description = match (
1263 current_secrets.contains_key(&scheme.name),
1264 &scheme.aperture_secret,
1265 ) {
1266 (true, _) => format!("{description} [CONFIGURED]"),
1267 (false, Some(_)) => format!("{description} [x-aperture-secret]"),
1268 (false, None) => format!("{description} [NOT CONFIGURED]"),
1269 };
1270
1271 if let Some(openapi_desc) = &scheme.description {
1273 description = format!("{description} - {openapi_desc}");
1274 }
1275
1276 (scheme.name.clone(), description)
1277 })
1278 .collect()
1279 }
1280
1281 fn run_interactive_configuration_loop(
1287 &self,
1288 api_name: &str,
1289 cached_spec: &crate::cache::models::CachedSpec,
1290 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1291 options: &[(String, String)],
1292 ) -> Result<(), Error> {
1293 loop {
1294 let selected_scheme =
1295 select_from_options("\nSelect a security scheme to configure:", options)?;
1296
1297 let scheme = cached_spec.security_schemes.get(&selected_scheme).expect(
1298 "Selected scheme should exist in cached spec - menu validation ensures this",
1299 );
1300
1301 Self::display_scheme_configuration_details(&selected_scheme, scheme, current_secrets);
1302
1303 let env_var = prompt_for_input(&format!(
1305 "\nEnter environment variable name for '{selected_scheme}' (or press Enter to skip): "
1306 ))?;
1307
1308 if env_var.is_empty() {
1309 println!("Skipping configuration for '{selected_scheme}'");
1311 } else {
1312 self.handle_secret_configuration(api_name, &selected_scheme, &env_var)?;
1313 }
1314
1315 if !confirm("\nConfigure another security scheme?")? {
1317 break;
1318 }
1319 }
1320
1321 Ok(())
1322 }
1323
1324 fn display_scheme_configuration_details(
1326 selected_scheme: &str,
1327 scheme: &crate::cache::models::CachedSecurityScheme,
1328 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1329 ) {
1330 println!("\nConfiguration for '{selected_scheme}':");
1332 println!(" Type: {}", scheme.scheme_type);
1334 if let Some(desc) = &scheme.description {
1335 println!(" Description: {desc}");
1337 }
1338
1339 match (
1341 current_secrets.get(selected_scheme),
1342 &scheme.aperture_secret,
1343 ) {
1344 (Some(current_secret), _) => {
1345 println!(" Current: environment variable '{}'", current_secret.name);
1347 }
1348 (None, Some(aperture_secret)) => {
1349 println!(
1351 " Current: x-aperture-secret -> '{}'",
1352 aperture_secret.name
1353 );
1354 }
1355 (None, None) => {
1356 println!(" Current: not configured");
1358 }
1359 }
1360 }
1361
1362 fn handle_secret_configuration(
1368 &self,
1369 api_name: &str,
1370 selected_scheme: &str,
1371 env_var: &str,
1372 ) -> Result<(), Error> {
1373 if let Err(e) = crate::interactive::validate_env_var_name(env_var) {
1375 println!("Invalid environment variable name: {e}");
1377 return Ok(()); }
1379
1380 println!("\nConfiguration Preview:");
1383 println!(" API: {api_name}");
1385 println!(" Scheme: {selected_scheme}");
1387 println!(" Environment Variable: {env_var}");
1389
1390 if confirm("Apply this configuration?")? {
1391 self.set_secret(api_name, selected_scheme, env_var)?;
1392 println!("Configuration saved successfully!");
1394 } else {
1395 println!("Configuration cancelled.");
1397 }
1398
1399 Ok(())
1400 }
1401
1402 fn categorize_warnings(
1404 warnings: &[crate::spec::validator::ValidationWarning],
1405 ) -> CategorizedWarnings<'_> {
1406 let mut categorized = CategorizedWarnings {
1407 content_type: Vec::new(),
1408 auth: Vec::new(),
1409 mixed_content: Vec::new(),
1410 };
1411
1412 for warning in warnings {
1413 if warning.reason.contains("no supported content types") {
1415 categorized.content_type.push(warning);
1416 continue;
1417 }
1418
1419 if warning.reason.contains("unsupported authentication") {
1420 categorized.auth.push(warning);
1421 continue;
1422 }
1423
1424 if warning
1425 .reason
1426 .contains("unsupported content types alongside JSON")
1427 {
1428 categorized.mixed_content.push(warning);
1429 }
1430 }
1431
1432 categorized
1433 }
1434
1435 fn format_content_type_warnings(
1437 lines: &mut Vec<String>,
1438 content_type_warnings: &[&crate::spec::validator::ValidationWarning],
1439 total_operations: Option<usize>,
1440 total_skipped: usize,
1441 indent: &str,
1442 ) {
1443 if content_type_warnings.is_empty() {
1444 return;
1445 }
1446
1447 let warning_msg = total_operations.map_or_else(
1448 || {
1449 format!(
1450 "{}Skipping {} endpoints with unsupported content types:",
1451 indent,
1452 content_type_warnings.len()
1453 )
1454 },
1455 |total| {
1456 let available = total.saturating_sub(total_skipped);
1457 format!(
1458 "{}Skipping {} endpoints with unsupported content types ({} of {} endpoints will be available):",
1459 indent,
1460 content_type_warnings.len(),
1461 available,
1462 total
1463 )
1464 },
1465 );
1466 lines.push(warning_msg);
1467
1468 for warning in content_type_warnings {
1469 lines.push(format!(
1470 "{} - {} {} ({}) - {}",
1471 indent,
1472 warning.endpoint.method,
1473 warning.endpoint.path,
1474 warning.endpoint.content_type,
1475 warning.reason
1476 ));
1477 }
1478 }
1479
1480 fn format_auth_warnings(
1482 lines: &mut Vec<String>,
1483 auth_warnings: &[&crate::spec::validator::ValidationWarning],
1484 total_operations: Option<usize>,
1485 total_skipped: usize,
1486 indent: &str,
1487 add_blank_line: bool,
1488 ) {
1489 if auth_warnings.is_empty() {
1490 return;
1491 }
1492
1493 if add_blank_line {
1494 lines.push(String::new()); }
1496
1497 let warning_msg = total_operations.map_or_else(
1498 || {
1499 format!(
1500 "{}Skipping {} endpoints with unsupported authentication:",
1501 indent,
1502 auth_warnings.len()
1503 )
1504 },
1505 |total| {
1506 let available = total.saturating_sub(total_skipped);
1507 format!(
1508 "{}Skipping {} endpoints with unsupported authentication ({} of {} endpoints will be available):",
1509 indent,
1510 auth_warnings.len(),
1511 available,
1512 total
1513 )
1514 },
1515 );
1516 lines.push(warning_msg);
1517
1518 for warning in auth_warnings {
1519 lines.push(format!(
1520 "{} - {} {} - {}",
1521 indent, warning.endpoint.method, warning.endpoint.path, warning.reason
1522 ));
1523 }
1524 }
1525
1526 fn format_mixed_content_warnings(
1528 lines: &mut Vec<String>,
1529 mixed_content_warnings: &[&crate::spec::validator::ValidationWarning],
1530 indent: &str,
1531 add_blank_line: bool,
1532 ) {
1533 if mixed_content_warnings.is_empty() {
1534 return;
1535 }
1536
1537 if add_blank_line {
1538 lines.push(String::new()); }
1540
1541 lines.push(format!(
1542 "{indent}Endpoints with partial content type support:"
1543 ));
1544 for warning in mixed_content_warnings {
1545 lines.push(format!(
1546 "{} - {} {} supports JSON but not: {}",
1547 indent,
1548 warning.endpoint.method,
1549 warning.endpoint.path,
1550 warning.endpoint.content_type
1551 ));
1552 }
1553 }
1554}
1555
1556pub fn get_config_dir() -> Result<PathBuf, Error> {
1562 let home_dir = dirs::home_dir().ok_or_else(Error::home_directory_not_found)?;
1563 let config_dir = home_dir.join(".config").join("aperture");
1564 Ok(config_dir)
1565}
1566
1567#[must_use]
1569pub fn is_url(input: &str) -> bool {
1570 input.starts_with("http://") || input.starts_with("https://")
1571}
1572
1573const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; #[allow(clippy::future_not_send)]
1585async fn fetch_spec_from_url(url: &str) -> Result<String, Error> {
1586 fetch_spec_from_url_with_timeout(url, std::time::Duration::from_secs(30)).await
1587}
1588
1589#[allow(clippy::future_not_send)]
1590async fn fetch_spec_from_url_with_timeout(
1591 url: &str,
1592 timeout: std::time::Duration,
1593) -> Result<String, Error> {
1594 let client = reqwest::Client::builder()
1596 .timeout(timeout)
1597 .build()
1598 .map_err(|e| Error::network_request_failed(format!("Failed to create HTTP client: {e}")))?;
1599
1600 let response = client.get(url).send().await.map_err(|e| {
1602 if e.is_timeout() {
1604 return Error::network_request_failed(format!(
1605 "Request timed out after {} seconds",
1606 timeout.as_secs()
1607 ));
1608 }
1609
1610 if e.is_connect() {
1611 return Error::network_request_failed(format!("Failed to connect to {url}: {e}"));
1612 }
1613
1614 Error::network_request_failed(format!("Network error: {e}"))
1615 })?;
1616
1617 if !response.status().is_success() {
1619 return Err(Error::request_failed(
1620 response.status(),
1621 format!("HTTP {} from {url}", response.status()),
1622 ));
1623 }
1624
1625 let Some(content_length) = response.content_length() else {
1627 return download_and_validate_response(response).await;
1629 };
1630
1631 if content_length > MAX_RESPONSE_SIZE {
1632 return Err(Error::network_request_failed(format!(
1633 "Response too large: {content_length} bytes (max {MAX_RESPONSE_SIZE} bytes)"
1634 )));
1635 }
1636
1637 download_and_validate_response(response).await
1638}
1639
1640#[allow(clippy::future_not_send)]
1642async fn download_and_validate_response(response: reqwest::Response) -> Result<String, Error> {
1643 let bytes = response
1645 .bytes()
1646 .await
1647 .map_err(|e| Error::network_request_failed(format!("Failed to read response body: {e}")))?;
1648
1649 if bytes.len() > usize::try_from(MAX_RESPONSE_SIZE).unwrap_or(usize::MAX) {
1651 return Err(Error::network_request_failed(format!(
1652 "Response too large: {} bytes (max {MAX_RESPONSE_SIZE} bytes)",
1653 bytes.len()
1654 )));
1655 }
1656
1657 String::from_utf8(bytes.to_vec())
1659 .map_err(|e| Error::network_request_failed(format!("Invalid UTF-8 in response: {e}")))
1660}