1mod app;
33mod builder;
34mod database;
35mod environment;
36mod error;
37mod features;
38mod network;
39mod providers;
40
41pub use app::{AppConfig, Environment, LogLevel, RetryConfig};
42pub use builder::ConfigBuilder;
43pub use database::DatabaseConfig;
44pub use environment::EnvironmentSource;
45pub use error::{ConfigError, ConfigResult};
46pub use features::{Feature, FeaturesConfig};
47pub use network::{
48 AddressValidator, ChainConfig, ChainContract, EvmNetworkConfig, NetworkConfig,
49 SolanaNetworkConfig,
50};
51pub use providers::{AiProvider, BlockchainProvider, DataProvider, ProvidersConfig};
52#[cfg(test)]
55pub mod test_helpers;
56
57use serde::{Deserialize, Serialize};
58use std::sync::Arc;
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct Config {
63 #[serde(flatten)]
65 pub app: AppConfig,
66
67 #[serde(flatten)]
69 pub database: DatabaseConfig,
70
71 #[serde(flatten)]
73 pub network: NetworkConfig,
74
75 #[serde(flatten)]
77 pub providers: ProvidersConfig,
78
79 #[serde(flatten)]
81 pub features: FeaturesConfig,
82}
83
84impl Config {
85 pub fn try_from_env() -> ConfigResult<Arc<Self>> {
97 dotenvy::dotenv().ok();
99
100 let mut config = envy::from_env::<Config>()
102 .map_err(|e| ConfigError::EnvParse(format!("Failed to parse environment: {}", e)))?;
103
104 config.network.extract_rpc_urls();
106
107 if let Err(e) = config.network.load_chain_contracts() {
109 tracing::warn!("Failed to load chain contracts: {}", e);
110 }
111
112 config.validate_config()?;
114
115 tracing::info!("✅ Configuration loaded and validated successfully");
116
117 Ok(Arc::new(config))
118 }
119
120 pub fn from_env() -> Arc<Self> {
132 match Self::try_from_env() {
133 Ok(config) => config,
134 Err(e) => {
135 eprintln!("❌ FATAL: Failed to load configuration:");
136 eprintln!(" {}", e);
137 eprintln!(" See .env.example for required variables");
138 std::process::exit(1);
139 }
140 }
141 }
142
143 pub fn validate_config(&self) -> ConfigResult<()> {
145 self.app.validate_config()?;
146 self.database.validate_config()?;
147 self.network.validate_config(None)?;
148 self.providers.validate_config()?;
149 self.features.validate_config()?;
150
151 self.validate_cross_dependencies()?;
153
154 Ok(())
155 }
156
157 fn validate_cross_dependencies(&self) -> ConfigResult<()> {
159 if self.app.environment == Environment::Production {
161 if self.database.redis_url.contains("localhost") {
163 return Err(ConfigError::validation(
164 "Production cannot use localhost for Redis",
165 ));
166 }
167
168 if self.app.use_testnet {
170 return Err(ConfigError::validation("Production cannot use testnet"));
171 }
172
173 if self.features.enable_trading && self.providers.alchemy_api_key.is_none() {
175 return Err(ConfigError::validation(
176 "Trading is enabled in production, but ALCHEMY_API_KEY is not set",
177 ));
178 }
179 }
180
181 if self.features.enable_graph_memory && self.database.neo4j_url.is_none() {
183 return Err(ConfigError::validation(
184 "Graph memory feature requires NEO4J_URL to be configured",
185 ));
186 }
187
188 if self.features.enable_bridging && self.providers.lifi_api_key.is_none() {
189 return Err(ConfigError::validation(
190 "Bridging feature requires LIFI_API_KEY to be configured",
191 ));
192 }
193
194 if self.features.enable_social_monitoring && self.providers.twitter_bearer_token.is_none() {
195 tracing::warn!("Social monitoring enabled without Twitter bearer token");
196 }
197
198 Ok(())
199 }
200
201 pub fn builder() -> ConfigBuilder {
203 ConfigBuilder::new()
204 }
205}
206
207pub mod prelude {
209 pub use crate::{
210 AddressValidator, AppConfig, ChainConfig, Config, ConfigBuilder, ConfigError, ConfigResult,
211 DatabaseConfig, Environment, Feature, FeaturesConfig, NetworkConfig, ProvidersConfig,
212 RetryConfig,
213 };
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 fn create_test_config() -> Config {
221 let mut features = FeaturesConfig::default();
222 features.enable_bridging = false; features.enable_graph_memory = false; Config {
227 app: AppConfig::default(),
228 database: DatabaseConfig::default(),
229 network: NetworkConfig::default(),
230 providers: ProvidersConfig::default(),
231 features,
232 }
233 }
234
235 fn create_production_config() -> Config {
236 let mut config = create_test_config();
237 config.app.environment = Environment::Production;
238 config
239 }
240
241 #[test]
242 fn test_config_validate_when_valid_should_return_ok() {
243 let config = create_test_config();
244 assert!(config.validate_config().is_ok());
245 }
246
247 #[test]
248 fn test_config_validate_cross_dependencies_when_production_with_localhost_redis_should_return_err(
249 ) {
250 let mut config = create_production_config();
251 config.database.redis_url = "redis://localhost:6379".to_string();
252
253 let result = config.validate_cross_dependencies();
254 assert!(result.is_err());
255 assert!(result
256 .unwrap_err()
257 .to_string()
258 .contains("Production cannot use localhost for Redis"));
259 }
260
261 #[test]
262 fn test_config_validate_cross_dependencies_when_production_with_testnet_should_return_err() {
263 let mut config = create_production_config();
264 config.app.use_testnet = true;
265 config.database.redis_url = "redis://production-redis.example.com:6379".to_string(); let result = config.validate_cross_dependencies();
268 assert!(result.is_err());
269 assert!(result
270 .unwrap_err()
271 .to_string()
272 .contains("Production cannot use testnet"));
273 }
274
275 #[test]
276 fn test_config_validate_cross_dependencies_when_graph_memory_without_neo4j_should_return_err() {
277 let mut config = create_test_config();
278 config.features.enable_graph_memory = true;
279 config.database.neo4j_url = None;
280
281 let result = config.validate_cross_dependencies();
282 assert!(result.is_err());
283 assert!(result
284 .unwrap_err()
285 .to_string()
286 .contains("Graph memory feature requires NEO4J_URL"));
287 }
288
289 #[test]
290 fn test_prod_config_missing_alchemy_key_fails() {
291 let mut config = create_production_config();
292 config.features.enable_trading = true;
293 config.providers.alchemy_api_key = None;
294 config.database.redis_url = "redis://production-redis.example.com:6379".to_string(); let result = config.validate_cross_dependencies();
298 assert!(result.is_err());
299 assert!(result
300 .unwrap_err()
301 .to_string()
302 .contains("Trading is enabled in production, but ALCHEMY_API_KEY is not set"));
303 }
304
305 #[test]
306 fn test_dev_config_missing_alchemy_key_succeeds() {
307 let mut config = create_test_config();
308 config.app.environment = Environment::Development;
309 config.features.enable_trading = true;
310 config.providers.alchemy_api_key = None;
311
312 let result = config.validate_cross_dependencies();
314 assert!(result.is_ok());
315 }
316
317 #[test]
318 fn test_config_validate_cross_dependencies_when_social_monitoring_without_twitter_should_warn()
319 {
320 let mut config = create_test_config();
321 config.features.enable_social_monitoring = true;
322 config.providers.twitter_bearer_token = None;
323
324 let result = config.validate_cross_dependencies();
326 assert!(result.is_ok());
327 }
328
329 #[test]
330 fn test_config_validate_cross_dependencies_when_valid_production_should_return_ok() {
331 let mut config = create_production_config();
332 config.database.redis_url = "redis://production-server:6379".to_string();
333 config.app.use_testnet = false;
334 config.providers.alchemy_api_key = Some("test-alchemy-key".to_string());
336
337 let result = config.validate_cross_dependencies();
338 assert!(result.is_ok(), "Validation failed: {:?}", result);
339 }
340
341 #[test]
342 fn test_config_builder_build_when_valid_should_return_ok() {
343 let result = ConfigBuilder::new().build();
344 assert!(result.is_ok());
345 }
346
347 #[test]
348 fn test_config_builder_build_when_invalid_should_return_err() {
349 let mut app_config = AppConfig::default();
350 app_config.environment = Environment::Production;
351
352 let mut database_config = DatabaseConfig::default();
353 database_config.redis_url = "redis://localhost:6379".to_string();
354
355 let result = ConfigBuilder::new()
356 .app(app_config)
357 .database(database_config)
358 .build();
359
360 assert!(result.is_err());
361 }
362
363 #[test]
364 fn test_config_builder_chaining_should_work() {
365 let app_config = AppConfig::default();
366 let database_config = DatabaseConfig::default();
367 let network_config = NetworkConfig::default();
368 let providers_config = ProvidersConfig::default();
369 let features_config = FeaturesConfig::default();
370
371 let result = ConfigBuilder::new()
372 .app(app_config)
373 .database(database_config)
374 .network(network_config)
375 .providers(providers_config)
376 .features(features_config)
377 .build();
378
379 assert!(result.is_ok());
380 }
381
382 #[test]
383 fn test_config_builder_default_should_create_valid_builder() {
384 let builder = ConfigBuilder::new();
385 let result = builder.build();
386 assert!(result.is_ok());
387 }
388
389 #[test]
390 fn test_config_builder_should_create_same_as_new() {
391 let builder1 = ConfigBuilder::new();
392 let builder2 = ConfigBuilder::new();
393
394 let config1 = builder1.build().unwrap();
395 let config2 = builder2.build().unwrap();
396
397 assert_eq!(config1.app.environment, config2.app.environment);
398 }
399
400 #[test]
401 fn test_config_validate_when_app_validation_fails_should_return_err() {
402 let mut config = create_test_config();
403 config.app.environment = Environment::Production;
406 config.database.redis_url = "redis://localhost:6379".to_string();
407
408 let result = config.validate_config();
409 assert!(result.is_err());
410 }
411
412 #[test]
413 fn test_config_validate_cross_dependencies_when_graph_memory_with_neo4j_should_return_ok() {
414 let mut config = create_test_config();
415 config.features.enable_graph_memory = true;
416 config.database.neo4j_url = Some("bolt://localhost:7687".to_string());
417
418 let result = config.validate_cross_dependencies();
419 assert!(result.is_ok());
420 }
421
422 #[test]
423 fn test_config_validate_cross_dependencies_when_development_with_localhost_should_return_ok() {
424 let mut config = create_test_config();
425 config.app.environment = Environment::Development;
426 config.database.redis_url = "redis://localhost:6379".to_string();
427
428 let result = config.validate_cross_dependencies();
429 assert!(result.is_ok());
430 }
431
432 #[test]
433 fn test_config_validate_cross_dependencies_when_staging_should_return_ok() {
434 let mut config = create_test_config();
435 config.app.environment = Environment::Staging;
436 config.database.redis_url = "redis://staging-server:6379".to_string();
437
438 let result = config.validate_cross_dependencies();
439 assert!(result.is_ok());
440 }
441
442 #[test]
443 fn test_config_builder_should_validate_during_build() {
444 let mut features_config = FeaturesConfig::default();
446 features_config.enable_graph_memory = true;
447
448 let mut database_config = DatabaseConfig::default();
449 database_config.neo4j_url = None; let result = ConfigBuilder::new()
452 .features(features_config)
453 .database(database_config)
454 .build();
455
456 assert!(result.is_err());
457 }
458
459 #[test]
460 fn test_config_validate_cross_dependencies_when_bridging_without_lifi_key_should_return_err() {
461 let mut config = create_test_config();
462 config.features.enable_bridging = true;
463 config.providers.lifi_api_key = None;
464
465 let result = config.validate_cross_dependencies();
466 assert!(result.is_err());
467 if let Err(e) = result {
468 assert!(e.to_string().contains("LIFI_API_KEY"));
469 }
470 }
471
472 #[test]
473 fn test_config_validate_cross_dependencies_when_bridging_with_lifi_key_should_return_ok() {
474 let mut config = create_test_config();
475 config.features.enable_bridging = true;
476 config.providers.lifi_api_key = Some("test-lifi-key".to_string());
477
478 let result = config.validate_cross_dependencies();
479 assert!(result.is_ok());
480 }
481
482 #[test]
483 fn test_config_validate_cross_dependencies_when_social_monitoring_without_twitter_token_should_return_ok(
484 ) {
485 let mut config = create_test_config();
486 config.features.enable_social_monitoring = true;
487 config.providers.twitter_bearer_token = None;
488
489 let result = config.validate_cross_dependencies();
491 assert!(result.is_ok());
492 }
493
494 #[test]
495 fn test_config_validate_cross_dependencies_when_social_monitoring_with_twitter_token_should_return_ok(
496 ) {
497 let mut config = create_test_config();
498 config.features.enable_social_monitoring = true;
499 config.providers.twitter_bearer_token = Some("test-twitter-token".to_string());
500
501 let result = config.validate_cross_dependencies();
502 assert!(result.is_ok());
503 }
504
505 #[test]
506 fn test_config_validate_cross_dependencies_when_bridging_disabled_without_lifi_key_should_return_ok(
507 ) {
508 let mut config = create_test_config();
509 config.features.enable_bridging = false;
510 config.providers.lifi_api_key = None;
511
512 let result = config.validate_cross_dependencies();
514 assert!(result.is_ok());
515 }
516}