1#![allow(clippy::new_without_default, clippy::empty_line_after_outer_attr)]
7
8use crate::{
9 AppConfig, Config, ConfigResult, DatabaseConfig, FeaturesConfig, LogLevel, NetworkConfig,
10 ProvidersConfig,
11};
12
13#[derive(Default)]
33pub struct ConfigBuilder {
34 app: AppConfig,
35 database: DatabaseConfig,
36 network: NetworkConfig,
37 providers: ProvidersConfig,
38 features: FeaturesConfig,
39}
40
41impl ConfigBuilder {
42 pub fn new() -> Self {
47 Self::default()
48 }
49
50 #[must_use]
55 pub fn merge(mut self, other: Config) -> Self {
56 self.merge_app_config(other.app);
57 self.merge_database_config(other.database);
58 self.merge_network_config(other.network);
59 self.merge_providers_config(other.providers);
60 self.merge_features_config(other.features);
61 self
62 }
63
64 fn merge_app_config(&mut self, other: AppConfig) {
66 let default_app = AppConfig::default();
67
68 if other.port != default_app.port {
70 self.app.port = other.port;
71 }
72 if other.environment != default_app.environment {
73 self.app.environment = other.environment;
74 }
75 if other.log_level != default_app.log_level {
76 self.app.log_level = other.log_level;
77 }
78 if other.use_testnet != default_app.use_testnet {
79 self.app.use_testnet = other.use_testnet;
80 }
81 if other.transaction.max_gas_price_gwei != default_app.transaction.max_gas_price_gwei
83 || other.transaction.priority_fee_gwei != default_app.transaction.priority_fee_gwei
84 || other.transaction.slippage_tolerance_percent
85 != default_app.transaction.slippage_tolerance_percent
86 || other.transaction.deadline_seconds != default_app.transaction.deadline_seconds
87 {
88 self.app.transaction = other.transaction;
89 }
90 if other.retry.max_retry_attempts != default_app.retry.max_retry_attempts
91 || other.retry.retry_delay_ms != default_app.retry.retry_delay_ms
92 || other.retry.retry_backoff_multiplier != default_app.retry.retry_backoff_multiplier
93 || other.retry.max_retry_delay_ms != default_app.retry.max_retry_delay_ms
94 {
95 self.app.retry = other.retry;
96 }
97 }
98
99 fn merge_database_config(&mut self, other: DatabaseConfig) {
101 let default_database = DatabaseConfig::default();
102
103 if other.redis_url != default_database.redis_url {
104 self.database.redis_url = other.redis_url;
105 }
106 if other.neo4j_url != default_database.neo4j_url {
107 self.database.neo4j_url = other.neo4j_url;
108 }
109 }
110
111 fn merge_network_config(&mut self, other: NetworkConfig) {
113 let default_network = NetworkConfig::default();
114 if other.solana_rpc_url != default_network.solana_rpc_url {
115 self.network.solana_rpc_url = other.solana_rpc_url;
116 }
117 if other.solana_ws_url != default_network.solana_ws_url {
118 self.network.solana_ws_url = other.solana_ws_url;
119 }
120 if other.default_chain_id != default_network.default_chain_id {
121 self.network.default_chain_id = other.default_chain_id;
122 }
123 if !other.rpc_urls.is_empty() {
125 self.network.rpc_urls = other.rpc_urls;
126 }
127 if !other.chains.is_empty() {
128 self.network.chains = other.chains;
129 }
130 if other.timeouts.rpc_timeout_secs != default_network.timeouts.rpc_timeout_secs
132 || other.timeouts.ws_timeout_secs != default_network.timeouts.ws_timeout_secs
133 || other.timeouts.http_timeout_secs != default_network.timeouts.http_timeout_secs
134 {
135 self.network.timeouts = other.timeouts;
136 }
137 }
138
139 fn merge_providers_config(&mut self, other: ProvidersConfig) {
141 if other.anthropic_api_key.is_some() {
142 self.providers.anthropic_api_key = other.anthropic_api_key;
143 }
144 if other.openai_api_key.is_some() {
145 self.providers.openai_api_key = other.openai_api_key;
146 }
147 if other.alchemy_api_key.is_some() {
148 self.providers.alchemy_api_key = other.alchemy_api_key;
149 }
150 if other.lifi_api_key.is_some() {
151 self.providers.lifi_api_key = other.lifi_api_key;
152 }
153 if other.twitter_bearer_token.is_some() {
154 self.providers.twitter_bearer_token = other.twitter_bearer_token;
155 }
156 if other.dexscreener_api_key.is_some() {
157 self.providers.dexscreener_api_key = other.dexscreener_api_key;
158 }
159 }
160
161 fn merge_features_config(&mut self, other: FeaturesConfig) {
163 let default_features = FeaturesConfig::default();
164 if other.enable_trading != default_features.enable_trading {
165 self.features.enable_trading = other.enable_trading;
166 }
167 if other.enable_graph_memory != default_features.enable_graph_memory {
168 self.features.enable_graph_memory = other.enable_graph_memory;
169 }
170 if other.enable_bridging != default_features.enable_bridging {
171 self.features.enable_bridging = other.enable_bridging;
172 }
173 if other.enable_social_monitoring != default_features.enable_social_monitoring {
174 self.features.enable_social_monitoring = other.enable_social_monitoring;
175 }
176 if other.enable_streaming != default_features.enable_streaming {
177 self.features.enable_streaming = other.enable_streaming;
178 }
179 if other.enable_webhooks != default_features.enable_webhooks {
180 self.features.enable_webhooks = other.enable_webhooks;
181 }
182 if other.enable_analytics != default_features.enable_analytics {
183 self.features.enable_analytics = other.enable_analytics;
184 }
185 if other.debug_mode != default_features.debug_mode {
186 self.features.debug_mode = other.debug_mode;
187 }
188 if other.experimental != default_features.experimental {
189 self.features.experimental = other.experimental;
190 }
191 if !other.custom.is_empty() {
193 self.features.custom.extend(other.custom);
194 }
195 }
196
197 #[must_use]
201 pub fn app(mut self, config: AppConfig) -> Self {
202 self.app = config;
203 self
204 }
205
206 #[must_use]
208 pub fn database(mut self, config: DatabaseConfig) -> Self {
209 self.database = config;
210 self
211 }
212
213 #[must_use]
215 pub fn network(mut self, config: NetworkConfig) -> Self {
216 self.network = config;
217 self
218 }
219
220 #[must_use]
222 pub fn providers(mut self, config: ProvidersConfig) -> Self {
223 self.providers = config;
224 self
225 }
226
227 #[must_use]
229 pub fn features(mut self, config: FeaturesConfig) -> Self {
230 self.features = config;
231 self
232 }
233
234 #[must_use]
249 pub fn port(mut self, port: u16) -> Self {
250 self.app.port = port;
251 self
252 }
253
254 #[must_use]
267 pub fn log_level(mut self, level: LogLevel) -> Self {
268 self.app.log_level = level;
269 self
270 }
271
272 #[must_use]
285 pub fn use_testnet(mut self, use_testnet: bool) -> Self {
286 self.app.use_testnet = use_testnet;
287 self
288 }
289
290 #[must_use]
305 pub fn redis_url(mut self, url: String) -> Self {
306 self.database.redis_url = url;
307 self
308 }
309
310 #[must_use]
323 pub fn neo4j_url(mut self, url: Option<String>) -> Self {
324 self.database.neo4j_url = url;
325 self
326 }
327
328 #[must_use]
343 pub fn solana_rpc_url(mut self, url: String) -> Self {
344 self.network.solana_rpc_url = url;
345 self
346 }
347
348 #[must_use]
361 pub fn default_chain_id(mut self, id: u64) -> Self {
362 self.network.default_chain_id = id;
363 self
364 }
365
366 #[must_use]
381 pub fn anthropic_api_key(mut self, key: Option<String>) -> Self {
382 self.providers.anthropic_api_key = key;
383 self
384 }
385
386 #[must_use]
399 pub fn openai_api_key(mut self, key: Option<String>) -> Self {
400 self.providers.openai_api_key = key;
401 self
402 }
403
404 #[must_use]
426 pub fn development(mut self) -> Self {
427 use crate::{Environment, LogLevel};
428 self.app.environment = Environment::Development;
429 self.app.log_level = LogLevel::Debug;
430 self.app.use_testnet = true;
431 self
432 }
433
434 #[must_use]
454 pub fn staging(mut self) -> Self {
455 use crate::{Environment, LogLevel};
456 self.app.environment = Environment::Staging;
457 self.app.log_level = LogLevel::Info;
458 self.app.use_testnet = true;
459 self
460 }
461
462 #[must_use]
483 pub fn production(mut self) -> Self {
484 use crate::{Environment, LogLevel};
485 self.app.environment = Environment::Production;
486 self.app.log_level = LogLevel::Info;
487 self.app.use_testnet = false;
488 self
489 }
490
491 #[must_use]
510 pub fn testnet(mut self) -> Self {
511 self.app.use_testnet = true;
512 self.network.solana_rpc_url = "https://api.devnet.solana.com".to_string();
513 self.network.default_chain_id = 5; self
515 }
516
517 #[must_use]
536 pub fn mainnet(mut self) -> Self {
537 self.app.use_testnet = false;
538 self.network.solana_rpc_url = "https://api.mainnet-beta.solana.com".to_string();
539 self.network.default_chain_id = 1; self
541 }
542
543 #[must_use]
558 pub fn enable_trading(mut self, enabled: bool) -> Self {
559 self.features.enable_trading = enabled;
560 self
561 }
562
563 #[must_use]
577 pub fn enable_graph_memory(mut self, enabled: bool) -> Self {
578 self.features.enable_graph_memory = enabled;
579 self
580 }
581
582 pub fn from_file<P: AsRef<std::path::Path>>(mut self, path: P) -> ConfigResult<Self> {
601 let content = std::fs::read_to_string(path).map_err(|e| {
602 crate::ConfigError::validation(format!("Failed to read config file: {}", e))
603 })?;
604
605 let file_config: Config = toml::from_str(&content).map_err(|e| {
606 crate::ConfigError::validation(format!("Failed to parse TOML config: {}", e))
607 })?;
608
609 self.merge_app_config(file_config.app);
611 self.merge_database_config(file_config.database);
612 self.merge_network_config(file_config.network);
613 self.merge_providers_config(file_config.providers);
614 self.merge_features_config(file_config.features);
615
616 Ok(self)
617 }
618
619 pub fn from_env(mut self) -> ConfigResult<Self> {
636 dotenvy::dotenv().ok();
638
639 let env_config = envy::from_env::<Config>().map_err(|e| {
640 crate::ConfigError::EnvParse(format!("Failed to parse environment: {}", e))
641 })?;
642
643 self.merge_app_config(env_config.app);
645 self.merge_database_config(env_config.database);
646 self.merge_network_config(env_config.network);
647 self.merge_providers_config(env_config.providers);
648 self.merge_features_config(env_config.features);
649
650 Ok(self)
651 }
652
653 pub fn validate(&self) -> ConfigResult<()> {
671 let config = Config {
672 app: self.app.clone(),
673 database: self.database.clone(),
674 network: self.network.clone(),
675 providers: self.providers.clone(),
676 features: self.features.clone(),
677 };
678 config.validate_config()
679 }
680
681 pub fn build(self) -> ConfigResult<Config> {
685 let config = Config {
686 app: self.app,
687 database: self.database,
688 network: self.network,
689 providers: self.providers,
690 features: self.features,
691 };
692
693 config.validate_config()?;
694 Ok(config)
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use crate::Environment;
702
703 #[test]
704 fn test_builder_default() {
705 let config = ConfigBuilder::default().build().unwrap();
706 assert_eq!(config.app.environment, Environment::Development);
707 }
708
709 #[test]
710 fn test_builder_with_app_config() {
711 let config = ConfigBuilder::default()
713 .app(AppConfig {
714 environment: Environment::Development,
715 log_level: LogLevel::Warn,
716 ..Default::default()
717 })
718 .build()
719 .unwrap();
720
721 assert_eq!(config.app.environment, Environment::Development);
722 assert_eq!(config.app.log_level, LogLevel::Warn);
723 }
724
725 #[test]
726 fn test_builder_chaining() {
727 let config = ConfigBuilder::default().build().unwrap();
728
729 assert_eq!(config.app.environment, Environment::Development);
730 }
731
732 #[test]
733 fn test_builder_individual_setters() {
734 let config = ConfigBuilder::default()
735 .port(8080)
737 .log_level(LogLevel::Warn)
738 .use_testnet(true)
739 .redis_url("redis://localhost:6379".to_string())
741 .neo4j_url(Some("bolt://localhost:7687".to_string()))
742 .solana_rpc_url("https://api.mainnet-beta.solana.com".to_string())
744 .default_chain_id(137)
745 .anthropic_api_key(Some("test_anthropic_key".to_string()))
747 .openai_api_key(Some("test_openai_key".to_string()))
748 .enable_trading(false)
750 .enable_graph_memory(true)
751 .build()
752 .unwrap();
753
754 assert_eq!(config.app.port, 8080);
756 assert_eq!(config.app.log_level, LogLevel::Warn);
757 assert!(config.app.use_testnet);
758
759 assert_eq!(config.database.redis_url, "redis://localhost:6379");
761 assert_eq!(
762 config.database.neo4j_url,
763 Some("bolt://localhost:7687".to_string())
764 );
765
766 assert_eq!(
768 config.network.solana_rpc_url,
769 "https://api.mainnet-beta.solana.com"
770 );
771 assert_eq!(config.network.default_chain_id, 137);
772
773 assert_eq!(
775 config.providers.anthropic_api_key,
776 Some("test_anthropic_key".to_string())
777 );
778 assert_eq!(
779 config.providers.openai_api_key,
780 Some("test_openai_key".to_string())
781 );
782
783 assert!(!config.features.enable_trading);
785 assert!(config.features.enable_graph_memory);
786 }
787
788 #[test]
789 fn test_individual_setters_override_defaults() {
790 let config = ConfigBuilder::default().port(9000).build().unwrap();
792
793 assert_eq!(config.app.port, 9000);
794 assert_eq!(config.app.environment, Environment::Development);
796 }
797
798 #[test]
799 fn test_mixed_bulk_and_individual_setters() {
800 let mut app_config = AppConfig::default();
802 app_config.environment = Environment::Development; let config = ConfigBuilder::default()
805 .app(app_config)
806 .port(3333) .redis_url("redis://custom:6379".to_string())
808 .build()
809 .unwrap();
810
811 assert_eq!(config.app.port, 3333);
812 assert_eq!(config.app.environment, Environment::Development);
813 assert_eq!(config.database.redis_url, "redis://custom:6379");
814 }
815
816 #[test]
817 fn test_config_builder_development_preset() {
818 let config = ConfigBuilder::default().development().build().unwrap();
819
820 assert_eq!(config.app.environment, Environment::Development);
821 assert_eq!(config.app.log_level, LogLevel::Debug);
822 assert!(config.app.use_testnet);
823 }
824
825 #[test]
826 fn test_config_builder_staging_preset() {
827 let config = ConfigBuilder::default().staging().build().unwrap();
828
829 assert_eq!(config.app.environment, Environment::Staging);
830 assert_eq!(config.app.log_level, LogLevel::Info);
831 assert!(config.app.use_testnet);
832 }
833
834 #[test]
835 fn test_config_builder_production_preset() {
836 let config = ConfigBuilder::default()
838 .production()
839 .redis_url("redis://production-redis:6379".to_string())
840 .enable_trading(false) .build()
842 .unwrap();
843
844 assert_eq!(config.app.environment, Environment::Production);
845 assert_eq!(config.app.log_level, LogLevel::Info);
846 assert!(!config.app.use_testnet);
847 }
848
849 #[test]
850 fn test_config_builder_testnet_preset() {
851 let config = ConfigBuilder::default().testnet().build().unwrap();
852
853 assert!(config.app.use_testnet);
854 assert_eq!(config.network.default_chain_id, 5); assert_eq!(
856 config.network.solana_rpc_url,
857 "https://api.devnet.solana.com"
858 );
859 }
860
861 #[test]
862 fn test_config_builder_mainnet_preset() {
863 let config = ConfigBuilder::default().mainnet().build().unwrap();
864
865 assert!(!config.app.use_testnet);
866 assert_eq!(config.network.default_chain_id, 1); assert_eq!(
868 config.network.solana_rpc_url,
869 "https://api.mainnet-beta.solana.com"
870 );
871 }
872
873 #[test]
874 fn test_config_builder_validate_without_building() {
875 let builder = ConfigBuilder::default().development();
876
877 assert!(builder.validate().is_ok());
879
880 let config = builder.build().unwrap();
882 assert_eq!(config.app.environment, Environment::Development);
883 }
884
885 #[test]
886 fn test_config_builder_preset_chaining() {
887 let config = ConfigBuilder::default()
888 .development() .port(9000) .build()
891 .unwrap();
892
893 assert_eq!(config.app.environment, Environment::Development);
894 assert_eq!(config.app.log_level, LogLevel::Debug);
895 assert!(config.app.use_testnet);
896 assert_eq!(config.app.port, 9000); }
898
899 #[test]
900 fn test_config_builder_chaining_with_overrides() {
901 let config = ConfigBuilder::default()
902 .development() .production() .port(9000) .redis_url("redis://prod:6379".to_string()) .enable_trading(false) .build()
908 .unwrap();
909
910 assert_eq!(config.app.environment, Environment::Production);
911 assert_eq!(config.app.log_level, LogLevel::Info);
912 assert!(!config.app.use_testnet); assert_eq!(config.app.port, 9000); }
915
916 #[test]
917 fn test_config_builder_comprehensive_layering() {
918 use std::io::Write;
919 use tempfile::NamedTempFile;
920
921 let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
923 let config_content = r#"
924# App config
925port = 8081
926environment = "development"
927log_level = "info"
928use_testnet = true
929
930# Database config
931redis_url = "redis://file-redis:6379"
932
933# Network config
934solana_rpc_url = "https://file-solana-rpc.com"
935default_chain_id = 1
936
937# Features config
938enable_trading = false
939enable_graph_memory = false
940enable_bridging = false
941enable_social_monitoring = false
942enable_streaming = false
943enable_webhooks = false
944enable_analytics = false
945debug_mode = false
946experimental = false
947"#;
948 temp_file
949 .write_all(config_content.as_bytes())
950 .expect("Failed to write to temp file");
951
952 let temp_path = temp_file.path();
953
954 let file_only_config = ConfigBuilder::default()
956 .from_file(temp_path)
957 .expect("Failed to load from file")
958 .build()
959 .unwrap();
960
961 assert_eq!(
963 file_only_config.app.port, 8081,
964 "Port should come from file"
965 );
966 assert_eq!(
967 file_only_config.database.redis_url, "redis://file-redis:6379",
968 "Redis URL should come from file"
969 );
970
971 let config = ConfigBuilder::default()
973 .from_file(temp_path)
974 .expect("Failed to load from file")
975 .port(8083) .redis_url("redis://programmatic:6379".to_string()) .anthropic_api_key(Some("programmatic-key".to_string())) .build()
979 .unwrap();
980
981 assert_eq!(
984 config.app.port, 8083,
985 "Port should be set by programmatic setter"
986 );
987
988 assert_eq!(
990 config.database.redis_url, "redis://programmatic:6379",
991 "Redis URL should be set by programmatic setter"
992 );
993
994 assert_eq!(
996 config.network.solana_rpc_url, "https://file-solana-rpc.com",
997 "Solana RPC URL should be set by file"
998 );
999
1000 assert_eq!(
1002 config.providers.anthropic_api_key,
1003 Some("programmatic-key".to_string()),
1004 "Anthropic API key should be set by programmatic setter"
1005 );
1006
1007 assert!(
1009 !config.features.enable_trading,
1010 "Trading should be disabled by file config"
1011 );
1012 }
1013
1014 #[test]
1015 fn test_config_builder_merge_behavior() {
1016 use std::io::Write;
1017 use tempfile::NamedTempFile;
1018
1019 let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
1023 let config_content = r#"
1024port = 9001
1025redis_url = "redis://merge-test:6379"
1026solana_rpc_url = "https://file-rpc.com"
1027"#;
1028 temp_file
1029 .write_all(config_content.as_bytes())
1030 .expect("Failed to write to temp file");
1031
1032 let temp_path = temp_file.path();
1033
1034 let base_config = ConfigBuilder::default()
1036 .port(7000) .anthropic_api_key(Some("base-key".to_string())) .from_file(temp_path)
1039 .expect("Failed to load from file")
1040 .build()
1041 .unwrap();
1042
1043 assert_eq!(base_config.app.port, 9001, "Port should come from file");
1045 assert_eq!(
1046 base_config.database.redis_url, "redis://merge-test:6379",
1047 "Redis URL should come from file"
1048 );
1049 assert_eq!(
1050 base_config.network.solana_rpc_url, "https://file-rpc.com",
1051 "Solana RPC should come from file"
1052 );
1053 assert_eq!(
1054 base_config.providers.anthropic_api_key,
1055 Some("base-key".to_string()),
1056 "Anthropic key should remain from base builder"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_config_builder_layering_with_none_values() {
1062 use std::io::Write;
1063 use tempfile::NamedTempFile;
1064
1065 let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
1069 let config_content = r#"
1070redis_url = "redis://required:6379"
1071solana_rpc_url = "https://api.devnet.solana.com"
1072neo4j_url = "bolt://file-neo4j:7687"
1073"#;
1074 temp_file
1075 .write_all(config_content.as_bytes())
1076 .expect("Failed to write to temp file");
1077
1078 let temp_path = temp_file.path();
1079
1080 let file_only_config = ConfigBuilder::default()
1082 .from_file(temp_path)
1083 .expect("Failed to load from file")
1084 .build()
1085 .unwrap();
1086
1087 assert_eq!(
1088 file_only_config.database.neo4j_url,
1089 Some("bolt://file-neo4j:7687".to_string()),
1090 "Neo4j URL should come from file when loaded directly"
1091 );
1092
1093 let config_with_override = ConfigBuilder::default()
1095 .redis_url("redis://base:6379".to_string()) .from_file(temp_path)
1097 .expect("Failed to load from file")
1098 .redis_url("redis://override:6379".to_string()) .build()
1100 .unwrap();
1101
1102 assert_eq!(
1104 config_with_override.database.redis_url, "redis://override:6379",
1105 "Redis URL should be overridden by programmatic setter"
1106 );
1107
1108 assert_eq!(
1110 config_with_override.database.neo4j_url,
1111 Some("bolt://file-neo4j:7687".to_string()),
1112 "Neo4j URL should still come from file"
1113 );
1114 }
1115}