1use crate::cache::fingerprint::{compute_content_hash, get_file_mtime_secs};
2use crate::cache::metadata::CacheMetadataManager;
3use crate::config::context_name::ApiContextName;
4use crate::config::models::{ApertureSecret, ApiConfig, GlobalConfig, SecretSource};
5use crate::config::url_resolver::BaseUrlResolver;
6use crate::constants;
7use crate::engine::loader;
8use crate::error::Error;
9use crate::fs::{FileSystem, OsFileSystem};
10use crate::interactive::{confirm, prompt_for_input, select_from_options};
11use crate::spec::{SpecTransformer, SpecValidator};
12use openapiv3::{OpenAPI, ReferenceOr};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17struct CategorizedWarnings<'a> {
19 content_type: Vec<&'a crate::spec::validator::ValidationWarning>,
20 auth: Vec<&'a crate::spec::validator::ValidationWarning>,
21 mixed_content: Vec<&'a crate::spec::validator::ValidationWarning>,
22}
23
24pub struct ConfigManager<F: FileSystem> {
25 fs: F,
26 config_dir: PathBuf,
27}
28
29impl ConfigManager<OsFileSystem> {
30 pub fn new() -> Result<Self, Error> {
36 let config_dir = get_config_dir()?;
37 Ok(Self {
38 fs: OsFileSystem,
39 config_dir,
40 })
41 }
42}
43
44impl<F: FileSystem> ConfigManager<F> {
45 pub const fn with_fs(fs: F, config_dir: PathBuf) -> Self {
46 Self { fs, config_dir }
47 }
48
49 pub fn config_dir(&self) -> &Path {
51 &self.config_dir
52 }
53
54 #[must_use]
56 pub fn skipped_endpoints_to_warnings(
57 skipped_endpoints: &[crate::cache::models::SkippedEndpoint],
58 ) -> Vec<crate::spec::validator::ValidationWarning> {
59 skipped_endpoints
60 .iter()
61 .map(|endpoint| crate::spec::validator::ValidationWarning {
62 endpoint: crate::spec::validator::UnsupportedEndpoint {
63 path: endpoint.path.clone(),
64 method: endpoint.method.clone(),
65 content_type: endpoint.content_type.clone(),
66 },
67 reason: endpoint.reason.clone(),
68 })
69 .collect()
70 }
71
72 fn save_strict_preference(&self, api_name: &str, strict: bool) -> Result<(), Error> {
74 let mut config = self.load_global_config()?;
75 let api_config = config
76 .api_configs
77 .entry(api_name.to_string())
78 .or_insert_with(|| ApiConfig {
79 base_url_override: None,
80 environment_urls: HashMap::new(),
81 strict_mode: false,
82 secrets: HashMap::new(),
83 command_mapping: None,
84 });
85 api_config.strict_mode = strict;
86 self.save_global_config(&config)?;
87 Ok(())
88 }
89
90 pub fn get_strict_preference(&self, api_name: &ApiContextName) -> Result<bool, Error> {
96 let api_name = api_name.as_str();
97 let config = self.load_global_config()?;
98 Ok(config
99 .api_configs
100 .get(api_name)
101 .is_some_and(|c| c.strict_mode))
102 }
103
104 fn count_total_operations(spec: &OpenAPI) -> usize {
106 spec.paths
107 .iter()
108 .filter_map(|(_, path_item)| match path_item {
109 ReferenceOr::Item(item) => Some(item),
110 ReferenceOr::Reference { .. } => None,
111 })
112 .map(|item| {
113 let mut count = 0;
114 if item.get.is_some() {
115 count += 1;
116 }
117 if item.post.is_some() {
118 count += 1;
119 }
120 if item.put.is_some() {
121 count += 1;
122 }
123 if item.delete.is_some() {
124 count += 1;
125 }
126 if item.patch.is_some() {
127 count += 1;
128 }
129 if item.head.is_some() {
130 count += 1;
131 }
132 if item.options.is_some() {
133 count += 1;
134 }
135 if item.trace.is_some() {
136 count += 1;
137 }
138 count
139 })
140 .sum()
141 }
142
143 #[must_use]
145 pub fn format_validation_warnings(
146 warnings: &[crate::spec::validator::ValidationWarning],
147 total_operations: Option<usize>,
148 indent: &str,
149 ) -> Vec<String> {
150 let mut lines = Vec::new();
151
152 if !warnings.is_empty() {
153 let categorized_warnings = Self::categorize_warnings(warnings);
154 let total_skipped =
155 categorized_warnings.content_type.len() + categorized_warnings.auth.len();
156
157 Self::format_content_type_warnings(
158 &mut lines,
159 &categorized_warnings.content_type,
160 total_operations,
161 total_skipped,
162 indent,
163 );
164 Self::format_auth_warnings(
165 &mut lines,
166 &categorized_warnings.auth,
167 total_operations,
168 total_skipped,
169 indent,
170 !categorized_warnings.content_type.is_empty(),
171 );
172 Self::format_mixed_content_warnings(
173 &mut lines,
174 &categorized_warnings.mixed_content,
175 indent,
176 !categorized_warnings.content_type.is_empty()
177 || !categorized_warnings.auth.is_empty(),
178 );
179 }
180
181 lines
182 }
183
184 pub fn display_validation_warnings(
186 warnings: &[crate::spec::validator::ValidationWarning],
187 total_operations: Option<usize>,
188 ) {
189 if !warnings.is_empty() {
190 let lines = Self::format_validation_warnings(warnings, total_operations, "");
191 for line in lines {
192 match line.as_str() {
194 "" => {
195 eprintln!();
197 }
198 s if s.starts_with("Skipping") || s.starts_with("Endpoints") => {
199 eprintln!("{} {line}", crate::constants::MSG_WARNING_PREFIX);
201 }
202 _ => {
203 eprintln!("{line}");
205 }
206 }
207 }
208 eprintln!("\nUse --strict to reject specs with unsupported features.");
210 }
211 }
212
213 pub fn add_spec(
227 &self,
228 name: &ApiContextName,
229 file_path: &Path,
230 force: bool,
231 strict: bool,
232 ) -> Result<(), Error> {
233 self.check_spec_exists(name.as_str(), force)?;
234
235 let content = self.fs.read_to_string(file_path)?;
236 let openapi_spec = crate::spec::parse_openapi(&content)?;
237
238 let validator = SpecValidator::new();
240 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
241
242 if !validation_result.is_valid() {
244 return validation_result.into_result();
245 }
246
247 let total_operations = Self::count_total_operations(&openapi_spec);
249
250 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
252
253 self.add_spec_from_validated_openapi(
254 name.as_str(),
255 &openapi_spec,
256 &content,
257 &validation_result,
258 strict,
259 )
260 }
261
262 #[allow(clippy::future_not_send)]
278 pub async fn add_spec_from_url(
279 &self,
280 name: &ApiContextName,
281 url: &str,
282 force: bool,
283 strict: bool,
284 ) -> Result<(), Error> {
285 self.check_spec_exists(name.as_str(), force)?;
286
287 let content = fetch_spec_from_url(url).await?;
289 let openapi_spec = crate::spec::parse_openapi(&content)?;
290
291 let validator = SpecValidator::new();
293 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
294
295 if !validation_result.is_valid() {
297 return validation_result.into_result();
298 }
299
300 let total_operations = Self::count_total_operations(&openapi_spec);
302
303 Self::display_validation_warnings(&validation_result.warnings, Some(total_operations));
305
306 self.add_spec_from_validated_openapi(
307 name.as_str(),
308 &openapi_spec,
309 &content,
310 &validation_result,
311 strict,
312 )
313 }
314
315 #[allow(clippy::future_not_send)]
331 pub async fn add_spec_auto(
332 &self,
333 name: &ApiContextName,
334 file_or_url: &str,
335 force: bool,
336 strict: bool,
337 ) -> Result<(), Error> {
338 if is_url(file_or_url) {
339 self.add_spec_from_url(name, file_or_url, force, strict)
340 .await
341 } else {
342 let path = std::path::Path::new(file_or_url);
344 self.add_spec(name, path, force, strict)
345 }
346 }
347
348 pub fn list_specs(&self) -> Result<Vec<String>, Error> {
354 let specs_dir = self.config_dir.join(crate::constants::DIR_SPECS);
355 if !self.fs.exists(&specs_dir) {
356 return Ok(Vec::new());
357 }
358
359 let mut specs = Vec::new();
360 for entry in self.fs.read_dir(&specs_dir)? {
361 if !self.fs.is_file(&entry) {
363 continue;
364 }
365
366 let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) else {
368 continue;
369 };
370
371 if std::path::Path::new(file_name)
373 .extension()
374 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
375 {
376 specs.push(
377 file_name
378 .trim_end_matches(crate::constants::FILE_EXT_YAML)
379 .to_string(),
380 );
381 }
382 }
383 Ok(specs)
384 }
385
386 pub fn remove_spec(&self, name: &ApiContextName) -> Result<(), Error> {
392 let name = name.as_str();
393 let spec_path = self
394 .config_dir
395 .join(crate::constants::DIR_SPECS)
396 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
397 let cache_path = self
398 .config_dir
399 .join(crate::constants::DIR_CACHE)
400 .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
401
402 if !self.fs.exists(&spec_path) {
403 return Err(Error::spec_not_found(name));
404 }
405
406 self.fs.remove_file(&spec_path)?;
407 if self.fs.exists(&cache_path) {
408 self.fs.remove_file(&cache_path)?;
409 }
410
411 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
413 let metadata_manager = CacheMetadataManager::new(&self.fs);
414 let _ = metadata_manager.remove_spec_metadata(&cache_dir, name);
416
417 Ok(())
418 }
419
420 pub fn edit_spec(&self, name: &ApiContextName) -> Result<(), Error> {
429 let name = name.as_str();
430 let spec_path = self
431 .config_dir
432 .join(crate::constants::DIR_SPECS)
433 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
434
435 if !self.fs.exists(&spec_path) {
436 return Err(Error::spec_not_found(name));
437 }
438
439 let editor = std::env::var("EDITOR").map_err(|_| Error::editor_not_set())?;
440
441 let mut parts = editor.split_whitespace();
443 let program = parts.next().ok_or_else(Error::editor_not_set)?;
444 let args: Vec<&str> = parts.collect();
445
446 Command::new(program)
447 .args(&args)
448 .arg(&spec_path)
449 .status()
450 .map_err(|e| Error::io_error(format!("Failed to get editor process status: {e}")))?
451 .success()
452 .then_some(()) .ok_or_else(|| Error::editor_failed(name))
454 }
455
456 pub fn load_global_config(&self) -> Result<GlobalConfig, Error> {
462 let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
463 if self.fs.exists(&config_path) {
464 let content = self.fs.read_to_string(&config_path)?;
465 toml::from_str(&content).map_err(|e| Error::invalid_config(e.to_string()))
466 } else {
467 Ok(GlobalConfig::default())
468 }
469 }
470
471 pub fn save_global_config(&self, config: &GlobalConfig) -> Result<(), Error> {
477 let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
478
479 self.fs.create_dir_all(&self.config_dir)?;
481
482 let content = toml::to_string_pretty(config)
483 .map_err(|e| Error::serialization_error(format!("Failed to serialize config: {e}")))?;
484
485 self.fs.atomic_write(&config_path, content.as_bytes())?;
486 Ok(())
487 }
488
489 pub fn set_setting(
503 &self,
504 key: &crate::config::settings::SettingKey,
505 value: &crate::config::settings::SettingValue,
506 ) -> Result<(), Error> {
507 use crate::config::settings::{SettingKey, SettingValue};
508 use toml_edit::DocumentMut;
509
510 let config_path = self.config_dir.join(crate::constants::CONFIG_FILENAME);
511
512 let content = if self.fs.exists(&config_path) {
514 self.fs.read_to_string(&config_path)?
515 } else {
516 String::new()
517 };
518
519 let mut doc: DocumentMut = content
520 .parse()
521 .map_err(|e| Error::invalid_config(format!("Failed to parse config: {e}")))?;
522
523 match (key, value) {
527 (SettingKey::DefaultTimeoutSecs, SettingValue::U64(v)) => {
528 doc["default_timeout_secs"] =
529 toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
530 }
531 (SettingKey::AgentDefaultsJsonErrors, SettingValue::Bool(v)) => {
532 if doc.get("agent_defaults").is_none() {
534 doc["agent_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
535 }
536 doc["agent_defaults"]["json_errors"] = toml_edit::value(*v);
537 }
538 (SettingKey::RetryDefaultsMaxAttempts, SettingValue::U64(v)) => {
539 if doc.get("retry_defaults").is_none() {
541 doc["retry_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
542 }
543 doc["retry_defaults"]["max_attempts"] =
544 toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
545 }
546 (SettingKey::RetryDefaultsInitialDelayMs, SettingValue::U64(v)) => {
547 if doc.get("retry_defaults").is_none() {
549 doc["retry_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
550 }
551 doc["retry_defaults"]["initial_delay_ms"] =
552 toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
553 }
554 (SettingKey::RetryDefaultsMaxDelayMs, SettingValue::U64(v)) => {
555 if doc.get("retry_defaults").is_none() {
557 doc["retry_defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
558 }
559 doc["retry_defaults"]["max_delay_ms"] =
560 toml_edit::value(i64::try_from(*v).unwrap_or(i64::MAX));
561 }
562 (
564 SettingKey::DefaultTimeoutSecs
565 | SettingKey::RetryDefaultsMaxAttempts
566 | SettingKey::RetryDefaultsInitialDelayMs
567 | SettingKey::RetryDefaultsMaxDelayMs,
568 _,
569 ) => {
570 debug_assert!(false, "Integer settings require U64 value");
571 }
572 (SettingKey::AgentDefaultsJsonErrors, _) => {
573 debug_assert!(false, "AgentDefaultsJsonErrors requires Bool value");
574 }
575 }
576
577 self.fs.create_dir_all(&self.config_dir)?;
579
580 self.fs
582 .atomic_write(&config_path, doc.to_string().as_bytes())?;
583 Ok(())
584 }
585
586 pub fn get_setting(
595 &self,
596 key: &crate::config::settings::SettingKey,
597 ) -> Result<crate::config::settings::SettingValue, Error> {
598 let config = self.load_global_config()?;
599 Ok(key.value_from_config(&config))
600 }
601
602 pub fn list_settings(&self) -> Result<Vec<crate::config::settings::SettingInfo>, Error> {
608 use crate::config::settings::{SettingInfo, SettingKey};
609
610 let config = self.load_global_config()?;
611 let settings = SettingKey::ALL
612 .iter()
613 .map(|key| SettingInfo::new(*key, &key.value_from_config(&config)))
614 .collect();
615
616 Ok(settings)
617 }
618
619 pub fn set_url(
630 &self,
631 api_name: &ApiContextName,
632 url: &str,
633 environment: Option<&str>,
634 ) -> Result<(), Error> {
635 let api_name = api_name.as_str();
636 let spec_path = self
638 .config_dir
639 .join(crate::constants::DIR_SPECS)
640 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
641 if !self.fs.exists(&spec_path) {
642 return Err(Error::spec_not_found(api_name));
643 }
644
645 let mut config = self.load_global_config()?;
647
648 let api_config = config
650 .api_configs
651 .entry(api_name.to_string())
652 .or_insert_with(|| ApiConfig {
653 base_url_override: None,
654 environment_urls: HashMap::new(),
655 strict_mode: false,
656 secrets: HashMap::new(),
657 command_mapping: None,
658 });
659
660 if let Some(env) = environment {
662 api_config
663 .environment_urls
664 .insert(env.to_string(), url.to_string());
665 } else {
666 api_config.base_url_override = Some(url.to_string());
667 }
668
669 self.save_global_config(&config)?;
671 Ok(())
672 }
673
674 #[allow(clippy::type_complexity)]
686 pub fn get_url(
687 &self,
688 api_name: &ApiContextName,
689 ) -> Result<(Option<String>, HashMap<String, String>, String), Error> {
690 let api_name = api_name.as_str();
691 let spec_path = self
693 .config_dir
694 .join(crate::constants::DIR_SPECS)
695 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
696 if !self.fs.exists(&spec_path) {
697 return Err(Error::spec_not_found(api_name));
698 }
699
700 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
702 let cached_spec = loader::load_cached_spec(&cache_dir, api_name).ok();
703
704 let config = self.load_global_config()?;
706
707 let api_config = config.api_configs.get(api_name);
709
710 let base_url_override = api_config.and_then(|c| c.base_url_override.clone());
711 let environment_urls = api_config
712 .map(|c| c.environment_urls.clone())
713 .unwrap_or_default();
714
715 let resolved_url = cached_spec.map_or_else(
717 || "https://api.example.com".to_string(),
718 |spec| {
719 let resolver = BaseUrlResolver::new(&spec);
720 let resolver = if api_config.is_some() {
721 resolver.with_global_config(&config)
722 } else {
723 resolver
724 };
725 resolver.resolve(None)
726 },
727 );
728
729 Ok((base_url_override, environment_urls, resolved_url))
730 }
731
732 #[allow(clippy::type_complexity)]
741 pub fn list_urls(
742 &self,
743 ) -> Result<HashMap<String, (Option<String>, HashMap<String, String>)>, Error> {
744 let config = self.load_global_config()?;
745
746 let mut result = HashMap::new();
747 for (api_name, api_config) in config.api_configs {
748 result.insert(
749 api_name,
750 (api_config.base_url_override, api_config.environment_urls),
751 );
752 }
753
754 Ok(result)
755 }
756
757 #[doc(hidden)]
759 #[allow(clippy::future_not_send)]
760 pub async fn add_spec_from_url_with_timeout(
761 &self,
762 name: &ApiContextName,
763 url: &str,
764 force: bool,
765 timeout: std::time::Duration,
766 ) -> Result<(), Error> {
767 self.add_spec_from_url_with_timeout_and_mode(name.as_str(), url, force, timeout, false)
769 .await
770 }
771
772 #[doc(hidden)]
774 #[allow(clippy::future_not_send)]
775 async fn add_spec_from_url_with_timeout_and_mode(
776 &self,
777 name: &str,
778 url: &str,
779 force: bool,
780 timeout: std::time::Duration,
781 strict: bool,
782 ) -> Result<(), Error> {
783 self.check_spec_exists(name, force)?;
784
785 let content = fetch_spec_from_url_with_timeout(url, timeout).await?;
787 let openapi_spec = crate::spec::parse_openapi(&content)?;
788
789 let validator = SpecValidator::new();
791 let validation_result = validator.validate_with_mode(&openapi_spec, strict);
792
793 if !validation_result.is_valid() {
795 return validation_result.into_result();
796 }
797
798 self.add_spec_from_validated_openapi(
801 name,
802 &openapi_spec,
803 &content,
804 &validation_result,
805 strict,
806 )
807 }
808
809 pub fn set_secret(
820 &self,
821 api_name: &ApiContextName,
822 scheme_name: &str,
823 env_var_name: &str,
824 ) -> Result<(), Error> {
825 let api_name = api_name.as_str();
826 let spec_path = self
828 .config_dir
829 .join(crate::constants::DIR_SPECS)
830 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
831 if !self.fs.exists(&spec_path) {
832 return Err(Error::spec_not_found(api_name));
833 }
834
835 let mut config = self.load_global_config()?;
837
838 let api_config = config
840 .api_configs
841 .entry(api_name.to_string())
842 .or_insert_with(|| ApiConfig {
843 base_url_override: None,
844 environment_urls: HashMap::new(),
845 strict_mode: false,
846 secrets: HashMap::new(),
847 command_mapping: None,
848 });
849
850 api_config.secrets.insert(
852 scheme_name.to_string(),
853 ApertureSecret {
854 source: SecretSource::Env,
855 name: env_var_name.to_string(),
856 },
857 );
858
859 self.save_global_config(&config)?;
861 Ok(())
862 }
863
864 pub fn list_secrets(
876 &self,
877 api_name: &ApiContextName,
878 ) -> Result<HashMap<String, ApertureSecret>, Error> {
879 let api_name = api_name.as_str();
880 let spec_path = self
882 .config_dir
883 .join(crate::constants::DIR_SPECS)
884 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
885 if !self.fs.exists(&spec_path) {
886 return Err(Error::spec_not_found(api_name));
887 }
888
889 let config = self.load_global_config()?;
891
892 let secrets = config
894 .api_configs
895 .get(api_name)
896 .map(|c| c.secrets.clone())
897 .unwrap_or_default();
898
899 Ok(secrets)
900 }
901
902 pub fn get_secret(
915 &self,
916 api_name: &ApiContextName,
917 scheme_name: &str,
918 ) -> Result<Option<ApertureSecret>, Error> {
919 let secrets = self.list_secrets(api_name)?;
920 Ok(secrets.get(scheme_name).cloned())
921 }
922
923 pub fn remove_secret(&self, api_name: &ApiContextName, scheme_name: &str) -> Result<(), Error> {
932 let api_name = api_name.as_str();
933 let spec_path = self
935 .config_dir
936 .join(crate::constants::DIR_SPECS)
937 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
938 if !self.fs.exists(&spec_path) {
939 return Err(Error::spec_not_found(api_name));
940 }
941
942 let mut config = self.load_global_config()?;
944
945 let Some(api_config) = config.api_configs.get_mut(api_name) else {
947 return Err(Error::invalid_config(format!(
948 "No secrets configured for API '{api_name}'"
949 )));
950 };
951
952 if api_config.secrets.is_empty() {
954 return Err(Error::invalid_config(format!(
955 "No secrets configured for API '{api_name}'"
956 )));
957 }
958
959 if !api_config.secrets.contains_key(scheme_name) {
961 return Err(Error::invalid_config(format!(
962 "Secret for scheme '{scheme_name}' is not configured for API '{api_name}'"
963 )));
964 }
965
966 api_config.secrets.remove(scheme_name);
968
969 if api_config.is_empty() {
971 config.api_configs.remove(api_name);
972 }
973
974 self.save_global_config(&config)?;
976
977 Ok(())
978 }
979
980 pub fn clear_secrets(&self, api_name: &ApiContextName) -> Result<(), Error> {
988 let api_name = api_name.as_str();
989 let spec_path = self
991 .config_dir
992 .join(crate::constants::DIR_SPECS)
993 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
994 if !self.fs.exists(&spec_path) {
995 return Err(Error::spec_not_found(api_name));
996 }
997
998 let mut config = self.load_global_config()?;
1000
1001 let Some(api_config) = config.api_configs.get_mut(api_name) else {
1003 return Ok(());
1005 };
1006
1007 api_config.secrets.clear();
1009
1010 if api_config.is_empty() {
1012 config.api_configs.remove(api_name);
1013 }
1014
1015 self.save_global_config(&config)?;
1017
1018 Ok(())
1019 }
1020
1021 fn ensure_spec_exists(&self, api_name: &str) -> Result<(), Error> {
1029 let spec_path = self
1030 .config_dir
1031 .join(crate::constants::DIR_SPECS)
1032 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
1033
1034 if !self.fs.exists(&spec_path) {
1035 return Err(Error::spec_not_found(api_name));
1036 }
1037
1038 Ok(())
1039 }
1040
1041 fn ensure_command_mapping(
1044 &self,
1045 api_name: &str,
1046 ) -> Result<(crate::config::models::GlobalConfig, String), Error> {
1047 self.ensure_spec_exists(api_name)?;
1048
1049 let mut config = self.load_global_config()?;
1050 let api_config = config
1051 .api_configs
1052 .entry(api_name.to_string())
1053 .or_insert_with(|| ApiConfig {
1054 base_url_override: None,
1055 environment_urls: HashMap::new(),
1056 strict_mode: false,
1057 secrets: HashMap::new(),
1058 command_mapping: None,
1059 });
1060 if api_config.command_mapping.is_none() {
1061 api_config.command_mapping = Some(crate::config::models::CommandMapping::default());
1062 }
1063 Ok((config, api_name.to_string()))
1064 }
1065
1066 pub fn set_group_mapping(
1076 &self,
1077 api_name: &ApiContextName,
1078 original_tag: &str,
1079 new_name: &str,
1080 ) -> Result<(), Error> {
1081 if new_name.trim().is_empty() {
1082 return Err(Error::invalid_config("Group name cannot be empty"));
1083 }
1084 let (mut config, key) = self.ensure_command_mapping(api_name.as_str())?;
1085 let mapping = config
1087 .api_configs
1088 .get_mut(&key)
1089 .expect("ensure_command_mapping guarantees api_config exists")
1090 .command_mapping
1091 .as_mut()
1092 .expect("ensure_command_mapping guarantees command_mapping exists");
1093 mapping
1094 .groups
1095 .insert(original_tag.to_string(), new_name.to_string());
1096 self.save_global_config(&config)
1097 }
1098
1099 pub fn remove_group_mapping(
1105 &self,
1106 api_name: &ApiContextName,
1107 original_tag: &str,
1108 ) -> Result<(), Error> {
1109 self.ensure_spec_exists(api_name.as_str())?;
1110
1111 let mut config = self.load_global_config()?;
1112 let Some(api_config) = config.api_configs.get_mut(api_name.as_str()) else {
1113 return Ok(());
1114 };
1115 let Some(ref mut mapping) = api_config.command_mapping else {
1116 return Ok(());
1117 };
1118 mapping.groups.remove(original_tag);
1119 self.save_global_config(&config)
1120 }
1121
1122 pub fn set_operation_mapping(
1132 &self,
1133 api_name: &ApiContextName,
1134 operation_id: &str,
1135 name: Option<&str>,
1136 group: Option<&str>,
1137 alias: Option<&str>,
1138 hidden: Option<bool>,
1139 ) -> Result<(), Error> {
1140 if name.is_some_and(|n| n.trim().is_empty()) {
1141 return Err(Error::invalid_config("Operation name cannot be empty"));
1142 }
1143 if group.is_some_and(|g| g.trim().is_empty()) {
1144 return Err(Error::invalid_config("Operation group cannot be empty"));
1145 }
1146 if alias.is_some_and(|a| a.trim().is_empty()) {
1147 return Err(Error::invalid_config("Alias cannot be empty"));
1148 }
1149
1150 if name.is_none() && group.is_none() && alias.is_none() && hidden.is_none() {
1152 self.ensure_spec_exists(api_name.as_str())?;
1153 return Ok(());
1154 }
1155
1156 let (mut config, key) = self.ensure_command_mapping(api_name.as_str())?;
1157 let mapping = config
1159 .api_configs
1160 .get_mut(&key)
1161 .expect("ensure_command_mapping guarantees api_config exists")
1162 .command_mapping
1163 .as_mut()
1164 .expect("ensure_command_mapping guarantees command_mapping exists");
1165 let op = mapping
1166 .operations
1167 .entry(operation_id.to_string())
1168 .or_default();
1169
1170 if let Some(n) = name {
1171 op.name = Some(n.to_string());
1172 }
1173 if let Some(g) = group {
1174 op.group = Some(g.to_string());
1175 }
1176 let alias_str = alias.map(str::to_string);
1178 if alias_str.as_ref().is_some_and(|a| !op.aliases.contains(a)) {
1179 op.aliases
1180 .push(alias_str.expect("checked is_some_and above"));
1181 }
1182 if let Some(h) = hidden {
1183 op.hidden = h;
1184 }
1185
1186 self.save_global_config(&config)
1187 }
1188
1189 pub fn remove_operation_mapping(
1195 &self,
1196 api_name: &ApiContextName,
1197 operation_id: &str,
1198 ) -> Result<(), Error> {
1199 self.ensure_spec_exists(api_name.as_str())?;
1200
1201 let mut config = self.load_global_config()?;
1202 let Some(api_config) = config.api_configs.get_mut(api_name.as_str()) else {
1203 return Ok(());
1204 };
1205 let Some(ref mut mapping) = api_config.command_mapping else {
1206 return Ok(());
1207 };
1208 mapping.operations.remove(operation_id);
1209 self.save_global_config(&config)
1210 }
1211
1212 pub fn remove_alias(
1220 &self,
1221 api_name: &ApiContextName,
1222 operation_id: &str,
1223 alias: &str,
1224 ) -> Result<(), Error> {
1225 self.ensure_spec_exists(api_name.as_str())?;
1226
1227 let mut config = self.load_global_config()?;
1228 let Some(api_config) = config.api_configs.get_mut(api_name.as_str()) else {
1229 return Ok(());
1230 };
1231 let Some(ref mut mapping) = api_config.command_mapping else {
1232 return Ok(());
1233 };
1234 let Some(op) = mapping.operations.get_mut(operation_id) else {
1235 return Ok(());
1236 };
1237 op.aliases.retain(|a| a != alias);
1238 self.save_global_config(&config)
1239 }
1240
1241 pub fn get_command_mapping(
1247 &self,
1248 api_name: &ApiContextName,
1249 ) -> Result<Option<crate::config::models::CommandMapping>, Error> {
1250 let config = self.load_global_config()?;
1251 Ok(config
1252 .api_configs
1253 .get(api_name.as_str())
1254 .and_then(|c| c.command_mapping.clone()))
1255 }
1256
1257 pub fn set_secret_interactive(&self, api_name: &ApiContextName) -> Result<(), Error> {
1278 let (cached_spec, current_secrets) = self.load_spec_for_interactive_config(api_name)?;
1280
1281 if cached_spec.security_schemes.is_empty() {
1282 println!("No security schemes found in API '{api_name}'.");
1284 return Ok(());
1285 }
1286
1287 Self::display_interactive_header(api_name.as_str(), &cached_spec);
1288
1289 let options = Self::build_security_scheme_options(&cached_spec, ¤t_secrets);
1291
1292 self.run_interactive_configuration_loop(
1294 api_name.as_str(),
1295 &cached_spec,
1296 ¤t_secrets,
1297 &options,
1298 api_name,
1299 )?;
1300
1301 println!("\nInteractive configuration complete!");
1303 Ok(())
1304 }
1305
1306 fn check_spec_exists(&self, name: &str, force: bool) -> Result<(), Error> {
1312 let spec_path = self
1313 .config_dir
1314 .join(crate::constants::DIR_SPECS)
1315 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
1316
1317 if self.fs.exists(&spec_path) && !force {
1318 return Err(Error::spec_already_exists(name));
1319 }
1320
1321 Ok(())
1322 }
1323
1324 fn transform_spec_to_cached(
1330 name: &str,
1331 openapi_spec: &OpenAPI,
1332 validation_result: &crate::spec::validator::ValidationResult,
1333 ) -> Result<crate::cache::models::CachedSpec, Error> {
1334 let transformer = SpecTransformer::new();
1335
1336 let skip_endpoints: Vec<(String, String)> = validation_result
1338 .warnings
1339 .iter()
1340 .filter_map(super::super::spec::validator::ValidationWarning::to_skip_endpoint)
1341 .collect();
1342
1343 transformer.transform_with_warnings(
1344 name,
1345 openapi_spec,
1346 &skip_endpoints,
1347 &validation_result.warnings,
1348 )
1349 }
1350
1351 fn create_spec_directories(&self, name: &str) -> Result<(PathBuf, PathBuf), Error> {
1357 let spec_path = self
1358 .config_dir
1359 .join(crate::constants::DIR_SPECS)
1360 .join(format!("{name}{}", crate::constants::FILE_EXT_YAML));
1361 let cache_path = self
1362 .config_dir
1363 .join(crate::constants::DIR_CACHE)
1364 .join(format!("{name}{}", crate::constants::FILE_EXT_BIN));
1365
1366 let spec_parent = spec_path.parent().ok_or_else(|| {
1367 Error::invalid_path(
1368 spec_path.display().to_string(),
1369 "Path has no parent directory",
1370 )
1371 })?;
1372 let cache_parent = cache_path.parent().ok_or_else(|| {
1373 Error::invalid_path(
1374 cache_path.display().to_string(),
1375 "Path has no parent directory",
1376 )
1377 })?;
1378
1379 self.fs.create_dir_all(spec_parent)?;
1380 self.fs.create_dir_all(cache_parent)?;
1381
1382 Ok((spec_path, cache_path))
1383 }
1384
1385 fn write_spec_files(
1391 &self,
1392 name: &str,
1393 content: &str,
1394 cached_spec: &crate::cache::models::CachedSpec,
1395 spec_path: &Path,
1396 cache_path: &Path,
1397 ) -> Result<(), Error> {
1398 self.fs.atomic_write(spec_path, content.as_bytes())?;
1400
1401 let cached_data = postcard::to_allocvec(cached_spec)
1403 .map_err(|e| Error::serialization_error(e.to_string()))?;
1404 self.fs.atomic_write(cache_path, &cached_data)?;
1405
1406 let content_hash = compute_content_hash(content.as_bytes());
1408 let spec_file_size = content.len() as u64;
1409 let mtime_secs = get_file_mtime_secs(spec_path);
1410
1411 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
1413 let metadata_manager = CacheMetadataManager::new(&self.fs);
1414 metadata_manager.update_spec_metadata_with_fingerprint(
1415 &cache_dir,
1416 name,
1417 cached_data.len() as u64,
1418 Some(content_hash),
1419 mtime_secs,
1420 Some(spec_file_size),
1421 )?;
1422
1423 Ok(())
1424 }
1425
1426 fn add_spec_from_validated_openapi(
1432 &self,
1433 name: &str,
1434 openapi_spec: &OpenAPI,
1435 content: &str,
1436 validation_result: &crate::spec::validator::ValidationResult,
1437 strict: bool,
1438 ) -> Result<(), Error> {
1439 let mut cached_spec =
1441 Self::transform_spec_to_cached(name, openapi_spec, validation_result)?;
1442
1443 self.apply_command_mapping_if_configured(name, &mut cached_spec)?;
1445
1446 let (spec_path, cache_path) = self.create_spec_directories(name)?;
1448
1449 self.write_spec_files(name, content, &cached_spec, &spec_path, &cache_path)?;
1451
1452 self.save_strict_preference(name, strict)?;
1454
1455 Ok(())
1456 }
1457
1458 fn apply_command_mapping_if_configured(
1463 &self,
1464 name: &str,
1465 cached_spec: &mut crate::cache::models::CachedSpec,
1466 ) -> Result<(), Error> {
1467 let config = self.load_global_config()?;
1468 let Some(api_config) = config.api_configs.get(name) else {
1469 return Ok(());
1470 };
1471 let Some(ref mapping) = api_config.command_mapping else {
1472 return Ok(());
1473 };
1474
1475 let result =
1476 crate::config::mapping::apply_command_mapping(&mut cached_spec.commands, mapping)?;
1477
1478 for warning in &result.warnings {
1479 eprintln!("{} {warning}", crate::constants::MSG_WARNING_PREFIX);
1481 }
1482
1483 Ok(())
1484 }
1485
1486 fn load_spec_for_interactive_config(
1492 &self,
1493 api_name: &ApiContextName,
1494 ) -> Result<
1495 (
1496 crate::cache::models::CachedSpec,
1497 std::collections::HashMap<String, ApertureSecret>,
1498 ),
1499 Error,
1500 > {
1501 let spec_path = self
1503 .config_dir
1504 .join(crate::constants::DIR_SPECS)
1505 .join(format!("{api_name}{}", crate::constants::FILE_EXT_YAML));
1506 if !self.fs.exists(&spec_path) {
1507 return Err(Error::spec_not_found(api_name.as_str()));
1508 }
1509
1510 let cache_dir = self.config_dir.join(crate::constants::DIR_CACHE);
1512 let cached_spec = loader::load_cached_spec(&cache_dir, api_name.as_str())?;
1513
1514 let current_secrets = self.list_secrets(api_name)?;
1516
1517 Ok((cached_spec, current_secrets))
1518 }
1519
1520 fn display_interactive_header(api_name: &str, cached_spec: &crate::cache::models::CachedSpec) {
1522 println!("Interactive Secret Configuration for API: {api_name}");
1524 println!(
1526 "Found {} security scheme(s):\n",
1527 cached_spec.security_schemes.len()
1528 );
1529 }
1530
1531 fn build_security_scheme_options(
1533 cached_spec: &crate::cache::models::CachedSpec,
1534 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1535 ) -> Vec<(String, String)> {
1536 cached_spec
1537 .security_schemes
1538 .values()
1539 .map(|scheme| {
1540 let mut description = format!("{} ({})", scheme.scheme_type, scheme.name);
1541
1542 match scheme.scheme_type.as_str() {
1544 constants::AUTH_SCHEME_APIKEY => {
1545 if let (Some(location), Some(param)) =
1546 (&scheme.location, &scheme.parameter_name)
1547 {
1548 description = format!("{description} - {location} parameter: {param}");
1549 }
1550 }
1551 "http" => {
1552 if let Some(http_scheme) = &scheme.scheme {
1553 description = format!("{description} - {http_scheme} authentication");
1554 }
1555 }
1556 _ => {}
1557 }
1558
1559 description = match (
1561 current_secrets.contains_key(&scheme.name),
1562 &scheme.aperture_secret,
1563 ) {
1564 (true, _) => format!("{description} [CONFIGURED]"),
1565 (false, Some(_)) => format!("{description} [x-aperture-secret]"),
1566 (false, None) => format!("{description} [NOT CONFIGURED]"),
1567 };
1568
1569 if let Some(openapi_desc) = &scheme.description {
1571 description = format!("{description} - {openapi_desc}");
1572 }
1573
1574 (scheme.name.clone(), description)
1575 })
1576 .collect()
1577 }
1578
1579 fn run_interactive_configuration_loop(
1585 &self,
1586 api_name: &str,
1587 cached_spec: &crate::cache::models::CachedSpec,
1588 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1589 options: &[(String, String)],
1590 validated_name: &ApiContextName,
1591 ) -> Result<(), Error> {
1592 loop {
1593 let selected_scheme =
1594 select_from_options("\nSelect a security scheme to configure:", options)?;
1595
1596 let scheme = cached_spec.security_schemes.get(&selected_scheme).expect(
1597 "Selected scheme should exist in cached spec - menu validation ensures this",
1598 );
1599
1600 Self::display_scheme_configuration_details(&selected_scheme, scheme, current_secrets);
1601
1602 let env_var = prompt_for_input(&format!(
1604 "\nEnter environment variable name for '{selected_scheme}' (or press Enter to skip): "
1605 ))?;
1606
1607 if env_var.is_empty() {
1608 println!("Skipping configuration for '{selected_scheme}'");
1610 } else {
1611 self.handle_secret_configuration(
1612 api_name,
1613 &selected_scheme,
1614 &env_var,
1615 validated_name,
1616 )?;
1617 }
1618
1619 if !confirm("\nConfigure another security scheme?")? {
1621 break;
1622 }
1623 }
1624
1625 Ok(())
1626 }
1627
1628 fn display_scheme_configuration_details(
1630 selected_scheme: &str,
1631 scheme: &crate::cache::models::CachedSecurityScheme,
1632 current_secrets: &std::collections::HashMap<String, ApertureSecret>,
1633 ) {
1634 println!("\nConfiguration for '{selected_scheme}':");
1636 println!(" Type: {}", scheme.scheme_type);
1638 if let Some(desc) = &scheme.description {
1639 println!(" Description: {desc}");
1641 }
1642
1643 match (
1645 current_secrets.get(selected_scheme),
1646 &scheme.aperture_secret,
1647 ) {
1648 (Some(current_secret), _) => {
1649 println!(" Current: environment variable '{}'", current_secret.name);
1651 }
1652 (None, Some(aperture_secret)) => {
1653 println!(
1655 " Current: x-aperture-secret -> '{}'",
1656 aperture_secret.name
1657 );
1658 }
1659 (None, None) => {
1660 println!(" Current: not configured");
1662 }
1663 }
1664 }
1665
1666 fn handle_secret_configuration(
1672 &self,
1673 api_name: &str,
1674 selected_scheme: &str,
1675 env_var: &str,
1676 validated_name: &ApiContextName,
1677 ) -> Result<(), Error> {
1678 if let Err(e) = crate::interactive::validate_env_var_name(env_var) {
1680 println!("Invalid environment variable name: {e}");
1682 return Ok(()); }
1684
1685 println!("\nConfiguration Preview:");
1688 println!(" API: {api_name}");
1690 println!(" Scheme: {selected_scheme}");
1692 println!(" Environment Variable: {env_var}");
1694
1695 if confirm("Apply this configuration?")? {
1696 self.set_secret(validated_name, selected_scheme, env_var)?;
1697 println!("Configuration saved successfully!");
1699 } else {
1700 println!("Configuration cancelled.");
1702 }
1703
1704 Ok(())
1705 }
1706
1707 fn categorize_warnings(
1709 warnings: &[crate::spec::validator::ValidationWarning],
1710 ) -> CategorizedWarnings<'_> {
1711 let mut categorized = CategorizedWarnings {
1712 content_type: Vec::new(),
1713 auth: Vec::new(),
1714 mixed_content: Vec::new(),
1715 };
1716
1717 for warning in warnings {
1718 if warning.reason.contains("no supported content types") {
1720 categorized.content_type.push(warning);
1721 continue;
1722 }
1723
1724 if warning.reason.contains("unsupported authentication") {
1725 categorized.auth.push(warning);
1726 continue;
1727 }
1728
1729 if warning
1730 .reason
1731 .contains("unsupported content types alongside JSON")
1732 {
1733 categorized.mixed_content.push(warning);
1734 }
1735 }
1736
1737 categorized
1738 }
1739
1740 fn format_content_type_warnings(
1742 lines: &mut Vec<String>,
1743 content_type_warnings: &[&crate::spec::validator::ValidationWarning],
1744 total_operations: Option<usize>,
1745 total_skipped: usize,
1746 indent: &str,
1747 ) {
1748 if content_type_warnings.is_empty() {
1749 return;
1750 }
1751
1752 let warning_msg = total_operations.map_or_else(
1753 || {
1754 format!(
1755 "{}Skipping {} endpoints with unsupported content types:",
1756 indent,
1757 content_type_warnings.len()
1758 )
1759 },
1760 |total| {
1761 let available = total.saturating_sub(total_skipped);
1762 format!(
1763 "{}Skipping {} endpoints with unsupported content types ({} of {} endpoints will be available):",
1764 indent,
1765 content_type_warnings.len(),
1766 available,
1767 total
1768 )
1769 },
1770 );
1771 lines.push(warning_msg);
1772
1773 for warning in content_type_warnings {
1774 lines.push(format!(
1775 "{} - {} {} ({}) - {}",
1776 indent,
1777 warning.endpoint.method,
1778 warning.endpoint.path,
1779 warning.endpoint.content_type,
1780 warning.reason
1781 ));
1782 }
1783 }
1784
1785 fn format_auth_warnings(
1787 lines: &mut Vec<String>,
1788 auth_warnings: &[&crate::spec::validator::ValidationWarning],
1789 total_operations: Option<usize>,
1790 total_skipped: usize,
1791 indent: &str,
1792 add_blank_line: bool,
1793 ) {
1794 if auth_warnings.is_empty() {
1795 return;
1796 }
1797
1798 if add_blank_line {
1799 lines.push(String::new()); }
1801
1802 let warning_msg = total_operations.map_or_else(
1803 || {
1804 format!(
1805 "{}Skipping {} endpoints with unsupported authentication:",
1806 indent,
1807 auth_warnings.len()
1808 )
1809 },
1810 |total| {
1811 let available = total.saturating_sub(total_skipped);
1812 format!(
1813 "{}Skipping {} endpoints with unsupported authentication ({} of {} endpoints will be available):",
1814 indent,
1815 auth_warnings.len(),
1816 available,
1817 total
1818 )
1819 },
1820 );
1821 lines.push(warning_msg);
1822
1823 for warning in auth_warnings {
1824 lines.push(format!(
1825 "{} - {} {} - {}",
1826 indent, warning.endpoint.method, warning.endpoint.path, warning.reason
1827 ));
1828 }
1829 }
1830
1831 fn format_mixed_content_warnings(
1833 lines: &mut Vec<String>,
1834 mixed_content_warnings: &[&crate::spec::validator::ValidationWarning],
1835 indent: &str,
1836 add_blank_line: bool,
1837 ) {
1838 if mixed_content_warnings.is_empty() {
1839 return;
1840 }
1841
1842 if add_blank_line {
1843 lines.push(String::new()); }
1845
1846 lines.push(format!(
1847 "{indent}Endpoints with partial content type support:"
1848 ));
1849 for warning in mixed_content_warnings {
1850 lines.push(format!(
1851 "{} - {} {} supports JSON but not: {}",
1852 indent,
1853 warning.endpoint.method,
1854 warning.endpoint.path,
1855 warning.endpoint.content_type
1856 ));
1857 }
1858 }
1859}
1860
1861pub fn get_config_dir() -> Result<PathBuf, Error> {
1867 let home_dir = dirs::home_dir().ok_or_else(Error::home_directory_not_found)?;
1868 let config_dir = home_dir.join(".config").join("aperture");
1869 Ok(config_dir)
1870}
1871
1872#[must_use]
1874pub fn is_url(input: &str) -> bool {
1875 input.starts_with("http://") || input.starts_with("https://")
1876}
1877
1878const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024; #[allow(clippy::future_not_send)]
1890async fn fetch_spec_from_url(url: &str) -> Result<String, Error> {
1891 fetch_spec_from_url_with_timeout(url, std::time::Duration::from_secs(30)).await
1892}
1893
1894#[allow(clippy::future_not_send)]
1895async fn fetch_spec_from_url_with_timeout(
1896 url: &str,
1897 timeout: std::time::Duration,
1898) -> Result<String, Error> {
1899 let client = reqwest::Client::builder()
1901 .timeout(timeout)
1902 .build()
1903 .map_err(|e| Error::network_request_failed(format!("Failed to create HTTP client: {e}")))?;
1904
1905 let response = client.get(url).send().await.map_err(|e| {
1907 if e.is_timeout() {
1909 return Error::network_request_failed(format!(
1910 "Request timed out after {} seconds",
1911 timeout.as_secs()
1912 ));
1913 }
1914
1915 if e.is_connect() {
1916 return Error::network_request_failed(format!("Failed to connect to {url}: {e}"));
1917 }
1918
1919 Error::network_request_failed(format!("Network error: {e}"))
1920 })?;
1921
1922 if !response.status().is_success() {
1924 return Err(Error::request_failed(
1925 response.status(),
1926 format!("HTTP {} from {url}", response.status()),
1927 ));
1928 }
1929
1930 let Some(content_length) = response.content_length() else {
1932 return download_and_validate_response(response).await;
1934 };
1935
1936 if content_length > MAX_RESPONSE_SIZE {
1937 return Err(Error::network_request_failed(format!(
1938 "Response too large: {content_length} bytes (max {MAX_RESPONSE_SIZE} bytes)"
1939 )));
1940 }
1941
1942 download_and_validate_response(response).await
1943}
1944
1945#[allow(clippy::future_not_send)]
1947async fn download_and_validate_response(response: reqwest::Response) -> Result<String, Error> {
1948 let bytes = response
1950 .bytes()
1951 .await
1952 .map_err(|e| Error::network_request_failed(format!("Failed to read response body: {e}")))?;
1953
1954 if bytes.len() > usize::try_from(MAX_RESPONSE_SIZE).unwrap_or(usize::MAX) {
1956 return Err(Error::network_request_failed(format!(
1957 "Response too large: {} bytes (max {MAX_RESPONSE_SIZE} bytes)",
1958 bytes.len()
1959 )));
1960 }
1961
1962 String::from_utf8(bytes.to_vec())
1964 .map_err(|e| Error::network_request_failed(format!("Invalid UTF-8 in response: {e}")))
1965}